Settings.tsx

72.0 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10  AccentColorName,11  PasskeyProviderState,12  ProFeature,13  VaultSnapshot,14} from "../../shared/types";15import {16  DEFAULT_BINDINGS,17  findConflict,18  formatKeybinding,19  keybindingFromEvent,20  SHORTCUT_DESCRIPTORS,21  type Keybinding,22  type ShortcutAction,23} from "../../shared/shortcuts";24import { isNativePlatform, nativePlugin } from "../../shared/native";25import { WEB3FORMS_KEY } from "../../shared/config";2627interface LicenseInfo {28  valid: boolean;29  plan: "free" | "pro";30  issued?: string;31}3233interface SettingsProps {34  entriesCount: number;35  breachMonitoring: boolean;36  lockOnClose: boolean;37  autoLockMinutes: number;38  trashRetentionDays: number;39  theme: "dark" | "light";40  accentColor: AccentColorName;41  textSize: "small" | "medium" | "large";42  reduceMotion: boolean;43  isPro?: boolean;44  initialSection?: string | null;45  upgradeHighlight?: ProFeature | null;46  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;47  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;48  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;49  onTrashRetentionChange: (days: number) => void | Promise<void>;50  onThemeChange: (theme: "dark" | "light") => void;51  onAccentChange: (name: AccentColorName) => void | Promise<void>;52  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;53  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;54  shortcuts: Record<ShortcutAction, Keybinding>;55  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;56  onLicenseChange?: () => void | Promise<void>;57  isExiting?: boolean;58  onBack: () => void;59  onImport: () => void;60  onExport: () => void;61  onBreachCheckDone: () => void;62  onOpenHealth?: () => void;63  onOpenSyncPairing?: () => void;64  onOpenFileImport?: () => void;65  onOpenPanic?: () => void;66  onOpenCodexGenerate?: () => void;67  onOpenCodexRestore?: () => void;68  /** Invoked from within Settings when a Pro-gated control is tapped on69   *  free tier. App owns the upgrade navigation + highlight state, so this70   *  callback is the single way for Settings to request that navigation. */71  onProGate?: (feature: ProFeature) => void;72  onCardChange?: (id: CardId) => void;73}7475const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];76const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";77const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;7879function sanitizeFeedbackMessage(value: string): string {80  return value81    .trim()82    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)83    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")84    .replace(/&/g, "&amp;")85    .replace(/</g, "&lt;")86    .replace(/>/g, "&gt;")87    .replace(/"/g, "&quot;")88    .replace(/'/g, "&#39;");89}9091type DataCollectionPermissionApi = {92  permissions?: {93    getAll?: () => Promise<{ data_collection?: string[] }>;94    request?: (permissions: { data_collection: string[] }) => Promise<boolean>;95  };96};9798async function requestFeedbackDataPermission(): Promise<boolean> {99  const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??100    (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;101  if (!api?.permissions?.getAll) return true;102103  try {104    const permissions = await api.permissions.getAll();105    if (!("data_collection" in permissions)) return true;106    if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;107    return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));108  } catch {109    return false;110  }111}112113function formatActivatedSince(issued: string | undefined): string {114  if (!issued) return "Active";115  try {116    const d = new Date(issued);117    if (Number.isNaN(d.getTime())) return "Active";118    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;119  } catch {120    return "Active";121  }122}123124function initialSectionToCard(value: string | null | undefined): CardId | null {125  if (!value) return null;126  if (value === "security") return "protection";127  if (value === "license") return "pro";128  if (129    value === "protection" ||130    value === "vault" ||131    value === "sync" ||132    value === "appearance" ||133    value === "shortcuts" ||134    value === "pro" ||135    value === "feedback"136  ) {137    return value;138  }139  return null;140}141142export function SettingsView(props: SettingsProps) {143  const manifest = chrome.runtime.getManifest();144  const version = manifest.version;145  const initial = props.initialSection;146  const [activeCard, setActiveCard] = createSignal<CardId>(147    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"148  );149150  createEffect(() => { props.onCardChange?.(activeCard()); });151152  const [checkingBreaches, setCheckingBreaches] = createSignal(false);153  const [breachResult, setBreachResult] = createSignal<string | null>(null);154  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);155  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");156  const [licenseError, setLicenseError] = createSignal<string | null>(null);157  const [, setLicenseSuccess] = createSignal<string | null>(null);158  const [activatingLicense, setActivatingLicense] = createSignal(false);159160  const isPro = () => props.isPro ?? false;161  const cards = createMemo<StackCardSpec[]>(() => [162    {163      id: "protection",164      num: "I · PROTECT",165      title: "Protection",166      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,167      bgVar: "--stack-protect-bg",168      fgVar: "--stack-protect-fg",169      icon: "shield",170    },171    {172      id: "vault",173      num: "II · KEEP",174      title: "Vault",175      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,176      bgVar: "--stack-vault-bg",177      fgVar: "--stack-vault-fg",178      icon: "lock",179    },180    {181      id: "sync",182      num: "III · ECHO",183      title: "Sync",184      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",185      bgVar: "--stack-sync-bg",186      fgVar: "--stack-sync-fg",187      icon: "sync",188    },189    {190      id: "appearance",191      num: "IV · DRESS",192      title: "Appearance",193      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,194      bgVar: "--stack-appear-bg",195      fgVar: "--stack-appear-fg",196      icon: "sun",197    },198    {199      id: "shortcuts",200      num: "V · KEYS",201      title: "Shortcuts",202      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,203      bgVar: "--stack-shortcut-bg",204      fgVar: "--stack-shortcut-fg",205      icon: "keys",206    },207    {208      id: "pro",209      num: "VI · FOREVER",210      title: "Peach Pro",211      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",212      bgVar: "--stack-pro-bg",213      fgVar: "--stack-pro-fg",214      icon: "spark",215    },216    {217      id: "feedback",218      num: "VII · ECHO",219      title: "Feedback",220      lede: `version ${version} · tell us what would make it better`,221      bgVar: "--stack-feedback-bg",222      fgVar: "--stack-feedback-fg",223      icon: "bubble",224    },225  ]);226  const [biometricSupported, setBiometricSupported] = createSignal(false);227  const [biometricStored, setBiometricStored] = createSignal(false);228  const [biometricBusy, setBiometricBusy] = createSignal(false);229  const [biometricError, setBiometricError] = createSignal<string | null>(null);230  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);231  const [passkeySaving, setPasskeySaving] = createSignal(false);232233  const [dedupCount, setDedupCount] = createSignal<number | null>(null);234  const [dedupRunning, setDedupRunning] = createSignal(false);235  const [dedupDone, setDedupDone] = createSignal<number | null>(null);236237  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);238239  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted240  // BEFORE every real-mode vault write so the user can roll back if a sync241  // or import overwrites the wrong state.242  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);243  const [historyLoading, setHistoryLoading] = createSignal(false);244  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);245  const [historyShowAll, setHistoryShowAll] = createSignal(false);246247  async function loadVaultHistory() {248    setHistoryLoading(true);249    try {250      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });251      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];252      setHistorySnapshots(list);253    } finally {254      setHistoryLoading(false);255    }256  }257258  async function handleRestoreVaultSnapshot(id: string) {259    if (historyRestoring()) return;260    setHistoryRestoring(id);261    const res = await sendMessage({262      type: "RESTORE_VAULT_SNAPSHOT",263      payload: { snapshotId: id },264    });265    setHistoryRestoring(null);266    if ((res as { ok: boolean }).ok) {267      // Vault is now locked — let the parent close Settings; App.tsx's268      // VAULT_LOCKED listener will route back to the lock screen.269      props.onBack();270    }271  }272273  function formatSnapshotTime(savedAt: number): string {274    const diff = Date.now() - savedAt;275    if (diff < 60_000) return "Just now";276    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;277    const d = new Date(savedAt);278    const isToday = new Date().toDateString() === d.toDateString();279    const isYest =280      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();281    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });282    if (isToday) return `Today ${t}`;283    if (isYest) return `Yesterday ${t}`;284    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;285  }286287  function reasonLabel(reason: VaultSnapshot["reason"]): string {288    return (289      {290        local_save: "saved",291        sync_received: "synced",292        import: "imported",293        restore: "restored",294      } as const295    )[reason] ?? reason;296  }297298  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);299300  const [showChangePw, setShowChangePw] = createSignal(false);301  const [changePwCurrent, setChangePwCurrent] = createSignal("");302  const [changePwNew, setChangePwNew] = createSignal("");303  const [changePwConfirm, setChangePwConfirm] = createSignal("");304  const [changePwSaving, setChangePwSaving] = createSignal(false);305  const [changePwError, setChangePwError] = createSignal<string | null>(null);306  const [changePwSuccess] = createSignal(false);307  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);308309  const [duressEnabled, setDuressEnabled] = createSignal(false);310  const [showDuressSetup, setShowDuressSetup] = createSignal(false);311  const [duressPassword, setDuressPassword] = createSignal("");312  const [duressConfirm, setDuressConfirm] = createSignal("");313  const [duressMainPassword, setDuressMainPassword] = createSignal("");314  const [duressSaving, setDuressSaving] = createSignal(false);315  const [duressError, setDuressError] = createSignal<string | null>(null);316  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);317318  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);319320  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);321  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);322323  const [fbCategory, setFbCategory] = createSignal("Bug report");324  const [fbMessage, setFbMessage] = createSignal("");325  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';326  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');327328  createEffect(() => {329    if (props.upgradeHighlight) {330      setActiveCard("pro");331    }332  });333334  onMount(async () => {335    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([336      sendMessage({ type: "GET_LICENSE" }),337      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),338      sendMessage({ type: "IS_DURESS_ENABLED" }),339      sendMessage({ type: "GET_SETTINGS" }),340      sendMessage({ type: "GET_LICENSE_KEY" }),341    ]);342343    // Vault history loads in the background — the Vault sub-view picks it up344    // when the user navigates in. Loading it lazily keeps Settings boot fast.345    void loadVaultHistory();346347    if ((licenseRes as { ok: boolean }).ok) {348      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);349    }350351    if ((passkeyRes as { ok: boolean }).ok) {352      setPasskeyState(353        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null354      );355    }356357    if ((duressRes as { ok: boolean }).ok) {358      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);359    }360361    if ((settingsRes as { ok: boolean }).ok) {362      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;363      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);364    }365366    if ((keyRes as { ok: boolean }).ok) {367      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);368    }369370    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });371    if ((entriesRes as { ok: boolean }).ok) {372      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];373      setCanaryEntries(374        entries375          .filter((e) => e.canary?.webhookUrl)376          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))377      );378    }379380381    if (isNativePlatform()) {382      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");383      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");384      try {385        if (biometric) {386          const a = await biometric.isAvailable();387          setBiometricSupported(a.available);388        }389        if (keystore) {390          const k = await keystore.hasMasterKey();391          setBiometricStored(k.exists);392        }393      } catch { /* missing plugins — leave defaults */ }394    }395  });396397  async function handleEnableBiometrics() {398    setBiometricBusy(true);399    setBiometricError(null);400    try {401      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {402        ok: boolean;403        data?: { masterKeyHex: string };404        error?: string;405      };406      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {407        setBiometricError(keyRes.error === "vault_locked"408          ? "unlock the vault first"409          : "couldn't read derived key");410        return;411      }412      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");413      if (!keystore) {414        setBiometricError("biometric plugin unavailable");415        return;416      }417      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });418      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });419      setBiometricStored(true);420    } catch (e) {421      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");422    } finally {423      setBiometricBusy(false);424    }425  }426427  async function handleDisableBiometrics() {428    setBiometricBusy(true);429    setBiometricError(null);430    try {431      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");432      if (keystore) await keystore.clearMasterKey();433      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });434      setBiometricStored(false);435    } catch (e) {436      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");437    } finally {438      setBiometricBusy(false);439    }440  }441442  async function handleChangePassword() {443    setChangePwError(null);444    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }445    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }446    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }447    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }448    setChangePwSaving(true);449    const res = await sendMessage({450      type: "CHANGE_PASSWORD",451      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },452    }) as { ok: boolean; error?: string };453    if (res.ok) {454      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");455      setShowChangePw(false);456      setShowNewRecoveryWords(true);457    } else {458      setChangePwError(res.error ?? "failed to change password");459    }460    setChangePwSaving(false);461  }462463  const [copied, setCopied] = createSignal(false);464465  async function copyLicenseKey(key: string) {466    await navigator.clipboard.writeText(key);467    setCopied(true);468    setTimeout(() => setCopied(false), 2000);469  }470471  async function handleActivateLicense() {472    const key = licenseKeyInput().trim();473    if (!key) return;474    setActivatingLicense(true);475    setLicenseError(null);476    setLicenseSuccess(null);477    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });478    setActivatingLicense(false);479    if ((res as { ok: boolean }).ok) {480      const data = (res as { ok: boolean; data?: LicenseInfo }).data;481      if (data?.valid) {482        setLicenseInfo(data);483        setLicenseKey(key);484        setLicenseSuccess("License activated!");485        setLicenseKeyInput("");486      } else {487        setLicenseError("Invalid license key");488      }489    } else {490      setLicenseError("Activation failed");491    }492  }493494  async function handleTogglePasskeys() {495    const current = passkeyState();496    if (!current || !current.supported) return;497    setPasskeySaving(true);498    const res = await sendMessage({499      type: "SET_PASSKEY_PROVIDER_ENABLED",500      payload: { enabled: !current.enabled },501    });502    setPasskeySaving(false);503    if ((res as { ok: boolean }).ok) {504      setPasskeyState(505        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null506      );507    }508  }509510  async function handleCheckAllBreaches() {511    setCheckingBreaches(true);512    setBreachResult(null);513    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });514    setCheckingBreaches(false);515    if ((res as { ok: boolean }).ok) {516      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};517      const breached = Object.values(data).filter((count) => count > 0).length;518      setBreachResult(519        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"520      );521      props.onBreachCheckDone();522    }523  }524525526  async function handleSetupDuress() {527    const mainPw = duressMainPassword().trim();528    const duressPw = duressPassword().trim();529    const confirmPw = duressConfirm().trim();530531    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }532    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }533    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }534    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }535536    setDuressSaving(true);537    setDuressError(null);538539    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };540    if (!verifyRes.ok || !verifyRes.data) {541      setDuressError("incorrect main password");542      setDuressSaving(false);543      return;544    }545546    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };547    setDuressSaving(false);548    if (res.ok) {549      setDuressEnabled(true);550      setShowDuressSetup(false);551      setDuressPassword("");552      setDuressConfirm("");553      setDuressMainPassword("");554      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");555      setTimeout(() => setDuressSuccess(null), 5000);556    } else {557      setDuressError(558        res.error === "session_expired"559          ? "Vault session expired — lock and unlock Peach, then try again"560          : res.error ?? "failed to set duress password"561      );562    }563  }564565  async function handleClearDeclinedCards() {566    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });567    setDeclinedCardCount(0);568  }569570  async function handleCountDuplicates() {571    setDedupRunning(true);572    setDedupDone(null);573    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };574    setDedupRunning(false);575    if (res.ok) setDedupCount(res.data ?? 0);576  }577578  async function handleDeduplicate() {579    setDedupRunning(true);580    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };581    setDedupRunning(false);582    if (res.ok) {583      setDedupDone(res.data ?? 0);584      setDedupCount(0);585    }586  }587588  async function handleDisableDuress() {589    const mainPw = duressMainPassword().trim();590    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }591592    setDuressSaving(true);593    setDuressError(null);594595    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };596    if (!verifyRes.ok || !verifyRes.data) {597      setDuressError("incorrect main password");598      setDuressSaving(false);599      return;600    }601602    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };603    setDuressSaving(false);604    if (res.ok) {605      setDuressEnabled(false);606      setShowDuressSetup(false);607      setDuressMainPassword("");608      setDuressSuccess("Duress password disabled.");609      setTimeout(() => setDuressSuccess(null), 4000);610    } else {611      setDuressError(res.error ?? "failed to disable duress password");612    }613  }614615  async function handleSubmitFeedback() {616    const message = sanitizeFeedbackMessage(fbMessage());617    if (!message) return;618619    setFeedbackState('sending');620621    try {622      const hasDataPermission = await requestFeedbackDataPermission();623      if (!hasDataPermission) {624        setFeedbackState('idle');625        return;626      }627628      const category = FEEDBACK_CATEGORIES.includes(fbCategory())629        ? fbCategory()630        : FEEDBACK_CATEGORIES[0];631      const res = await fetch('https://api.web3forms.com/submit', {632        method: 'POST',633        headers: { 'Content-Type': 'application/json' },634        body: JSON.stringify({635          access_key: WEB3FORMS_KEY,636          subject: `Peach Feedback - ${category}`,637          message,638          from_name: 'Peach User',639          // Honeypot — leave blank640          botcheck: '',641        }),642      });643644      if (!res.ok) throw new Error(`HTTP ${res.status}`);645      const data = await res.json() as { success: boolean; message?: string };646      if (!data.success) throw new Error(data.message ?? 'unknown error');647648      setFeedbackState('sent');649      setFbMessage('');650      setFbCategory(FEEDBACK_CATEGORIES[0]);651    } catch (err) {652      console.error('[feedback]', err);653      setFeedbackState('error');654      setTimeout(() => setFeedbackState('idle'), 4000);655    }656  }657658  function renderSecurity() {659    return (660      <div class="peach-settings-body">661        <div class="peach-settings-group">662          <p class="peach-settings-sub-label">LOCKING</p>663          <div class="peach-settings-row">664            <span>Lock on close</span>665            <button666              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}667              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}668              aria-label="Toggle lock on close"669            >670              <span class="peach-toggle-knob"></span>671            </button>672          </div>673          <div class="peach-settings-row">674            <span>Auto-lock after</span>675            <select676              class="peach-settings-select"677              value={String(props.autoLockMinutes)}678              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}679            >680              <option value="1">1 minute</option>681              <option value="5">5 minutes</option>682              <option value="15">15 minutes</option>683              <option value="30">30 minutes</option>684              <option value="0">Never</option>685            </select>686          </div>687688          <Show when={isNativePlatform() && biometricSupported()}>689            <p class="peach-settings-sub-label">BIOMETRICS</p>690            <div class="peach-settings-row">691              <span>Unlock with biometrics</span>692              <button693                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}694                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}695                disabled={biometricBusy()}696                aria-label="Toggle biometric unlock"697              >698                <span class="peach-toggle-knob"></span>699              </button>700            </div>701            <Show when={biometricError()}>702              <div class="peach-settings-row peach-settings-row--meta">703                <span class="peach-hint peach-hint-error">{biometricError()}</span>704              </div>705            </Show>706          </Show>707708          <p class="peach-settings-sub-label">BREACH PROTECTION</p>709          <div class="peach-settings-row">710            <span>Breach monitoring</span>711            <button712              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}713              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}714              aria-label="Toggle breach monitoring"715            >716              <span class="peach-toggle-knob"></span>717            </button>718          </div>719          <div class="peach-settings-row peach-settings-row--meta">720            <span>Check all passwords</span>721            <button722              class="peach-btn peach-btn--subtle peach-btn--sm"723              onClick={() => void handleCheckAllBreaches()}724              disabled={checkingBreaches()}725            >726              {checkingBreaches() ? "checking..." : "run"}727            </button>728          </div>729          <Show when={breachResult()}>730            <div class="peach-settings-row peach-settings-row--meta">731              <span>Last check</span>732              <span class="peach-settings-value">{breachResult()}</span>733            </div>734          </Show>735736          <p class="peach-settings-sub-label">MASTER PASSWORD</p>737          <div class="peach-settings-row peach-settings-row--meta">738            <span>Master password</span>739            <button740              class="peach-btn peach-btn--subtle peach-btn--sm"741              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}742            >743              {showChangePw() ? "cancel" : "change"}744            </button>745          </div>746          <Show when={showChangePw()}>747            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">748              <input749                class="peach-settings-input"750                type="password"751                placeholder="current password"752                value={changePwCurrent()}753                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}754                autocomplete="current-password"755              />756              <input757                class="peach-settings-input"758                type="password"759                placeholder="new password (min 12 chars)"760                value={changePwNew()}761                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}762                autocomplete="new-password"763              />764              <input765                class="peach-settings-input"766                type="password"767                placeholder="confirm new password"768                value={changePwConfirm()}769                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}770                autocomplete="new-password"771                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}772              />773              <Show when={changePwError()}>774                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>775              </Show>776              <Show when={changePwSuccess()}>777                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>778              </Show>779              <button780                class="peach-btn peach-btn--accent peach-btn--sm"781                style="align-self:flex-start"782                onClick={() => void handleChangePassword()}783                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}784              >785                {changePwSaving() ? "saving…" : "save new password"}786              </button>787            </div>788          </Show>789790          <p class="peach-settings-sub-label">791            DURESS PASSWORD792            <Show when={!isPro()}>793              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>794            </Show>795          </p>796          <div class="peach-settings-row peach-settings-row--top">797            <div class="peach-settings-row-main">798              <span>799                Duress password800                <Show when={!isPro()}>801                  <span class="peach-pro-badge">Pro</span>802                </Show>803              </span>804              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">805                If entered under coercion, shows a decoy empty vault instead of your real one.806              </span>807            </div>808            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>809              {duressEnabled() ? "on" : "off"}810            </div>811          </div>812813          <Show when={duressEnabled() && !showDuressSetup()}>814            <div class="peach-settings-row peach-settings-row--meta">815              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">816                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.817              </span>818            </div>819          </Show>820821          <Show when={duressSuccess()}>822            <div class="peach-settings-row peach-settings-row--meta">823              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>824            </div>825          </Show>826827          <Show when={!showDuressSetup()}>828            <div class="peach-settings-row peach-settings-row--meta">829              <button830                class="peach-btn peach-btn--subtle peach-btn--sm"831                onClick={() => {832                  // Pro gate entry-point: free users are redirected to the833                  // upgrade screen before the setup form opens. The server-834                  // side SETUP_DURESS handler is the single enforcement835                  // point; this is UI redirection so the form never appears836                  // for a user who can't complete it.837                  if (!isPro() && !duressEnabled()) {838                    props.onProGate?.("duress_vault");839                    setActiveCard("pro");840                    return;841                  }842                  setShowDuressSetup(true);843                  setDuressError(null);844                  setDuressSuccess(null);845                  setDuressPassword("");846                  setDuressConfirm("");847                  setDuressMainPassword("");848                }}849              >850                {duressEnabled() ? "Change / disable" : "Set up"}851              </button>852            </div>853          </Show>854855          <Show when={showDuressSetup()}>856            <div class="peach-duress-form">857              <label class="peach-label">main password (to confirm)</label>858              <input859                class="peach-settings-input"860                type="password"861                placeholder="your current master password"862                autocomplete="current-password"863                value={duressMainPassword()}864                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}865              />866              <Show when={!duressEnabled()}>867                <label class="peach-label">new duress password</label>868                <input869                  class="peach-settings-input"870                  type="password"871                  placeholder="duress password (min 8 chars)"872                  autocomplete="new-password"873                  value={duressPassword()}874                  onInput={(e) => setDuressPassword(e.currentTarget.value)}875                />876                <label class="peach-label">confirm duress password</label>877                <input878                  class="peach-settings-input"879                  type="password"880                  placeholder="confirm duress password"881                  autocomplete="new-password"882                  value={duressConfirm()}883                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}884                />885              </Show>886887              <Show when={duressError()}>888                <p class="peach-hint peach-hint-error">{duressError()}</p>889              </Show>890891              <div class="peach-duress-actions">892                <button893                  class="peach-btn peach-btn--subtle peach-btn--sm"894                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}895                >896                  cancel897                </button>898                <Show when={duressEnabled()}>899                  <button900                    class="peach-btn peach-btn--ghost peach-btn--sm"901                    style="color:var(--peach-breach)"902                    onClick={() => void handleDisableDuress()}903                    disabled={duressSaving()}904                  >905                    {duressSaving() ? "disabling..." : "disable duress"}906                  </button>907                </Show>908                <Show when={!duressEnabled()}>909                  <button910                    class="peach-btn peach-btn--accent peach-btn--sm"911                    onClick={() => void handleSetupDuress()}912                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}913                  >914                    {duressSaving() ? "saving..." : "save"}915                  </button>916                </Show>917              </div>918            </div>919          </Show>920921          <p class="peach-settings-sub-label">922            CANARY ENTRIES923            <Show when={!isPro()}>924              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>925            </Show>926          </p>927          <div class="peach-settings-row peach-settings-row--top">928            <div class="peach-settings-row-main">929              <span>930                Canary entries931                <Show when={!isPro()}>932                  <span class="peach-pro-badge">Pro</span>933                </Show>934              </span>935              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">936                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.937              </span>938            </div>939            <span class="peach-settings-value" style="flex-shrink:0">940              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}941            </span>942          </div>943          <Show when={canaryEntries().length > 0}>944            <For each={canaryEntries()}>945              {(entry) => (946                <div class="peach-settings-row peach-settings-row--meta">947                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>948                  <button949                    class="peach-btn peach-btn--subtle peach-btn--sm"950                    onClick={() => void handleTestCanary(entry)}951                    disabled={canaryTestingId() === entry.id}952                    style="flex-shrink:0"953                  >954                    {canaryTestingId() === entry.id955                      ? "sending…"956                      : canaryTestResult()?.id === entry.id957                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")958                        : "test"}959                  </button>960                </div>961              )}962            </For>963          </Show>964965          <p class="peach-settings-sub-label">PASSKEYS</p>966          <div class="peach-settings-row peach-settings-row--top">967            <div class="peach-settings-row-main">968              <span>Intercept passkey requests</span>969              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">970                Peach can handle passkey sign-in and creation on supported Chromium browsers.971              </span>972            </div>973            <button974              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}975              onClick={() => void handleTogglePasskeys()}976              aria-label="Toggle passkey interception"977              disabled={!passkeyState()?.supported || passkeySaving()}978            >979              <span class="peach-toggle-knob"></span>980            </button>981          </div>982          <div class="peach-settings-row peach-settings-row--meta">983            <span>Status</span>984            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>985          </div>986          <div class="peach-settings-row peach-settings-row--meta">987            <span>Browser</span>988            <span class="peach-settings-value">989              {passkeyState()?.browserLabel ?? "Unknown"}990              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}991            </span>992          </div>993          <div class="peach-info-notice" style="margin-top:8px">994            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.995          </div>996          <Show when={passkeyState()?.conflict}>997            <div class="peach-settings-row peach-settings-row--meta">998              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>999            </div>1000          </Show>1001        </div>1002      </div>1003    );1004  }10051006  function renderVault() {1007    return (1008      <div class="peach-settings-body">1009        <div class="peach-settings-group">1010          <p class="peach-settings-sub-label">ENTRIES</p>1011          <div class="peach-settings-row peach-settings-row--meta">1012            <span>Entries</span>1013            <span class="peach-settings-value">{props.entriesCount}</span>1014          </div>1015          <div class="peach-settings-row peach-settings-row--meta">1016            <span>Duplicates</span>1017            <Show when={dedupDone() !== null} fallback={1018              <button1019                class="peach-btn peach-btn--subtle peach-btn--sm"1020                onClick={() => void handleCountDuplicates()}1021                disabled={dedupRunning()}1022              >1023                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1024              </button>1025            }>1026              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1027                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1028              </span>1029            </Show>1030          </div>1031          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1032            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1033              <div class="peach-restore-warning">1034                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1035              </div>1036              <div style="display:flex;gap:6px">1037                <button1038                  class="peach-btn peach-btn--subtle peach-btn--sm"1039                  onClick={() => setDedupCount(null)}1040                >1041                  cancel1042                </button>1043                <button1044                  class="peach-btn peach-btn--accent peach-btn--sm"1045                  onClick={() => void handleDeduplicate()}1046                  disabled={dedupRunning()}1047                >1048                  {dedupRunning() ? "removing..." : "remove duplicates"}1049                </button>1050              </div>1051            </div>1052          </Show>10531054          <p class="peach-settings-sub-label">MAINTENANCE</p>1055          <div class="peach-settings-row peach-settings-row--meta">1056            <span>Trash auto-delete</span>1057            <select1058              class="peach-settings-select"1059              value={String(props.trashRetentionDays)}1060              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1061            >1062              <option value="7">7 days</option>1063              <option value="30">30 days</option>1064              <option value="90">90 days</option>1065              <option value="0">Never</option>1066            </select>1067          </div>1068          <div class="peach-settings-row peach-settings-row--meta">1069            <span>Vault Health</span>1070            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1071              open1072            </button>1073          </div>1074          <Show when={declinedCardCount() > 0}>1075            <div class="peach-settings-row peach-settings-row--meta">1076              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1077              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1078                Clear1079              </button>1080            </div>1081          </Show>10821083          <p class="peach-settings-sub-label">DATA</p>1084          <div class="peach-settings-row peach-settings-row--meta">1085            <span>Import passwords</span>1086            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1087              open1088            </button>1089          </div>1090          <div class="peach-settings-row peach-settings-row--meta">1091            <span>Export backup</span>1092            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1093              open1094            </button>1095          </div>1096          <div class="peach-settings-row peach-settings-row--meta">1097            <span>1098              Peach Codex1099              <Show when={!isPro()}>1100                <span class="peach-pro-badge">Pro</span>1101              </Show>1102            </span>1103            <button1104              class="peach-btn peach-btn--subtle peach-btn--sm"1105              onClick={() => props.onOpenCodexGenerate?.()}1106            >1107              open1108            </button>1109          </div>1110          {/* Restore from Codex: intentionally never gated — a printed1111              Codex must remain restorable regardless of license state. */}1112          <div class="peach-settings-row peach-settings-row--meta">1113            <span>Restore from Codex</span>1114            <button1115              class="peach-btn peach-btn--subtle peach-btn--sm"1116              onClick={() => props.onOpenCodexRestore?.()}1117            >1118              open1119            </button>1120          </div>11211122          <span class="peach-settings-sub-label">VAULT HISTORY</span>1123          <Show when={historyLoading()}>1124            <p class="peach-settings-hint">Loading…</p>1125          </Show>1126          <Show when={!historyLoading() && historySnapshots().length === 0}>1127            <p class="peach-settings-hint">1128              No snapshots yet. Peach saves automatically before every change —1129              saves, syncs, and imports.1130            </p>1131          </Show>1132          <Show when={!historyLoading() && historySnapshots().length > 0}>1133            <p class="peach-settings-hint">1134              Restoring locks your vault. Unlock with the password that1135              encrypted that snapshot to confirm.1136            </p>1137            <div class="peach-history-list">1138              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1139                {(snap) => (1140                  <div class="peach-history-row">1141                    <div class="peach-history-meta">1142                      <span class="peach-history-time">1143                        {formatSnapshotTime(snap.savedAt)}1144                      </span>1145                      <span class="peach-history-sub">1146                        {[1147                          snap.entryCount !== null1148                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1149                            : null,1150                          reasonLabel(snap.reason),1151                        ]1152                          .filter(Boolean)1153                          .join(" · ")}1154                      </span>1155                    </div>1156                    <button1157                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1158                        historyRestoring() === snap.id ? " is-busy" : ""1159                      }`}1160                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1161                      disabled={historyRestoring() !== null}1162                    >1163                      <Show when={historyRestoring() === snap.id} fallback="Restore">1164                        …1165                      </Show>1166                    </button>1167                  </div>1168                )}1169              </For>1170            </div>1171            <Show when={historySnapshots().length > 3}>1172              <button1173                class="peach-settings-advanced-toggle"1174                onClick={() => setHistoryShowAll((v) => !v)}1175                aria-expanded={historyShowAll()}1176              >1177                <span class="peach-settings-hint" style="margin:0">1178                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1179                </span>1180                <span class="peach-settings-advanced-chevron">1181                  {historyShowAll() ? "▲" : "▼"}1182                </span>1183              </button>1184            </Show>1185            <p class="peach-history-footer">1186              {historySnapshots().length} of 10 snapshots1187            </p>1188          </Show>11891190          <button1191            class="peach-settings-advanced-toggle"1192            onClick={() => setShowVaultAdvanced((v) => !v)}1193            aria-expanded={showVaultAdvanced()}1194          >1195            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1196            <span class="peach-settings-advanced-chevron">1197              {showVaultAdvanced() ? "▲" : "▼"}1198            </span>1199          </button>12001201          <Show when={showVaultAdvanced()}>1202            <div class="peach-danger-card">1203              <div class="peach-danger-card-head">1204                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1205                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1206              </div>1207              <p class="peach-danger-card-copy">1208                Wipes the current vault and restores from a backup file.1209                Recoverable only if your backup is good.1210              </p>1211              <button1212                class="peach-danger-card-button"1213                onClick={() => props.onOpenFileImport?.()}1214              >1215                Choose backup file1216              </button>1217            </div>12181219            <Show when={props.onOpenPanic}>1220              <div class="peach-danger-card peach-danger-card--hazard">1221                <div class="peach-danger-card-head">1222                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1223                  <h3 class="peach-danger-card-title">Destroy vault</h3>1224                </div>1225                <p class="peach-danger-card-copy">1226                  Permanently wipes every entry, setting, and license on this1227                  device. There is no recovery.1228                </p>1229                <button1230                  class="peach-danger-card-button"1231                  onClick={() => props.onOpenPanic?.()}1232                >1233                  Begin destruction1234                </button>1235              </div>1236            </Show>1237          </Show>1238        </div>1239      </div>1240    );1241  }12421243  function renderSync() {1244    return (1245      <div class="peach-settings-body">1246        <div class="peach-settings-group">1247          <p class="peach-settings-sub-label">PAIRING</p>1248          <div class="peach-settings-row peach-settings-row--top">1249            <div class="peach-settings-row-main">1250              <span>1251                Peach Sync1252                <Show when={!isPro()}>1253                  <span class="peach-pro-badge">Pro</span>1254                </Show>1255              </span>1256              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1257                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1258              </span>1259            </div>1260            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1261          </div>1262          <div class="peach-settings-row peach-settings-row--meta">1263            <span>Status</span>1264            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1265          </div>1266          <div class="peach-settings-row peach-settings-row--meta">1267            <span>Sync hub</span>1268            <button1269              class="peach-btn peach-btn--subtle peach-btn--sm"1270              onClick={() => props.onOpenSyncPairing?.()}1271            >1272              open1273            </button>1274          </div>12751276          <p class="peach-settings-sub-label">DEVICE FLOW</p>1277          <div class="peach-settings-row peach-settings-row--top">1278            <div class="peach-settings-row-main">1279              <span>Pair new device</span>1280              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1281                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1282              </span>1283            </div>1284            <button1285              class="peach-btn peach-btn--subtle peach-btn--sm"1286              onClick={() => props.onOpenSyncPairing?.()}1287            >1288              pair1289            </button>1290          </div>1291          <div class="peach-info-notice" style="margin-top:8px">1292            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1293          </div>1294        </div>1295      </div>1296    );1297  }12981299  function renderAppearance() {1300    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1301      { id: "medium", label: "Default" },1302      { id: "large", label: "Large" },1303    ];1304    return (1305      <div class="peach-settings-body">1306        <div class="peach-settings-group">1307          <p class="peach-settings-sub-label">THEME</p>1308          <div class="peach-settings-row peach-settings-row--meta">1309            <span>Theme</span>1310            <button1311              class="peach-btn peach-btn--subtle peach-btn--sm"1312              onClick={() =>1313                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1314              }1315            >1316              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1317            </button>1318          </div>13191320          <p class="peach-settings-sub-label">ACCENT</p>1321          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1322            <span>Accent</span>1323            <div class="peach-accent-picker">1324              <For each={ACCENT_COLORS}>1325                {(color) => (1326                  <button1327                    class="peach-accent-swatch peach-swatch"1328                    classList={{ "is-active": props.accentColor === color.name }}1329                    style={{ background: color.hex }}1330                    onClick={() => void props.onAccentChange(color.name)}1331                    aria-label={color.name}1332                    title={color.name}1333                  />1334                )}1335              </For>1336            </div>1337          </div>13381339          <p class="peach-settings-sub-label">TEXT SIZE</p>1340          <div class="peach-settings-row peach-settings-row--meta">1341            <span>Size</span>1342            <div class="peach-pill-group">1343              <For each={sizeOptions}>1344                {(opt) => (1345                  <button1346                    class="peach-btn peach-btn--ghost peach-btn--sm"1347                    classList={{ "is-active": props.textSize === opt.id }}1348                    onClick={() => void props.onTextSizeChange(opt.id)}1349                  >1350                    {opt.label}1351                  </button>1352                )}1353              </For>1354            </div>1355          </div>13561357          <p class="peach-settings-sub-label">MOTION</p>1358          <div class="peach-settings-row peach-settings-row--meta">1359            <span>Reduce motion</span>1360            <button1361              class="peach-btn peach-btn--subtle peach-btn--sm"1362              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1363            >1364              {props.reduceMotion ? "On" : "Off"}1365            </button>1366          </div>1367        </div>1368      </div>1369    );1370  }13711372  function renderShortcuts() {1373    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1374    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13751376    function startCapture(action: ShortcutAction) {1377      setConflictMsg(null);1378      setCapturingAction(action);1379    }1380    function cancelCapture() {1381      setCapturingAction(null);1382      setConflictMsg(null);1383    }1384    function commitBinding(action: ShortcutAction, binding: Keybinding) {1385      const conflict = findConflict(props.shortcuts, binding, action);1386      if (conflict) {1387        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1388        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1389        return;1390      }1391      const next = { ...props.shortcuts, [action]: binding };1392      setCapturingAction(null);1393      setConflictMsg(null);1394      void props.onShortcutsChange(next);1395    }1396    function resetAction(action: ShortcutAction) {1397      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1398      void props.onShortcutsChange(next);1399    }1400    function resetAll() {1401      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1402    }14031404    // Capture-mode key handler — wired via ref onto the chip element so it1405    // catches the next non-modifier keypress and ignores other bindings.1406    function handleCaptureKey(e: KeyboardEvent) {1407      e.preventDefault();1408      e.stopPropagation();1409      if (e.key === "Escape") { cancelCapture(); return; }1410      const binding = keybindingFromEvent(e);1411      if (!binding) return; // bare modifier — keep waiting1412      const action = capturingAction();1413      if (action) commitBinding(action, binding);1414    }14151416    return (1417      <div class="peach-settings-body">1418        <div class="peach-settings-group peach-shortcuts-group">1419          <p class="peach-settings-sub-label">ENTRY VIEW</p>1420          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1421            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1422          </For>1423        </div>14241425        <div class="peach-settings-group peach-shortcuts-group">1426          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1427          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1428            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1429          </For>1430        </div>14311432        <Show when={conflictMsg()}>1433          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1434        </Show>14351436        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1437          <div class="peach-settings-row peach-settings-row--meta">1438            <span>Inline autofill on page</span>1439            <a1440              class="peach-settings-link"1441              href="chrome://extensions/shortcuts"1442              onClick={(e) => {1443                e.preventDefault();1444                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1445              }}1446            >1447              ⌘⇧P — customize ↗1448            </a>1449          </div>1450          <button1451            class="peach-btn peach-btn--subtle peach-btn--sm"1452            style="align-self:flex-start"1453            onClick={resetAll}1454          >1455            Reset all to defaults1456          </button>1457        </div>1458      </div>1459    );14601461    function renderRow(1462      desc: typeof SHORTCUT_DESCRIPTORS[number],1463      capturing: () => ShortcutAction | null,1464      onStart: (a: ShortcutAction) => void,1465      onCancel: () => void,1466      onReset: (a: ShortcutAction) => void,1467      onKey: (e: KeyboardEvent) => void,1468    ) {1469      const isActive = () => capturing() === desc.action;1470      const binding = () => props.shortcuts[desc.action];1471      const isCustom = () => {1472        const b = binding();1473        const d = DEFAULT_BINDINGS[desc.action];1474        return b.key !== d.key1475          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1476          || Boolean(b.meta)  !== Boolean(d.meta)1477          || Boolean(b.shift) !== Boolean(d.shift)1478          || Boolean(b.alt)   !== Boolean(d.alt);1479      };1480      return (1481        <div class="peach-shortcut-row">1482          <span class="peach-shortcut-row-label">{desc.label}</span>1483          <button1484            class="peach-shortcut-chip"1485            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1486            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1487            onKeyDown={(e) => { if (isActive()) onKey(e); }}1488            ref={(el) => {1489              // Auto-focus when entering capture mode so the keydown listener catches the next press.1490              if (el) {1491                queueMicrotask(() => { if (isActive()) el.focus(); });1492              }1493            }}1494          >1495            {isActive() ? "Press a key…" : formatKeybinding(binding())}1496          </button>1497          <Show when={isCustom() && !isActive()}>1498            <button1499              class="peach-shortcut-reset"1500              title="Reset to default"1501              onClick={() => onReset(desc.action)}1502            >1503              ↺1504            </button>1505          </Show>1506        </div>1507      );1508    }1509  }15101511  const PRO_FEATURES: Array<{1512    feature: ProFeature;1513    name: string;1514    desc: string;1515  }> = [1516    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1517    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1518    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1519    { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1520    { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1521  ];15221523  const PRO_SECURITY_FEATURES: Array<{1524    feature: ProFeature;1525    name: string;1526    desc: string;1527  }> = [1528    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1529    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1530  ];15311532  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1533  const [showKey, setShowKey] = createSignal(false);15341535  const [deactivating, setDeactivating] = createSignal(false);15361537  async function handleDeactivateLicense() {1538    setDeactivating(true);1539    await sendMessage({ type: "DEACTIVATE_LICENSE" });1540    setLicenseInfo(null);1541    await props.onLicenseChange?.();1542    setDeactivating(false);1543  }15441545  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1546    setCanaryTestingId(entry.id);1547    setCanaryTestResult(null);1548    const res = await sendMessage({1549      type: "FIRE_CANARY_PING",1550      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1551    }) as { ok: boolean };1552    setCanaryTestResult({ id: entry.id, ok: res.ok });1553    setCanaryTestingId(null);1554    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1555  }15561557  function renderLicense() {1558    async function onActivate() {1559      await handleActivateLicense();1560      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1561        await props.onLicenseChange?.();1562      }1563    }15641565    return (1566      <div class="peach-settings-body">1567        <Show1568          when={isPro()}1569          fallback={1570                <div class="peach-pro-upgrade">1571                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15721573                  <h2 class="peach-pro-headline">1574                    Every<em>thing,</em><br />every<em>where.</em>1575                  </h2>15761577                  <p class="peach-pro-subtitle">1578                    One key unlocks every Pro feature — for the lifetime of the app.1579                  </p>15801581                  <div class="peach-pro-grid">1582                    <For each={PRO_FEATURES}>1583                      {(item, i) => (1584                        <div1585                          class="peach-pro-grid-item"1586                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1587                        >1588                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1589                          <span class="peach-pro-grid-name">{item.name}</span>1590                          <span class="peach-pro-grid-desc">{item.desc}</span>1591                        </div>1592                      )}1593                    </For>1594                  </div>15951596                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1597                  <div class="peach-pro-grid">1598                    <For each={PRO_SECURITY_FEATURES}>1599                      {(item, i) => (1600                        <div1601                          class="peach-pro-grid-item"1602                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1603                        >1604                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1605                          <span class="peach-pro-grid-name">{item.name}</span>1606                          <span class="peach-pro-grid-desc">{item.desc}</span>1607                        </div>1608                      )}1609                    </For>1610                  </div>16111612                  <div class="peach-pro-upgrade-actions">1613                    <p class="peach-pro-pricing">1614                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1615                    </p>1616                    <a1617                      href="https://peachpasswords.com/#pricing"1618                      target="_blank"1619                      rel="noopener noreferrer"1620                      class="peach-pro-upgrade-cta"1621                    >1622                      Get Peach Pro1623                    </a>1624                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1625                    <div class="peach-pro-key-inline">1626                      <input1627                        class="peach-pro-key-inline-input"1628                        type="text"1629                        placeholder="PEACH-XXXX-XXXX-XXXX"1630                        value={licenseKeyInput()}1631                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1632                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1633                        autocomplete="off"1634                        spellcheck={false}1635                      />1636                      <button1637                        class="peach-pro-key-apply"1638                        onClick={() => void onActivate()}1639                        disabled={activatingLicense() || !licenseKeyInput().trim()}1640                      >1641                        {activatingLicense() ? "…" : "APPLY"}1642                      </button>1643                    </div>1644                    <Show when={licenseError()}>1645                      <p class="peach-pro-key-error">{licenseError()}</p>1646                    </Show>1647                  </div>1648                </div>1649          }1650        >1651          <div class="peach-pro-active">1652            <div class="peach-pro-active-hero">1653              <div class="peach-pro-active-mark-wrap">1654                <PeachMark class="peach-pro-active-mark" />1655              </div>1656              <h2 class="peach-pro-active-title">Peach Pro</h2>1657              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1658            </div>16591660            <Show when={licenseKey()}>1661              <div class="peach-settings-license">1662                <span class="peach-settings-label">LICENSE KEY</span>1663                <div class="peach-settings-license-row">1664                  <Show1665                    when={showKey()}1666                    fallback={1667                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1668                        ••••••••••••••••••••••••••••1669                      </span>1670                    }1671                  >1672                    <span class="peach-settings-license-key">{licenseKey()!}</span>1673                  </Show>1674                  <div class="peach-settings-license-actions">1675                    <button1676                      class="peach-settings-license-copy"1677                      title="Copy license key"1678                      onClick={() => copyLicenseKey(licenseKey()!)}1679                    >1680                      {copied() ? "✓" : "copy"}1681                    </button>1682                    <button1683                      class="peach-settings-license-toggle"1684                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1685                      title={showKey() ? "Hide" : "Reveal"}1686                      onClick={() => setShowKey(v => !v)}1687                    >1688                      <Show1689                        when={showKey()}1690                        fallback={1691                          <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">1692                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1693                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1694                          </svg>1695                        }1696                      >1697                        <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">1698                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1699                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1700                          <line x1="2" y1="2" x2="12" y2="12" />1701                        </svg>1702                      </Show>1703                    </button>1704                  </div>1705                </div>1706              </div>1707            </Show>17081709            <div class="peach-pro-active-features">1710              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1711                {(item) => (1712                  <div class="peach-pro-active-feature">1713                    <span class="peach-pro-active-feature-mark">✦</span>1714                    <span class="peach-pro-active-feature-name">{item.name}</span>1715                  </div>1716                )}1717              </For>1718            </div>17191720            <button1721              class="peach-pro-deactivate"1722              onClick={() => void handleDeactivateLicense()}1723              disabled={deactivating()}1724            >1725              {deactivating() ? "Deactivating…" : "Deactivate"}1726            </button>1727          </div>1728        </Show>1729      </div>1730    );1731  }17321733  function renderFeedback() {1734    return (1735      <div class="peach-settings-body">1736        <div class="peach-settings-group peach-feedback-group">1737          <Show1738            when={feedbackState() === 'sent'}1739            fallback={1740              <>1741                <p class="peach-feedback-intro">1742                  Help us improve Peach. Every report goes directly to our inbox.1743                </p>17441745                <p class="peach-settings-sub-label">TYPE</p>1746                <div class="peach-feedback-chips">1747                  <For each={FEEDBACK_CATEGORIES}>1748                    {(cat) => {1749                      const selected = () => fbCategory() === cat;1750                      return (1751                        <button1752                          type="button"1753                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1754                          aria-pressed={selected()}1755                          onClick={() => setFbCategory(cat)}1756                        >1757                          {cat}1758                        </button>1759                      );1760                    }}1761                  </For>1762                </div>17631764                <p class="peach-settings-sub-label">MESSAGE</p>1765                <textarea1766                  class="peach-feedback-textarea"1767                  placeholder="Describe the issue or idea..."1768                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1769                  value={fbMessage()}1770                  onInput={(e) => setFbMessage(e.currentTarget.value)}1771                />17721773                <p class="peach-feedback-meta">1774                  Version {version} · will be included automatically1775                </p>17761777                <button1778                  type="button"1779                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1780                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}1781                  onClick={() => void handleSubmitFeedback()}1782                >1783                  <Switch>1784                    <Match when={feedbackState() === 'idle'}>Send</Match>1785                    <Match when={feedbackState() === 'sending'}>Sending...</Match>1786                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>1787                  </Switch>1788                </button>17891790                <p class="peach-feedback-anon">1791                  Sent anonymously · no account required1792                </p>1793              </>1794            }1795          >1796            <div class="peach-feedback-thanks" role="status" aria-live="polite">1797              <p class="peach-feedback-thanks-title">Thank you</p>1798              <p>1799                Your note is in the queue. Someone will process it soon.1800              </p>1801            </div>1802          </Show>1803        </div>1804      </div>1805    );1806  }18071808  const bodies: StackBodies = {1809    protection: renderSecurity(),1810    vault: renderVault(),1811    sync: renderSync(),1812    appearance: renderAppearance(),1813    shortcuts: renderShortcuts(),1814    pro: renderLicense(),1815    feedback: renderFeedback(),1816  };18171818  return (1819    <Show1820      when={showNewRecoveryWords()}1821      fallback={1822        <div1823          class="peach-overlay-view peach-settings-overlay"1824          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1825          style="display:flex;flex-direction:column"1826        >1827          <StackDeck1828            active={activeCard()}1829            onActiveChange={setActiveCard}1830            onBack={props.onBack}1831            cards={cards()}1832            bodies={bodies}1833            version={version}1834          />1835        </div>1836      }1837    >1838      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1839    </Show>1840  );1841}