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. Set the698      // pending flag the same way the create/recovery flow does; the display699      // clears it on confirm.700      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });701      setShowNewRecoveryWords(true);702    } else {703      setChangePwError(res.error ?? "failed to change password");704    }705    setChangePwSaving(false);706  }707708  const [copied, setCopied] = createSignal(false);709710  async function copyLicenseKey(key: string) {711    await navigator.clipboard.writeText(key);712    setCopied(true);713    setTimeout(() => setCopied(false), 2000);714  }715716  async function handleActivateLicense() {717    const key = licenseKeyInput().trim();718    if (!key) return;719    setActivatingLicense(true);720    setLicenseError(null);721    setLicenseSuccess(null);722    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });723    setActivatingLicense(false);724    if ((res as { ok: boolean }).ok) {725      const data = (res as { ok: boolean; data?: LicenseInfo }).data;726      if (data?.valid) {727        setLicenseInfo(data);728        setLicenseKey(key);729        setLicenseSuccess("License activated!");730        setLicenseKeyInput("");731      } else {732        setLicenseError("Invalid license key");733      }734    } else {735      setLicenseError("Activation failed");736    }737  }738739  async function handleTogglePasskeys() {740    const current = passkeyState();741    if (!current || !current.supported) return;742    setPasskeySaving(true);743    const res = await sendMessage({744      type: "SET_PASSKEY_PROVIDER_ENABLED",745      payload: { enabled: !current.enabled },746    });747    setPasskeySaving(false);748    if ((res as { ok: boolean }).ok) {749      setPasskeyState(750        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null751      );752    }753  }754755  async function handleCheckAllBreaches() {756    setCheckingBreaches(true);757    setBreachResult(null);758    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });759    setCheckingBreaches(false);760    if ((res as { ok: boolean }).ok) {761      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};762      const breached = Object.values(data).filter((count) => count > 0).length;763      setBreachResult(764        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"765      );766      props.onBreachCheckDone();767    }768  }769770771  async function handleSetupDuress() {772    const mainPw = duressMainPassword().trim();773    const duressPw = duressPassword().trim();774    const confirmPw = duressConfirm().trim();775776    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }777    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }778    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }779    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }780781    setDuressSaving(true);782    setDuressError(null);783784    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };785    if (!verifyRes.ok || !verifyRes.data) {786      setDuressError("incorrect main password");787      setDuressSaving(false);788      return;789    }790791    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };792    setDuressSaving(false);793    if (res.ok) {794      setDuressEnabled(true);795      setShowDuressSetup(false);796      setDuressPassword("");797      setDuressConfirm("");798      setDuressMainPassword("");799      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");800      setTimeout(() => setDuressSuccess(null), 5000);801    } else {802      setDuressError(803        res.error === "session_expired"804          ? "Vault session expired — lock and unlock Peach, then try again"805          : res.error ?? "failed to set duress password"806      );807    }808  }809810  async function handleClearDeclinedCards() {811    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });812    setDeclinedCardCount(0);813  }814815  async function handleCountDuplicates() {816    setDedupRunning(true);817    setDedupDone(null);818    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };819    setDedupRunning(false);820    if (res.ok) setDedupCount(res.data ?? 0);821  }822823  async function handleDeduplicate() {824    setDedupRunning(true);825    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };826    setDedupRunning(false);827    if (res.ok) {828      setDedupDone(res.data ?? 0);829      setDedupCount(0);830    }831  }832833  async function handleDisableDuress() {834    const mainPw = duressMainPassword().trim();835    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }836837    setDuressSaving(true);838    setDuressError(null);839840    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };841    if (!verifyRes.ok || !verifyRes.data) {842      setDuressError("incorrect main password");843      setDuressSaving(false);844      return;845    }846847    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };848    setDuressSaving(false);849    if (res.ok) {850      setDuressEnabled(false);851      setShowDuressSetup(false);852      setDuressMainPassword("");853      setDuressSuccess("Duress password disabled.");854      setTimeout(() => setDuressSuccess(null), 4000);855    } else {856      setDuressError(res.error ?? "failed to disable duress password");857    }858  }859860  async function handleSubmitFeedback() {861    const message = sanitizeFeedbackMessage(fbMessage());862    if (!message) return;863864    setFeedbackState('sending');865866    try {867      const category = FEEDBACK_CATEGORIES.includes(fbCategory())868        ? fbCategory()869        : FEEDBACK_CATEGORIES[0];870      const res = await fetch('https://api.web3forms.com/submit', {871        method: 'POST',872        headers: { 'Content-Type': 'application/json' },873        body: JSON.stringify({874          access_key: WEB3FORMS_KEY,875          subject: `Peach Feedback - ${category}`,876          message,877          from_name: 'Peach User',878          // Honeypot — leave blank879          botcheck: '',880        }),881      });882883      if (!res.ok) throw new Error(`HTTP ${res.status}`);884      const data = await res.json() as { success: boolean; message?: string };885      if (!data.success) throw new Error(data.message ?? 'unknown error');886887      setFeedbackState('sent');888      setFbMessage('');889      setFbCategory(FEEDBACK_CATEGORIES[0]);890    } catch (err) {891      console.error('[feedback]', err);892      setFeedbackState('error');893      setTimeout(() => setFeedbackState('idle'), 4000);894    }895  }896897  function renderSecurity() {898    return (899      <div class="peach-settings-body">900        <div class="peach-settings-group">901          <p class="peach-settings-sub-label">LOCKING</p>902          <div class="peach-settings-row">903            <span>Lock on close</span>904            <button905              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}906              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}907              aria-label="Toggle lock on close"908            >909              <span class="peach-toggle-knob"></span>910            </button>911          </div>912          <div class="peach-settings-row">913            <span>Auto-lock after</span>914            <select915              class="peach-settings-select"916              value={String(props.autoLockMinutes)}917              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}918            >919              <option value="1">1 minute</option>920              <option value="5">5 minutes</option>921              <option value="15">15 minutes</option>922              <option value="30">30 minutes</option>923              <option value="0">Never</option>924            </select>925          </div>926927          <p class="peach-settings-sub-label">QUICK UNLOCK</p>928          <div class="peach-settings-row peach-settings-row--meta">929            <span>PIN unlock</span>930            <button931              class="peach-btn peach-btn--subtle peach-btn--sm"932              onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}933            >934              {pinEnabled() ? "remove" : "set up"}935            </button>936          </div>937          <Show when={pinFlow() === "setup"}>938            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">939              <input940                class="peach-settings-input"941                type="password"942                placeholder="current password"943                value={pinCurrentPassword()}944                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}945                autocomplete="current-password"946              />947              <input948                class="peach-settings-input"949                type="password"950                inputmode="numeric"951                maxlength="12"952                placeholder="new 4–12 digit PIN"953                value={pinValue()}954                onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}955                autocomplete="off"956              />957              <input958                class="peach-settings-input"959                type="password"960                inputmode="numeric"961                maxlength="12"962                placeholder="confirm PIN"963                value={pinConfirm()}964                onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}965                autocomplete="off"966                onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}967              />968              <Show when={pinError()}>969                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>970              </Show>971              <button972                class="peach-btn peach-btn--accent peach-btn--sm"973                style="align-self:flex-start"974                onClick={() => void handleSetupPin()}975                disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}976              >977                {pinSaving() ? "saving…" : "save PIN"}978              </button>979            </div>980          </Show>981          <Show when={pinFlow() === "disable"}>982            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">983              <input984                class="peach-settings-input"985                type="password"986                placeholder="current password"987                value={pinCurrentPassword()}988                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}989                autocomplete="current-password"990                onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}991              />992              <Show when={pinError()}>993                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>994              </Show>995              <button996                class="peach-btn peach-btn--subtle peach-btn--sm"997                style="align-self:flex-start"998                onClick={() => void handleDisablePin()}999                disabled={pinSaving() || !pinCurrentPassword()}1000              >1001                {pinSaving() ? "removing…" : "remove PIN"}1002              </button>1003            </div>1004          </Show>10051006          <p class="peach-settings-sub-label">BREACH PROTECTION</p>1007          <div class="peach-settings-row">1008            <span>Breach monitoring</span>1009            <button1010              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1011              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1012              aria-label="Toggle breach monitoring"1013            >1014              <span class="peach-toggle-knob"></span>1015            </button>1016          </div>1017          <div class="peach-settings-row peach-settings-row--meta">1018            <span>Check all passwords</span>1019            <button1020              class="peach-btn peach-btn--subtle peach-btn--sm"1021              onClick={() => void handleCheckAllBreaches()}1022              disabled={checkingBreaches()}1023            >1024              {checkingBreaches() ? "checking..." : "run"}1025            </button>1026          </div>1027          <Show when={breachResult()}>1028            <div class="peach-settings-row peach-settings-row--meta">1029              <span>Last check</span>1030              <span class="peach-settings-value">{breachResult()}</span>1031            </div>1032          </Show>10331034          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1035          <div class="peach-settings-row peach-settings-row--meta">1036            <span>Master password</span>1037            <button1038              class="peach-btn peach-btn--subtle peach-btn--sm"1039              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1040            >1041              {showChangePw() ? "cancel" : "change"}1042            </button>1043          </div>1044          <Show when={showChangePw()}>1045            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1046              <input1047                class="peach-settings-input"1048                type="password"1049                placeholder="current password"1050                value={changePwCurrent()}1051                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1052                autocomplete="current-password"1053              />1054              <input1055                class="peach-settings-input"1056                type="password"1057                placeholder="new password (min 12 chars)"1058                value={changePwNew()}1059                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1060                autocomplete="new-password"1061              />1062              <input1063                class="peach-settings-input"1064                type="password"1065                placeholder="confirm new password"1066                value={changePwConfirm()}1067                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1068                autocomplete="new-password"1069                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1070              />1071              <Show when={changePwError()}>1072                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1073              </Show>1074              <Show when={changePwSuccess()}>1075                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1076              </Show>1077              <button1078                class="peach-btn peach-btn--accent peach-btn--sm"1079                style="align-self:flex-start"1080                onClick={() => void handleChangePassword()}1081                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1082              >1083                {changePwSaving() ? "saving…" : "save new password"}1084              </button>1085            </div>1086          </Show>10871088          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1089          <div class="peach-settings-row peach-settings-row--meta">1090            <span>Recovery words</span>1091            <button1092              class="peach-btn peach-btn--subtle peach-btn--sm"1093              onClick={() => {1094                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1095              }}1096            >1097              {showViewWords() ? "cancel" : "view"}1098            </button>1099          </div>1100          <Show when={showViewWords()}>1101            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1102              <Show1103                when={viewedWords()}1104                fallback={1105                  <>1106                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1107                      Enter your master password to reveal your recovery words.1108                    </p>1109                    <input1110                      class="peach-settings-input"1111                      type="password"1112                      placeholder="master password"1113                      value={viewWordsPassword()}1114                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1115                      autocomplete="current-password"1116                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1117                    />1118                    <Show when={viewWordsError()}>1119                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1120                    </Show>1121                    <button1122                      class="peach-btn peach-btn--accent peach-btn--sm"1123                      style="align-self:flex-start"1124                      onClick={() => void handleViewRecoveryWords()}1125                      disabled={viewWordsBusy() || !viewWordsPassword()}1126                    >1127                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1128                    </button>1129                  </>1130                }1131              >1132                <div class="peach-recovery-words-grid">1133                  <For each={viewedWords()!}>1134                    {(word, i) => (1135                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1136                    )}1137                  </For>1138                </div>1139                <button1140                  class="peach-btn peach-btn--subtle peach-btn--sm"1141                  style="align-self:flex-start"1142                  onClick={closeViewWords}1143                >done</button>1144              </Show>1145            </div>1146          </Show>11471148          <p class="peach-settings-sub-label">1149            DURESS PASSWORD1150            <Show when={!isPro()}>1151              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1152            </Show>1153          </p>1154          <div class="peach-settings-row peach-settings-row--top">1155            <div class="peach-settings-row-main">1156              <span>1157                Duress password1158                <Show when={!isPro()}>1159                  <span class="peach-pro-badge">Pro</span>1160                </Show>1161              </span>1162              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1163                If entered under coercion, shows a decoy empty vault instead of your real one.1164              </span>1165            </div>1166            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1167              {duressEnabled() ? "on" : "off"}1168            </div>1169          </div>11701171          <Show when={duressEnabled() && !showDuressSetup()}>1172            <div class="peach-settings-row peach-settings-row--meta">1173              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1174                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.1175              </span>1176            </div>1177          </Show>11781179          <Show when={duressSuccess()}>1180            <div class="peach-settings-row peach-settings-row--meta">1181              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1182            </div>1183          </Show>11841185          <Show when={!showDuressSetup()}>1186            <div class="peach-settings-row peach-settings-row--meta">1187              <button1188                class="peach-btn peach-btn--subtle peach-btn--sm"1189                onClick={() => {1190                  // Pro gate entry-point: free users are redirected to the1191                  // upgrade screen before the setup form opens. The server-1192                  // side SETUP_DURESS handler is the single enforcement1193                  // point; this is UI redirection so the form never appears1194                  // for a user who can't complete it.1195                  if (!isPro() && !duressEnabled()) {1196                    props.onProGate?.("duress_vault");1197                    setActiveCard("pro");1198                    return;1199                  }1200                  setShowDuressSetup(true);1201                  setDuressError(null);1202                  setDuressSuccess(null);1203                  setDuressPassword("");1204                  setDuressConfirm("");1205                  setDuressMainPassword("");1206                }}1207              >1208                {duressEnabled() ? "Change / disable" : "Set up"}1209              </button>1210            </div>1211          </Show>12121213          <Show when={showDuressSetup()}>1214            <div class="peach-duress-form">1215              <label class="peach-label">main password (to confirm)</label>1216              <input1217                class="peach-settings-input"1218                type="password"1219                placeholder="your current master password"1220                autocomplete="current-password"1221                value={duressMainPassword()}1222                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1223              />1224              <Show when={!duressEnabled()}>1225                <label class="peach-label">new duress password</label>1226                <input1227                  class="peach-settings-input"1228                  type="password"1229                  placeholder="duress password (min 8 chars)"1230                  autocomplete="new-password"1231                  value={duressPassword()}1232                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1233                />1234                <label class="peach-label">confirm duress password</label>1235                <input1236                  class="peach-settings-input"1237                  type="password"1238                  placeholder="confirm duress password"1239                  autocomplete="new-password"1240                  value={duressConfirm()}1241                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1242                />1243              </Show>12441245              <Show when={duressError()}>1246                <p class="peach-hint peach-hint-error">{duressError()}</p>1247              </Show>12481249              <div class="peach-duress-actions">1250                <button1251                  class="peach-btn peach-btn--subtle peach-btn--sm"1252                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1253                >1254                  cancel1255                </button>1256                <Show when={duressEnabled()}>1257                  <button1258                    class="peach-btn peach-btn--ghost peach-btn--sm"1259                    style="color:var(--peach-breach)"1260                    onClick={() => void handleDisableDuress()}1261                    disabled={duressSaving()}1262                  >1263                    {duressSaving() ? "disabling..." : "disable duress"}1264                  </button>1265                </Show>1266                <Show when={!duressEnabled()}>1267                  <button1268                    class="peach-btn peach-btn--accent peach-btn--sm"1269                    onClick={() => void handleSetupDuress()}1270                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1271                  >1272                    {duressSaving() ? "saving..." : "save"}1273                  </button>1274                </Show>1275              </div>1276            </div>1277          </Show>12781279          <p class="peach-settings-sub-label">1280            CANARY ENTRIES1281            <Show when={!isPro()}>1282              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1283            </Show>1284          </p>1285          <div class="peach-settings-row peach-settings-row--top">1286            <div class="peach-settings-row-main">1287              <span>1288                Canary entries1289                <Show when={!isPro()}>1290                  <span class="peach-pro-badge">Pro</span>1291                </Show>1292              </span>1293              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1294                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1295              </span>1296            </div>1297            <span class="peach-settings-value" style="flex-shrink:0">1298              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1299            </span>1300          </div>1301          <Show when={canaryEntries().length > 0}>1302            <For each={canaryEntries()}>1303              {(entry) => (1304                <div class="peach-settings-row peach-settings-row--meta">1305                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1306                  <button1307                    class="peach-btn peach-btn--subtle peach-btn--sm"1308                    onClick={() => void handleTestCanary(entry)}1309                    disabled={canaryTestingId() === entry.id}1310                    style="flex-shrink:0"1311                  >1312                    {canaryTestingId() === entry.id1313                      ? "sending…"1314                      : canaryTestResult()?.id === entry.id1315                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1316                        : "test"}1317                  </button>1318                </div>1319              )}1320            </For>1321          </Show>13221323          <p class="peach-settings-sub-label">PASSKEYS</p>1324          <div class="peach-settings-row peach-settings-row--top">1325            <div class="peach-settings-row-main">1326              <span>Intercept passkey requests</span>1327              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1328                Peach can handle passkey sign-in and creation on supported Chromium browsers.1329              </span>1330            </div>1331            <button1332              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1333              onClick={() => void handleTogglePasskeys()}1334              aria-label="Toggle passkey interception"1335              disabled={!passkeyState()?.supported || passkeySaving()}1336            >1337              <span class="peach-toggle-knob"></span>1338            </button>1339          </div>1340          <div class="peach-settings-row peach-settings-row--meta">1341            <span>Status</span>1342            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1343          </div>1344          <div class="peach-settings-row peach-settings-row--meta">1345            <span>Browser</span>1346            <span class="peach-settings-value">1347              {passkeyState()?.browserLabel ?? "Unknown"}1348              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1349            </span>1350          </div>1351          <div class="peach-info-notice" style="margin-top:8px">1352            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1353          </div>1354          <Show when={passkeyState()?.conflict}>1355            <div class="peach-settings-row peach-settings-row--meta">1356              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1357            </div>1358          </Show>1359        </div>1360      </div>1361    );1362  }13631364  function renderVault() {1365    return (1366      <div class="peach-settings-body">1367        <div class="peach-settings-group">1368          <p class="peach-settings-sub-label">ENTRIES</p>1369          <div class="peach-settings-row peach-settings-row--meta">1370            <span>Entries</span>1371            <span class="peach-settings-value">{props.entriesCount}</span>1372          </div>1373          <div class="peach-settings-row peach-settings-row--meta">1374            <span>Duplicates</span>1375            <Show when={dedupDone() !== null} fallback={1376              <button1377                class="peach-btn peach-btn--subtle peach-btn--sm"1378                onClick={() => void handleCountDuplicates()}1379                disabled={dedupRunning()}1380              >1381                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1382              </button>1383            }>1384              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1385                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1386              </span>1387            </Show>1388          </div>1389          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1390            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1391              <div class="peach-restore-warning">1392                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1393              </div>1394              <div style="display:flex;gap:6px">1395                <button1396                  class="peach-btn peach-btn--subtle peach-btn--sm"1397                  onClick={() => setDedupCount(null)}1398                >1399                  cancel1400                </button>1401                <button1402                  class="peach-btn peach-btn--accent peach-btn--sm"1403                  onClick={() => void handleDeduplicate()}1404                  disabled={dedupRunning()}1405                >1406                  {dedupRunning() ? "removing..." : "remove duplicates"}1407                </button>1408              </div>1409            </div>1410          </Show>14111412          <p class="peach-settings-sub-label">MAINTENANCE</p>1413          <div class="peach-settings-row peach-settings-row--meta">1414            <span>Trash auto-delete</span>1415            <select1416              class="peach-settings-select"1417              value={String(props.trashRetentionDays)}1418              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1419            >1420              <option value="7">7 days</option>1421              <option value="30">30 days</option>1422              <option value="90">90 days</option>1423              <option value="0">Never</option>1424            </select>1425          </div>1426          <div class="peach-settings-row peach-settings-row--meta">1427            <span>Advanced Vault Health</span>1428            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1429              open1430            </button>1431          </div>1432          <Show when={declinedCardCount() > 0}>1433            <div class="peach-settings-row peach-settings-row--meta">1434              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1435              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1436                Clear1437              </button>1438            </div>1439          </Show>14401441          <p class="peach-settings-sub-label">DATA</p>1442          <div class="peach-settings-row peach-settings-row--meta">1443            <span>Import passwords</span>1444            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1445              open1446            </button>1447          </div>1448          <div class="peach-settings-row peach-settings-row--meta">1449            <span>Export backup</span>1450            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1451              open1452            </button>1453          </div>1454          <div class="peach-settings-row peach-settings-row--meta">1455            <span>1456              Peach Codex1457              <Show when={!isPro()}>1458                <span class="peach-pro-badge">Pro</span>1459              </Show>1460            </span>1461            <button1462              class="peach-btn peach-btn--subtle peach-btn--sm"1463              onClick={() => props.onOpenCodexGenerate?.()}1464            >1465              open1466            </button>1467          </div>1468          {/* Restore from Codex: intentionally never gated — a printed1469              Codex must remain restorable regardless of license state. */}1470          <div class="peach-settings-row peach-settings-row--meta">1471            <span>Restore from Codex</span>1472            <button1473              class="peach-btn peach-btn--subtle peach-btn--sm"1474              onClick={() => props.onOpenCodexRestore?.()}1475            >1476              open1477            </button>1478          </div>14791480          <span class="peach-settings-sub-label">VAULT HISTORY</span>1481          <Show when={historyLoading()}>1482            <p class="peach-settings-hint">Loading…</p>1483          </Show>1484          <Show when={!historyLoading() && historySnapshots().length === 0}>1485            <p class="peach-settings-hint">1486              No snapshots yet. Peach saves automatically before every change —1487              saves, syncs, and imports.1488            </p>1489          </Show>1490          <Show when={!historyLoading() && historySnapshots().length > 0}>1491            <p class="peach-settings-hint">1492              Restoring locks your vault. Unlock with the password that1493              encrypted that snapshot to confirm.1494            </p>1495            <div class="peach-history-list">1496              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1497                {(snap) => (1498                  <div class="peach-history-row">1499                    <div class="peach-history-meta">1500                      <span class="peach-history-time">1501                        {formatSnapshotTime(snap.savedAt)}1502                      </span>1503                      <span class="peach-history-sub">1504                        {[1505                          snap.entryCount !== null1506                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1507                            : null,1508                          reasonLabel(snap.reason),1509                        ]1510                          .filter(Boolean)1511                          .join(" · ")}1512                      </span>1513                    </div>1514                    <button1515                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1516                        historyRestoring() === snap.id ? " is-busy" : ""1517                      }`}1518                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1519                      disabled={historyRestoring() !== null}1520                    >1521                      <Show when={historyRestoring() === snap.id} fallback="Restore">1522                        …1523                      </Show>1524                    </button>1525                  </div>1526                )}1527              </For>1528            </div>1529            <Show when={historySnapshots().length > 3}>1530              <button1531                class="peach-settings-advanced-toggle"1532                onClick={() => setHistoryShowAll((v) => !v)}1533                aria-expanded={historyShowAll()}1534              >1535                <span class="peach-settings-hint" style="margin:0">1536                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1537                </span>1538                <span class="peach-settings-advanced-chevron">1539                  {historyShowAll() ? "▲" : "▼"}1540                </span>1541              </button>1542            </Show>1543            <p class="peach-history-footer">1544              {historySnapshots().length} of 10 snapshots1545            </p>1546          </Show>15471548          <button1549            class="peach-settings-advanced-toggle"1550            onClick={() => setShowVaultAdvanced((v) => !v)}1551            aria-expanded={showVaultAdvanced()}1552          >1553            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1554            <span class="peach-settings-advanced-chevron">1555              {showVaultAdvanced() ? "▲" : "▼"}1556            </span>1557          </button>15581559          <Show when={showVaultAdvanced()}>1560            <div class="peach-danger-card">1561              <div class="peach-danger-card-head">1562                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1563                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1564              </div>1565              <p class="peach-danger-card-copy">1566                Wipes the current vault and restores from a backup file.1567                Recoverable only if your backup is good.1568              </p>1569              <button1570                class="peach-danger-card-button"1571                onClick={() => props.onOpenFileImport?.()}1572              >1573                Choose backup file1574              </button>1575            </div>15761577            <Show when={props.onOpenPanic}>1578              <div class="peach-danger-card peach-danger-card--hazard">1579                <div class="peach-danger-card-head">1580                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1581                  <h3 class="peach-danger-card-title">Destroy vault</h3>1582                </div>1583                <p class="peach-danger-card-copy">1584                  Permanently wipes every entry, setting, and license on this1585                  device. There is no recovery.1586                </p>1587                <button1588                  class="peach-danger-card-button"1589                  onClick={() => props.onOpenPanic?.()}1590                >1591                  Begin destruction1592                </button>1593              </div>1594            </Show>1595          </Show>1596        </div>1597      </div>1598    );1599  }16001601  function renderSync() {1602    return (1603      <div class="peach-settings-body">1604        <div class="peach-settings-group">1605          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1606          <div1607            class="peach-settings-row peach-settings-row--meta"1608            style="align-items:center;gap:8px"1609            role="status"1610            aria-label={syncLightLabel()}1611            title={syncLightLabel()}1612          >1613            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1614            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1615              {syncFlash() ?? lastSyncLine()}1616            </span>1617          </div>1618          <Show when={pairedDevices().length > 0}>1619            <div class="peach-settings-row peach-settings-row--meta">1620              <span class="peach-settings-value" style="color:var(--peach-t2)">1621                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01622                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1623                  : ""}1624              </span>1625              <button1626                class="peach-btn peach-btn--subtle peach-btn--sm"1627                aria-label="Sync this vault now"1628                disabled={syncStatus() === "syncing"}1629                onClick={() => void syncNow()}1630              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1631            </div>1632          </Show>16331634          <p class="peach-settings-sub-label">ADD A DEVICE</p>1635          <button1636            class="peach-sync-pair-option"1637            onClick={() => props.onOpenSyncPairing?.("code")}1638          >1639            <span class="peach-sync-pair-option-icon" aria-hidden="true">1640              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1641                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1642                <path d="M9 15 L11 15" />1643              </svg>1644            </span>1645            <span class="peach-sync-pair-option-main">1646              <span class="peach-sync-pair-option-title">Show a code</span>1647              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1648            </span>1649            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1650          </button>1651          <Show when={!isPro()}>1652            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1653              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1654            </p>1655          </Show>16561657          <Show when={pairedDevices().length > 0}>1658            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1659            <For each={pairedDevices()}>1660              {(d) => (1661                <div class="peach-settings-row peach-settings-row--top">1662                  <div class="peach-settings-row-main">1663                    <Show1664                      when={renameDeviceId() === d.deviceId}1665                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1666                    >1667                      <input1668                        class="peach-settings-input"1669                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1670                        maxlength={64}1671                        value={renameDraft()}1672                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1673                        onKeyDown={(event) => {1674                          if (event.key === "Enter") void renameDevice(d.deviceId);1675                          if (event.key === "Escape") setRenameDeviceId(null);1676                        }}1677                      />1678                    </Show>1679                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1680                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1681                    </span>1682                  </div>1683                  <Show1684                    when={confirmUnpair() === d.deviceId}1685                    fallback={1686                      <div style="display:flex;gap:4px;flex-shrink:0">1687                        <Show1688                          when={renameDeviceId() === d.deviceId}1689                          fallback={1690                            <button1691                              class="peach-btn peach-btn--subtle peach-btn--sm"1692                              onClick={() => {1693                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1694                                setRenameDeviceId(d.deviceId);1695                              }}1696                            >rename</button>1697                          }1698                        >1699                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1700                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1701                        </Show>1702                        <button1703                          class="peach-btn peach-btn--subtle peach-btn--sm"1704                          onClick={() => setConfirmUnpair(d.deviceId)}1705                        >remove</button>1706                      </div>1707                    }1708                  >1709                    {/* HOLE 4: revocation stops this device receiving future1710                        changes. It does NOT secure the vault against it — that1711                        device may already hold the recovery words, which are1712                        master-password-equivalent and restore the whole vault1713                        anywhere, forever, with no sync involved. Say so here,1714                        rather than leaving the user falsely reassured. */}1715                    <div class="peach-unpair-confirm">1716                      <p class="peach-unpair-confirm-note">1717                        Stops this device receiving future changes. It doesn't1718                        lock it out of what it already has — if it's lost or1719                        stolen, also change your master password and generate1720                        new recovery words.1721                      </p>1722                      <div style="display:flex;gap:6px;justify-content:flex-end">1723                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1724                        <button1725                          class="peach-btn peach-btn--sm"1726                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1727                          onClick={() => void unpairDevice(d.deviceId)}1728                        >remove</button>1729                      </div>1730                    </div>1731                  </Show>1732                </div>1733              )}1734            </For>1735          </Show>17361737          <Show when={pendingVouched().length > 0}>1738            <p class="peach-settings-sub-label">1739              PENDING DEVICES1740              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1741            </p>1742            <For each={pendingVouched()}>1743              {(dev) => (1744                <div class="peach-settings-row peach-settings-row--top">1745                  <div class="peach-settings-row-main">1746                    <span>{dev.deviceName}</span>1747                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1748                      Was added by another of your devices. Allow it to sync with this vault?1749                    </span>1750                  </div>1751                  <div style="display:flex;gap:6px;flex-shrink:0">1752                    <button1753                      class="peach-btn peach-btn--sm"1754                      disabled={vouchedBusy() === dev.deviceId}1755                      onClick={() => void confirmVouched(dev.deviceId)}1756                    >Allow</button>1757                    <button1758                      class="peach-btn peach-btn--subtle peach-btn--sm"1759                      disabled={vouchedBusy() === dev.deviceId}1760                      onClick={() => void rejectVouched(dev.deviceId)}1761                    >Reject</button>1762                  </div>1763                </div>1764              )}1765            </For>1766          </Show>17671768          <button1769            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1770            onClick={() => setShowSyncConnection((value) => !value)}1771            aria-expanded={showSyncConnection()}1772            aria-controls="peach-sync-connection"1773          >1774            <span>1775              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1776              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1777            </span>1778            <span class="peach-settings-advanced-chevron">1779              {showSyncConnection() ? "▲" : "▼"}1780            </span>1781          </button>1782          <Show when={showSyncConnection()}>1783            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1784              <SyncModeSelector />1785            </div>1786          </Show>17871788          <button1789            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1790            onClick={() => setShowSyncRecovery((value) => !value)}1791            aria-expanded={showSyncRecovery()}1792            aria-controls="peach-sync-recovery"1793          >1794            <span>1795              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1796              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1797            </span>1798            <span class="peach-settings-advanced-chevron">1799              {showSyncRecovery() ? "▲" : "▼"}1800            </span>1801          </button>1802          <Show when={showSyncRecovery()}>1803            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1804              <Show1805                when={rotateConfirmOpen()}1806                fallback={1807                  <div class="peach-settings-row peach-settings-row--top">1808                    <div class="peach-settings-row-main">1809                      <span>Rotate sync identity</span>1810                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1811                        Disconnects every device and replaces the keys used for sync.1812                      </span>1813                    </div>1814                    <button1815                      class="peach-btn peach-btn--subtle peach-btn--sm"1816                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1817                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1818                    >rotate</button>1819                  </div>1820                }1821              >1822                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1823                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1824                  <Show when={rotateError()}>1825                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1826                  </Show>1827                  <div style="display:flex;gap:6px;margin-top:10px">1828                    <button1829                      class="peach-btn peach-btn--subtle peach-btn--sm"1830                      disabled={rotating()}1831                      onClick={() => setRotateConfirmOpen(false)}1832                    >Cancel</button>1833                    <button1834                      class="peach-btn peach-btn--sm"1835                      style="background:var(--peach-breach);border-color:var(--peach-breach)"1836                      disabled={rotating()}1837                      onClick={() => void doRotateSyncIdentity()}1838                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1839                  </div>1840                </div>1841              </Show>1842            </div>1843          </Show>1844        </div>1845      </div>1846    );1847  }18481849  function renderAppearance() {1850    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1851      { id: "medium", label: "Default" },1852      { id: "large", label: "Large" },1853    ];1854    return (1855      <div class="peach-settings-body">1856        <div class="peach-settings-group">1857          <p class="peach-settings-sub-label">THEME</p>1858          <div class="peach-settings-row peach-settings-row--meta">1859            <span>Theme</span>1860            <button1861              class="peach-btn peach-btn--subtle peach-btn--sm"1862              onClick={() =>1863                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1864              }1865            >1866              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1867            </button>1868          </div>18691870          <p class="peach-settings-sub-label">ACCENT</p>1871          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1872            <span>Accent</span>1873            <div class="peach-accent-picker">1874              <For each={ACCENT_COLORS}>1875                {(color) => (1876                  <button1877                    class="peach-accent-swatch peach-swatch"1878                    classList={{ "is-active": props.accentColor === color.name }}1879                    style={{ background: color.hex }}1880                    onClick={() => void props.onAccentChange(color.name)}1881                    aria-label={color.name}1882                    title={color.name}1883                  />1884                )}1885              </For>1886            </div>1887          </div>18881889          <p class="peach-settings-sub-label">TEXT SIZE</p>1890          <div class="peach-settings-row peach-settings-row--meta">1891            <span>Size</span>1892            <div class="peach-pill-group">1893              <For each={sizeOptions}>1894                {(opt) => (1895                  <button1896                    class="peach-btn peach-btn--ghost peach-btn--sm"1897                    classList={{ "is-active": props.textSize === opt.id }}1898                    onClick={() => void props.onTextSizeChange(opt.id)}1899                  >1900                    {opt.label}1901                  </button>1902                )}1903              </For>1904            </div>1905          </div>19061907          <p class="peach-settings-sub-label">MOTION</p>1908          <div class="peach-settings-row peach-settings-row--meta">1909            <span>Reduce motion</span>1910            <button1911              class="peach-btn peach-btn--subtle peach-btn--sm"1912              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1913            >1914              {props.reduceMotion ? "On" : "Off"}1915            </button>1916          </div>1917        </div>1918      </div>1919    );1920  }19211922  function renderShortcuts() {1923    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1924    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);19251926    function startCapture(action: ShortcutAction) {1927      setConflictMsg(null);1928      setCapturingAction(action);1929    }1930    function cancelCapture() {1931      setCapturingAction(null);1932      setConflictMsg(null);1933    }1934    function commitBinding(action: ShortcutAction, binding: Keybinding) {1935      const conflict = findConflict(props.shortcuts, binding, action);1936      if (conflict) {1937        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1938        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1939        return;1940      }1941      const next = { ...props.shortcuts, [action]: binding };1942      setCapturingAction(null);1943      setConflictMsg(null);1944      void props.onShortcutsChange(next);1945    }1946    function resetAction(action: ShortcutAction) {1947      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1948      void props.onShortcutsChange(next);1949    }1950    function resetAll() {1951      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1952    }19531954    // Capture-mode key handler — wired via ref onto the chip element so it1955    // catches the next non-modifier keypress and ignores other bindings.1956    function handleCaptureKey(e: KeyboardEvent) {1957      e.preventDefault();1958      e.stopPropagation();1959      if (e.key === "Escape") { cancelCapture(); return; }1960      const binding = keybindingFromEvent(e);1961      if (!binding) return; // bare modifier — keep waiting1962      const action = capturingAction();1963      if (action) commitBinding(action, binding);1964    }19651966    return (1967      <div class="peach-settings-body">1968        <div class="peach-settings-group peach-shortcuts-group">1969          <p class="peach-settings-sub-label">ENTRY VIEW</p>1970          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1971            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1972          </For>1973        </div>19741975        <div class="peach-settings-group peach-shortcuts-group">1976          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1977          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1978            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1979          </For>1980        </div>19811982        <Show when={conflictMsg()}>1983          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1984        </Show>19851986        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1987          <div class="peach-settings-row peach-settings-row--meta">1988            <span>Inline autofill on page</span>1989            <a1990              class="peach-settings-link"1991              href="chrome://extensions/shortcuts"1992              onClick={(e) => {1993                e.preventDefault();1994                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1995              }}1996            >1997              ⌘⇧P — customize ↗1998            </a>1999          </div>2000          <button2001            class="peach-btn peach-btn--subtle peach-btn--sm"2002            style="align-self:flex-start"2003            onClick={resetAll}2004          >2005            Reset all to defaults2006          </button>2007        </div>2008      </div>2009    );20102011    function renderRow(2012      desc: typeof SHORTCUT_DESCRIPTORS[number],2013      capturing: () => ShortcutAction | null,2014      onStart: (a: ShortcutAction) => void,2015      onCancel: () => void,2016      onReset: (a: ShortcutAction) => void,2017      onKey: (e: KeyboardEvent) => void,2018    ) {2019      const isActive = () => capturing() === desc.action;2020      const binding = () => props.shortcuts[desc.action];2021      const isCustom = () => {2022        const b = binding();2023        const d = DEFAULT_BINDINGS[desc.action];2024        return b.key !== d.key2025          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2026          || Boolean(b.meta)  !== Boolean(d.meta)2027          || Boolean(b.shift) !== Boolean(d.shift)2028          || Boolean(b.alt)   !== Boolean(d.alt);2029      };2030      return (2031        <div class="peach-shortcut-row">2032          <span class="peach-shortcut-row-label">{desc.label}</span>2033          <button2034            class="peach-shortcut-chip"2035            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2036            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2037            onKeyDown={(e) => { if (isActive()) onKey(e); }}2038            ref={(el) => {2039              // Auto-focus when entering capture mode so the keydown listener catches the next press.2040              if (el) {2041                queueMicrotask(() => { if (isActive()) el.focus(); });2042              }2043            }}2044          >2045            {isActive() ? "Press a key…" : formatKeybinding(binding())}2046          </button>2047          <Show when={isCustom() && !isActive()}>2048            <button2049              class="peach-shortcut-reset"2050              title="Reset to default"2051              onClick={() => onReset(desc.action)}2052            >2053              ↺2054            </button>2055          </Show>2056        </div>2057      );2058    }2059  }20602061  const PRO_FEATURES: Array<{2062    feature: ProFeature;2063    name: string;2064    desc: string;2065  }> = [2066    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2067    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2068    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2069    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2070  ];20712072  const PRO_SECURITY_FEATURES: Array<{2073    feature: ProFeature;2074    name: string;2075    desc: string;2076  }> = [2077    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2078    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2079  ];20802081  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2082  const [showKey, setShowKey] = createSignal(false);20832084  const [deactivating, setDeactivating] = createSignal(false);20852086  async function handleDeactivateLicense() {2087    setDeactivating(true);2088    await sendMessage({ type: "DEACTIVATE_LICENSE" });2089    setLicenseInfo(null);2090    await props.onLicenseChange?.();2091    setDeactivating(false);2092  }20932094  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2095    setCanaryTestingId(entry.id);2096    setCanaryTestResult(null);2097    const res = await sendMessage({2098      type: "FIRE_CANARY_PING",2099      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2100    }) as { ok: boolean };2101    setCanaryTestResult({ id: entry.id, ok: res.ok });2102    setCanaryTestingId(null);2103    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2104  }21052106  function renderLicense() {2107    async function onActivate() {2108      await handleActivateLicense();2109      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2110        await props.onLicenseChange?.();2111      }2112    }21132114    return (2115      <div class="peach-settings-body">2116        <Show2117          when={isPro()}2118          fallback={2119                <div class="peach-pro-upgrade">2120                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>21212122                  <h2 class="peach-pro-headline">2123                    Every<em>thing,</em><br />every<em>where.</em>2124                  </h2>21252126                  <p class="peach-pro-subtitle">2127                    One key unlocks every Pro feature — for the lifetime of the app.2128                  </p>21292130                  <div class="peach-pro-grid">2131                    <For each={PRO_FEATURES}>2132                      {(item, i) => (2133                        <div2134                          class="peach-pro-grid-item"2135                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2136                        >2137                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2138                          <span class="peach-pro-grid-name">{item.name}</span>2139                          <span class="peach-pro-grid-desc">{item.desc}</span>2140                        </div>2141                      )}2142                    </For>2143                  </div>21442145                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2146                  <div class="peach-pro-grid">2147                    <For each={PRO_SECURITY_FEATURES}>2148                      {(item, i) => (2149                        <div2150                          class="peach-pro-grid-item"2151                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2152                        >2153                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2154                          <span class="peach-pro-grid-name">{item.name}</span>2155                          <span class="peach-pro-grid-desc">{item.desc}</span>2156                        </div>2157                      )}2158                    </For>2159                  </div>21602161                  <div class="peach-pro-upgrade-actions">2162                    <p class="peach-pro-pricing">2163                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2164                    </p>2165                    <a2166                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2167                      target="_blank"2168                      rel="noopener noreferrer"2169                      class="peach-pro-upgrade-cta"2170                    >2171                      Get Peach Pro2172                    </a>2173                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2174                    <div class="peach-pro-key-inline">2175                      <input2176                        class="peach-pro-key-inline-input"2177                        type="text"2178                        placeholder="PEACH-XXXX-XXXX-XXXX"2179                        value={licenseKeyInput()}2180                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2181                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2182                        autocomplete="off"2183                        spellcheck={false}2184                      />2185                      <button2186                        class="peach-pro-key-apply"2187                        onClick={() => void onActivate()}2188                        disabled={activatingLicense() || !licenseKeyInput().trim()}2189                      >2190                        {activatingLicense() ? "…" : "APPLY"}2191                      </button>2192                    </div>2193                    <Show when={licenseError()}>2194                      <p class="peach-pro-key-error">{licenseError()}</p>2195                    </Show>2196                  </div>2197                </div>2198          }2199        >2200          <div class="peach-pro-active">2201            <div class="peach-pro-active-hero">2202              <button2203                class="peach-pro-active-mark-wrap"2204                type="button"2205                aria-label="Celebrate"2206                onClick={handleProCelebrationClick}2207              >2208                <PeachMark class="peach-pro-active-mark" />2209                <Show when={justActivated()}>2210                  <div class="peach-pro-sparks" aria-hidden="true">2211                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2212                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2213                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2214                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2215                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2216                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2217                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2218                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2219                  </div>2220                </Show>2221              </button>2222              <h2 class="peach-pro-active-title">Peach Pro</h2>2223              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2224              <Show when={justActivated()}>2225                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2226              </Show>2227            </div>22282229            <Show when={licenseKey()}>2230              <div class="peach-settings-license">2231                <span class="peach-settings-label">LICENSE KEY</span>2232                <div class="peach-settings-license-row">2233                  <Show2234                    when={showKey()}2235                    fallback={2236                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2237                        ••••••••••••••••••••••••••••2238                      </span>2239                    }2240                  >2241                    <span class="peach-settings-license-key">{licenseKey()!}</span>2242                  </Show>2243                  <div class="peach-settings-license-actions">2244                    <button2245                      class="peach-settings-license-copy"2246                      title="Copy license key"2247                      onClick={() => copyLicenseKey(licenseKey()!)}2248                    >2249                      {copied() ? "✓" : "copy"}2250                    </button>2251                    <button2252                      class="peach-settings-license-toggle"2253                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2254                      title={showKey() ? "Hide" : "Reveal"}2255                      onClick={() => setShowKey(v => !v)}2256                    >2257                      <Show2258                        when={showKey()}2259                        fallback={2260                          <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">2261                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2262                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2263                          </svg>2264                        }2265                      >2266                        <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">2267                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2268                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2269                          <line x1="2" y1="2" x2="12" y2="12" />2270                        </svg>2271                      </Show>2272                    </button>2273                  </div>2274                </div>2275              </div>2276            </Show>22772278            <div class="peach-pro-active-features">2279              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2280                {(item) => (2281                  <div class="peach-pro-active-feature">2282                    <span class="peach-pro-active-feature-mark">✦</span>2283                    <span class="peach-pro-active-feature-name">{item.name}</span>2284                  </div>2285                )}2286              </For>2287            </div>22882289            <button2290              class="peach-pro-deactivate"2291              onClick={() => void handleDeactivateLicense()}2292              disabled={deactivating()}2293            >2294              {deactivating() ? "Deactivating…" : "Deactivate"}2295            </button>2296          </div>2297        </Show>2298      </div>2299    );2300  }23012302  function renderFeedback() {2303    return (2304      <div class="peach-settings-body">2305        <div class="peach-settings-group peach-feedback-group">2306          <Show2307            when={feedbackState() === 'sent'}2308            fallback={2309              <>2310                <p class="peach-feedback-intro">2311                  Help us improve Peach. Every report goes directly to our inbox.2312                </p>23132314                <p class="peach-settings-sub-label">TYPE</p>2315                <div class="peach-feedback-chips">2316                  <For each={FEEDBACK_CATEGORIES}>2317                    {(cat) => {2318                      const selected = () => fbCategory() === cat;2319                      return (2320                        <button2321                          type="button"2322                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2323                          aria-pressed={selected()}2324                          onClick={() => setFbCategory(cat)}2325                        >2326                          {cat}2327                        </button>2328                      );2329                    }}2330                  </For>2331                </div>23322333                <p class="peach-settings-sub-label">MESSAGE</p>2334                <textarea2335                  class="peach-feedback-textarea"2336                  placeholder="Describe the issue or idea..."2337                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2338                  value={fbMessage()}2339                  onInput={(e) => setFbMessage(e.currentTarget.value)}2340                />23412342                <p class="peach-feedback-meta">2343                  Version {version} · will be included automatically2344                </p>23452346                <button2347                  type="button"2348                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2349                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2350                  onClick={() => void handleSubmitFeedback()}2351                >2352                  <Switch>2353                    <Match when={feedbackState() === 'idle'}>Send</Match>2354                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2355                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2356                  </Switch>2357                </button>23582359                <p class="peach-feedback-anon">2360                  Sent anonymously · no account required2361                </p>2362              </>2363            }2364          >2365            <div class="peach-feedback-thanks" role="status" aria-live="polite">2366              <p class="peach-feedback-thanks-title">Thank you</p>2367              <p>2368                Your note is in the queue. Someone will process it soon.2369              </p>2370            </div>2371          </Show>2372        </div>2373      </div>2374    );2375  }23762377  const bodies: StackBodies = {2378    protection: renderSecurity(),2379    vault: renderVault(),2380    sync: renderSync(),2381    appearance: renderAppearance(),2382    shortcuts: renderShortcuts(),2383    pro: renderLicense(),2384    feedback: renderFeedback(),2385  };23862387  return (2388    <Show2389      when={showNewRecoveryWords()}2390      fallback={2391        <div2392          class="peach-overlay-view peach-settings-overlay"2393          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2394          style="display:flex;flex-direction:column"2395        >2396          <StackDeck2397            active={activeCard()}2398            onActiveChange={setActiveCard}2399            onBack={props.onBack}2400            cards={cards()}2401            bodies={bodies}2402            version={version}2403            isFanning={isFanning()}2404            celebratingCardId={justActivated() ? "pro" : null}2405            isPro={isPro()}2406          />2407        </div>2408      }2409    >2410      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2411    </Show>2412  );2413}