Settings.tsx

74.1 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) ?? "pro"148  );149150  createEffect(() => { props.onCardChange?.(activeCard()); });151152  const [justActivated, setJustActivated] = createSignal(false);153  const [isFanning, setIsFanning] = createSignal(false);154155  function triggerCelebration() {156    if (isFanning()) return;157    setIsFanning(true);158    setActiveCard("pro");159    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];160    fanOrder.forEach((id, i) => {161      window.setTimeout(() => setActiveCard(id), (i + 1) * 80);162    });163    window.setTimeout(() => {164      setActiveCard("pro");165      setIsFanning(false);166      setJustActivated(true);167      window.setTimeout(() => setJustActivated(false), 2800);168    }, (fanOrder.length + 1) * 80);169  }170171  const [checkingBreaches, setCheckingBreaches] = createSignal(false);172  const [breachResult, setBreachResult] = createSignal<string | null>(null);173  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);174  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");175  const [licenseError, setLicenseError] = createSignal<string | null>(null);176  const [, setLicenseSuccess] = createSignal<string | null>(null);177  const [activatingLicense, setActivatingLicense] = createSignal(false);178179  const isPro = () => props.isPro ?? false;180  const cards = createMemo<StackCardSpec[]>(() => [181    {182      id: "pro",183      num: "I · FOREVER",184      title: "Peach Pro",185      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",186      bgVar: "--stack-pro-bg",187      fgVar: "--stack-pro-fg",188      icon: "spark",189      onIconClick: isPro() ? triggerCelebration : undefined,190    },191    {192      id: "protection",193      num: "II · PROTECT",194      title: "Protection",195      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,196      bgVar: "--stack-protect-bg",197      fgVar: "--stack-protect-fg",198      icon: "shield",199    },200    {201      id: "vault",202      num: "III · KEEP",203      title: "Vault",204      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,205      bgVar: "--stack-vault-bg",206      fgVar: "--stack-vault-fg",207      icon: "lock",208    },209    {210      id: "sync",211      num: "IV · ECHO",212      title: "Sync",213      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",214      bgVar: "--stack-sync-bg",215      fgVar: "--stack-sync-fg",216      icon: "sync",217    },218    {219      id: "appearance",220      num: "V · DRESS",221      title: "Appearance",222      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,223      bgVar: "--stack-appear-bg",224      fgVar: "--stack-appear-fg",225      icon: "sun",226    },227    {228      id: "shortcuts",229      num: "VI · KEYS",230      title: "Shortcuts",231      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,232      bgVar: "--stack-shortcut-bg",233      fgVar: "--stack-shortcut-fg",234      icon: "keys",235    },236    {237      id: "feedback",238      num: "VII · ECHO",239      title: "Feedback",240      lede: `version ${version} · tell us what would make it better`,241      bgVar: "--stack-feedback-bg",242      fgVar: "--stack-feedback-fg",243      icon: "bubble",244    },245  ]);246  const [biometricSupported, setBiometricSupported] = createSignal(false);247  const [biometricStored, setBiometricStored] = createSignal(false);248  const [biometricBusy, setBiometricBusy] = createSignal(false);249  const [biometricError, setBiometricError] = createSignal<string | null>(null);250  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);251  const [passkeySaving, setPasskeySaving] = createSignal(false);252253  const [dedupCount, setDedupCount] = createSignal<number | null>(null);254  const [dedupRunning, setDedupRunning] = createSignal(false);255  const [dedupDone, setDedupDone] = createSignal<number | null>(null);256257  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);258259  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted260  // BEFORE every real-mode vault write so the user can roll back if a sync261  // or import overwrites the wrong state.262  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);263  const [historyLoading, setHistoryLoading] = createSignal(false);264  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);265  const [historyShowAll, setHistoryShowAll] = createSignal(false);266267  async function loadVaultHistory() {268    setHistoryLoading(true);269    try {270      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });271      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];272      setHistorySnapshots(list);273    } finally {274      setHistoryLoading(false);275    }276  }277278  async function handleRestoreVaultSnapshot(id: string) {279    if (historyRestoring()) return;280    setHistoryRestoring(id);281    const res = await sendMessage({282      type: "RESTORE_VAULT_SNAPSHOT",283      payload: { snapshotId: id },284    });285    setHistoryRestoring(null);286    if ((res as { ok: boolean }).ok) {287      // Vault is now locked — let the parent close Settings; App.tsx's288      // VAULT_LOCKED listener will route back to the lock screen.289      props.onBack();290    }291  }292293  function formatSnapshotTime(savedAt: number): string {294    const diff = Date.now() - savedAt;295    if (diff < 60_000) return "Just now";296    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;297    const d = new Date(savedAt);298    const isToday = new Date().toDateString() === d.toDateString();299    const isYest =300      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();301    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });302    if (isToday) return `Today ${t}`;303    if (isYest) return `Yesterday ${t}`;304    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;305  }306307  function reasonLabel(reason: VaultSnapshot["reason"]): string {308    return (309      {310        local_save: "saved",311        sync_received: "synced",312        import: "imported",313        restore: "restored",314      } as const315    )[reason] ?? reason;316  }317318  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);319320  const [showChangePw, setShowChangePw] = createSignal(false);321  const [changePwCurrent, setChangePwCurrent] = createSignal("");322  const [changePwNew, setChangePwNew] = createSignal("");323  const [changePwConfirm, setChangePwConfirm] = createSignal("");324  const [changePwSaving, setChangePwSaving] = createSignal(false);325  const [changePwError, setChangePwError] = createSignal<string | null>(null);326  const [changePwSuccess] = createSignal(false);327  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);328329  const [duressEnabled, setDuressEnabled] = createSignal(false);330  const [showDuressSetup, setShowDuressSetup] = createSignal(false);331  const [duressPassword, setDuressPassword] = createSignal("");332  const [duressConfirm, setDuressConfirm] = createSignal("");333  const [duressMainPassword, setDuressMainPassword] = createSignal("");334  const [duressSaving, setDuressSaving] = createSignal(false);335  const [duressError, setDuressError] = createSignal<string | null>(null);336  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);337338  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);339340  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);341  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);342343  const [fbCategory, setFbCategory] = createSignal("Bug report");344  const [fbMessage, setFbMessage] = createSignal("");345  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';346  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');347348  createEffect(() => {349    if (props.upgradeHighlight) {350      setActiveCard("pro");351    }352  });353354  onMount(async () => {355    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([356      sendMessage({ type: "GET_LICENSE" }),357      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),358      sendMessage({ type: "IS_DURESS_ENABLED" }),359      sendMessage({ type: "GET_SETTINGS" }),360      sendMessage({ type: "GET_LICENSE_KEY" }),361    ]);362363    // Vault history loads in the background — the Vault sub-view picks it up364    // when the user navigates in. Loading it lazily keeps Settings boot fast.365    void loadVaultHistory();366367    if ((licenseRes as { ok: boolean }).ok) {368      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);369    }370371    if ((passkeyRes as { ok: boolean }).ok) {372      setPasskeyState(373        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null374      );375    }376377    if ((duressRes as { ok: boolean }).ok) {378      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);379    }380381    if ((settingsRes as { ok: boolean }).ok) {382      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;383      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);384    }385386    if ((keyRes as { ok: boolean }).ok) {387      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);388    }389390    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });391    if ((entriesRes as { ok: boolean }).ok) {392      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];393      setCanaryEntries(394        entries395          .filter((e) => e.canary?.webhookUrl)396          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))397      );398    }399400401    if (isNativePlatform()) {402      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");403      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");404      try {405        if (biometric) {406          const a = await biometric.isAvailable();407          setBiometricSupported(a.available);408        }409        if (keystore) {410          const k = await keystore.hasMasterKey();411          setBiometricStored(k.exists);412        }413      } catch { /* missing plugins — leave defaults */ }414    }415  });416417  async function handleEnableBiometrics() {418    setBiometricBusy(true);419    setBiometricError(null);420    try {421      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {422        ok: boolean;423        data?: { masterKeyHex: string };424        error?: string;425      };426      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {427        setBiometricError(keyRes.error === "vault_locked"428          ? "unlock the vault first"429          : "couldn't read derived key");430        return;431      }432      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");433      if (!keystore) {434        setBiometricError("biometric plugin unavailable");435        return;436      }437      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });438      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });439      setBiometricStored(true);440    } catch (e) {441      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");442    } finally {443      setBiometricBusy(false);444    }445  }446447  async function handleDisableBiometrics() {448    setBiometricBusy(true);449    setBiometricError(null);450    try {451      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");452      if (keystore) await keystore.clearMasterKey();453      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });454      setBiometricStored(false);455    } catch (e) {456      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");457    } finally {458      setBiometricBusy(false);459    }460  }461462  async function handleChangePassword() {463    setChangePwError(null);464    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }465    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }466    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }467    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }468    setChangePwSaving(true);469    const res = await sendMessage({470      type: "CHANGE_PASSWORD",471      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },472    }) as { ok: boolean; error?: string };473    if (res.ok) {474      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");475      setShowChangePw(false);476      setShowNewRecoveryWords(true);477    } else {478      setChangePwError(res.error ?? "failed to change password");479    }480    setChangePwSaving(false);481  }482483  const [copied, setCopied] = createSignal(false);484485  async function copyLicenseKey(key: string) {486    await navigator.clipboard.writeText(key);487    setCopied(true);488    setTimeout(() => setCopied(false), 2000);489  }490491  async function handleActivateLicense() {492    const key = licenseKeyInput().trim();493    if (!key) return;494    setActivatingLicense(true);495    setLicenseError(null);496    setLicenseSuccess(null);497    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });498    setActivatingLicense(false);499    if ((res as { ok: boolean }).ok) {500      const data = (res as { ok: boolean; data?: LicenseInfo }).data;501      if (data?.valid) {502        setLicenseInfo(data);503        setLicenseKey(key);504        setLicenseSuccess("License activated!");505        setLicenseKeyInput("");506      } else {507        setLicenseError("Invalid license key");508      }509    } else {510      setLicenseError("Activation failed");511    }512  }513514  async function handleTogglePasskeys() {515    const current = passkeyState();516    if (!current || !current.supported) return;517    setPasskeySaving(true);518    const res = await sendMessage({519      type: "SET_PASSKEY_PROVIDER_ENABLED",520      payload: { enabled: !current.enabled },521    });522    setPasskeySaving(false);523    if ((res as { ok: boolean }).ok) {524      setPasskeyState(525        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null526      );527    }528  }529530  async function handleCheckAllBreaches() {531    setCheckingBreaches(true);532    setBreachResult(null);533    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });534    setCheckingBreaches(false);535    if ((res as { ok: boolean }).ok) {536      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};537      const breached = Object.values(data).filter((count) => count > 0).length;538      setBreachResult(539        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"540      );541      props.onBreachCheckDone();542    }543  }544545546  async function handleSetupDuress() {547    const mainPw = duressMainPassword().trim();548    const duressPw = duressPassword().trim();549    const confirmPw = duressConfirm().trim();550551    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }552    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }553    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }554    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }555556    setDuressSaving(true);557    setDuressError(null);558559    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };560    if (!verifyRes.ok || !verifyRes.data) {561      setDuressError("incorrect main password");562      setDuressSaving(false);563      return;564    }565566    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };567    setDuressSaving(false);568    if (res.ok) {569      setDuressEnabled(true);570      setShowDuressSetup(false);571      setDuressPassword("");572      setDuressConfirm("");573      setDuressMainPassword("");574      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");575      setTimeout(() => setDuressSuccess(null), 5000);576    } else {577      setDuressError(578        res.error === "session_expired"579          ? "Vault session expired — lock and unlock Peach, then try again"580          : res.error ?? "failed to set duress password"581      );582    }583  }584585  async function handleClearDeclinedCards() {586    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });587    setDeclinedCardCount(0);588  }589590  async function handleCountDuplicates() {591    setDedupRunning(true);592    setDedupDone(null);593    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };594    setDedupRunning(false);595    if (res.ok) setDedupCount(res.data ?? 0);596  }597598  async function handleDeduplicate() {599    setDedupRunning(true);600    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };601    setDedupRunning(false);602    if (res.ok) {603      setDedupDone(res.data ?? 0);604      setDedupCount(0);605    }606  }607608  async function handleDisableDuress() {609    const mainPw = duressMainPassword().trim();610    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }611612    setDuressSaving(true);613    setDuressError(null);614615    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };616    if (!verifyRes.ok || !verifyRes.data) {617      setDuressError("incorrect main password");618      setDuressSaving(false);619      return;620    }621622    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };623    setDuressSaving(false);624    if (res.ok) {625      setDuressEnabled(false);626      setShowDuressSetup(false);627      setDuressMainPassword("");628      setDuressSuccess("Duress password disabled.");629      setTimeout(() => setDuressSuccess(null), 4000);630    } else {631      setDuressError(res.error ?? "failed to disable duress password");632    }633  }634635  async function handleSubmitFeedback() {636    const message = sanitizeFeedbackMessage(fbMessage());637    if (!message) return;638639    setFeedbackState('sending');640641    try {642      const hasDataPermission = await requestFeedbackDataPermission();643      if (!hasDataPermission) {644        setFeedbackState('idle');645        return;646      }647648      const category = FEEDBACK_CATEGORIES.includes(fbCategory())649        ? fbCategory()650        : FEEDBACK_CATEGORIES[0];651      const res = await fetch('https://api.web3forms.com/submit', {652        method: 'POST',653        headers: { 'Content-Type': 'application/json' },654        body: JSON.stringify({655          access_key: WEB3FORMS_KEY,656          subject: `Peach Feedback - ${category}`,657          message,658          from_name: 'Peach User',659          // Honeypot — leave blank660          botcheck: '',661        }),662      });663664      if (!res.ok) throw new Error(`HTTP ${res.status}`);665      const data = await res.json() as { success: boolean; message?: string };666      if (!data.success) throw new Error(data.message ?? 'unknown error');667668      setFeedbackState('sent');669      setFbMessage('');670      setFbCategory(FEEDBACK_CATEGORIES[0]);671    } catch (err) {672      console.error('[feedback]', err);673      setFeedbackState('error');674      setTimeout(() => setFeedbackState('idle'), 4000);675    }676  }677678  function renderSecurity() {679    return (680      <div class="peach-settings-body">681        <div class="peach-settings-group">682          <p class="peach-settings-sub-label">LOCKING</p>683          <div class="peach-settings-row">684            <span>Lock on close</span>685            <button686              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}687              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}688              aria-label="Toggle lock on close"689            >690              <span class="peach-toggle-knob"></span>691            </button>692          </div>693          <div class="peach-settings-row">694            <span>Auto-lock after</span>695            <select696              class="peach-settings-select"697              value={String(props.autoLockMinutes)}698              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}699            >700              <option value="1">1 minute</option>701              <option value="5">5 minutes</option>702              <option value="15">15 minutes</option>703              <option value="30">30 minutes</option>704              <option value="0">Never</option>705            </select>706          </div>707708          <Show when={isNativePlatform() && biometricSupported()}>709            <p class="peach-settings-sub-label">BIOMETRICS</p>710            <div class="peach-settings-row">711              <span>Unlock with biometrics</span>712              <button713                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}714                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}715                disabled={biometricBusy()}716                aria-label="Toggle biometric unlock"717              >718                <span class="peach-toggle-knob"></span>719              </button>720            </div>721            <Show when={biometricError()}>722              <div class="peach-settings-row peach-settings-row--meta">723                <span class="peach-hint peach-hint-error">{biometricError()}</span>724              </div>725            </Show>726          </Show>727728          <p class="peach-settings-sub-label">BREACH PROTECTION</p>729          <div class="peach-settings-row">730            <span>Breach monitoring</span>731            <button732              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}733              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}734              aria-label="Toggle breach monitoring"735            >736              <span class="peach-toggle-knob"></span>737            </button>738          </div>739          <div class="peach-settings-row peach-settings-row--meta">740            <span>Check all passwords</span>741            <button742              class="peach-btn peach-btn--subtle peach-btn--sm"743              onClick={() => void handleCheckAllBreaches()}744              disabled={checkingBreaches()}745            >746              {checkingBreaches() ? "checking..." : "run"}747            </button>748          </div>749          <Show when={breachResult()}>750            <div class="peach-settings-row peach-settings-row--meta">751              <span>Last check</span>752              <span class="peach-settings-value">{breachResult()}</span>753            </div>754          </Show>755756          <p class="peach-settings-sub-label">MASTER PASSWORD</p>757          <div class="peach-settings-row peach-settings-row--meta">758            <span>Master password</span>759            <button760              class="peach-btn peach-btn--subtle peach-btn--sm"761              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}762            >763              {showChangePw() ? "cancel" : "change"}764            </button>765          </div>766          <Show when={showChangePw()}>767            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">768              <input769                class="peach-settings-input"770                type="password"771                placeholder="current password"772                value={changePwCurrent()}773                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}774                autocomplete="current-password"775              />776              <input777                class="peach-settings-input"778                type="password"779                placeholder="new password (min 12 chars)"780                value={changePwNew()}781                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}782                autocomplete="new-password"783              />784              <input785                class="peach-settings-input"786                type="password"787                placeholder="confirm new password"788                value={changePwConfirm()}789                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}790                autocomplete="new-password"791                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}792              />793              <Show when={changePwError()}>794                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>795              </Show>796              <Show when={changePwSuccess()}>797                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>798              </Show>799              <button800                class="peach-btn peach-btn--accent peach-btn--sm"801                style="align-self:flex-start"802                onClick={() => void handleChangePassword()}803                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}804              >805                {changePwSaving() ? "saving…" : "save new password"}806              </button>807            </div>808          </Show>809810          <p class="peach-settings-sub-label">811            DURESS PASSWORD812            <Show when={!isPro()}>813              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>814            </Show>815          </p>816          <div class="peach-settings-row peach-settings-row--top">817            <div class="peach-settings-row-main">818              <span>819                Duress password820                <Show when={!isPro()}>821                  <span class="peach-pro-badge">Pro</span>822                </Show>823              </span>824              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">825                If entered under coercion, shows a decoy empty vault instead of your real one.826              </span>827            </div>828            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>829              {duressEnabled() ? "on" : "off"}830            </div>831          </div>832833          <Show when={duressEnabled() && !showDuressSetup()}>834            <div class="peach-settings-row peach-settings-row--meta">835              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">836                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.837              </span>838            </div>839          </Show>840841          <Show when={duressSuccess()}>842            <div class="peach-settings-row peach-settings-row--meta">843              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>844            </div>845          </Show>846847          <Show when={!showDuressSetup()}>848            <div class="peach-settings-row peach-settings-row--meta">849              <button850                class="peach-btn peach-btn--subtle peach-btn--sm"851                onClick={() => {852                  // Pro gate entry-point: free users are redirected to the853                  // upgrade screen before the setup form opens. The server-854                  // side SETUP_DURESS handler is the single enforcement855                  // point; this is UI redirection so the form never appears856                  // for a user who can't complete it.857                  if (!isPro() && !duressEnabled()) {858                    props.onProGate?.("duress_vault");859                    setActiveCard("pro");860                    return;861                  }862                  setShowDuressSetup(true);863                  setDuressError(null);864                  setDuressSuccess(null);865                  setDuressPassword("");866                  setDuressConfirm("");867                  setDuressMainPassword("");868                }}869              >870                {duressEnabled() ? "Change / disable" : "Set up"}871              </button>872            </div>873          </Show>874875          <Show when={showDuressSetup()}>876            <div class="peach-duress-form">877              <label class="peach-label">main password (to confirm)</label>878              <input879                class="peach-settings-input"880                type="password"881                placeholder="your current master password"882                autocomplete="current-password"883                value={duressMainPassword()}884                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}885              />886              <Show when={!duressEnabled()}>887                <label class="peach-label">new duress password</label>888                <input889                  class="peach-settings-input"890                  type="password"891                  placeholder="duress password (min 8 chars)"892                  autocomplete="new-password"893                  value={duressPassword()}894                  onInput={(e) => setDuressPassword(e.currentTarget.value)}895                />896                <label class="peach-label">confirm duress password</label>897                <input898                  class="peach-settings-input"899                  type="password"900                  placeholder="confirm duress password"901                  autocomplete="new-password"902                  value={duressConfirm()}903                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}904                />905              </Show>906907              <Show when={duressError()}>908                <p class="peach-hint peach-hint-error">{duressError()}</p>909              </Show>910911              <div class="peach-duress-actions">912                <button913                  class="peach-btn peach-btn--subtle peach-btn--sm"914                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}915                >916                  cancel917                </button>918                <Show when={duressEnabled()}>919                  <button920                    class="peach-btn peach-btn--ghost peach-btn--sm"921                    style="color:var(--peach-breach)"922                    onClick={() => void handleDisableDuress()}923                    disabled={duressSaving()}924                  >925                    {duressSaving() ? "disabling..." : "disable duress"}926                  </button>927                </Show>928                <Show when={!duressEnabled()}>929                  <button930                    class="peach-btn peach-btn--accent peach-btn--sm"931                    onClick={() => void handleSetupDuress()}932                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}933                  >934                    {duressSaving() ? "saving..." : "save"}935                  </button>936                </Show>937              </div>938            </div>939          </Show>940941          <p class="peach-settings-sub-label">942            CANARY ENTRIES943            <Show when={!isPro()}>944              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>945            </Show>946          </p>947          <div class="peach-settings-row peach-settings-row--top">948            <div class="peach-settings-row-main">949              <span>950                Canary entries951                <Show when={!isPro()}>952                  <span class="peach-pro-badge">Pro</span>953                </Show>954              </span>955              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">956                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.957              </span>958            </div>959            <span class="peach-settings-value" style="flex-shrink:0">960              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}961            </span>962          </div>963          <Show when={canaryEntries().length > 0}>964            <For each={canaryEntries()}>965              {(entry) => (966                <div class="peach-settings-row peach-settings-row--meta">967                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>968                  <button969                    class="peach-btn peach-btn--subtle peach-btn--sm"970                    onClick={() => void handleTestCanary(entry)}971                    disabled={canaryTestingId() === entry.id}972                    style="flex-shrink:0"973                  >974                    {canaryTestingId() === entry.id975                      ? "sending…"976                      : canaryTestResult()?.id === entry.id977                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")978                        : "test"}979                  </button>980                </div>981              )}982            </For>983          </Show>984985          <p class="peach-settings-sub-label">PASSKEYS</p>986          <div class="peach-settings-row peach-settings-row--top">987            <div class="peach-settings-row-main">988              <span>Intercept passkey requests</span>989              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">990                Peach can handle passkey sign-in and creation on supported Chromium browsers.991              </span>992            </div>993            <button994              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}995              onClick={() => void handleTogglePasskeys()}996              aria-label="Toggle passkey interception"997              disabled={!passkeyState()?.supported || passkeySaving()}998            >999              <span class="peach-toggle-knob"></span>1000            </button>1001          </div>1002          <div class="peach-settings-row peach-settings-row--meta">1003            <span>Status</span>1004            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1005          </div>1006          <div class="peach-settings-row peach-settings-row--meta">1007            <span>Browser</span>1008            <span class="peach-settings-value">1009              {passkeyState()?.browserLabel ?? "Unknown"}1010              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1011            </span>1012          </div>1013          <div class="peach-info-notice" style="margin-top:8px">1014            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1015          </div>1016          <Show when={passkeyState()?.conflict}>1017            <div class="peach-settings-row peach-settings-row--meta">1018              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1019            </div>1020          </Show>1021        </div>1022      </div>1023    );1024  }10251026  function renderVault() {1027    return (1028      <div class="peach-settings-body">1029        <div class="peach-settings-group">1030          <p class="peach-settings-sub-label">ENTRIES</p>1031          <div class="peach-settings-row peach-settings-row--meta">1032            <span>Entries</span>1033            <span class="peach-settings-value">{props.entriesCount}</span>1034          </div>1035          <div class="peach-settings-row peach-settings-row--meta">1036            <span>Duplicates</span>1037            <Show when={dedupDone() !== null} fallback={1038              <button1039                class="peach-btn peach-btn--subtle peach-btn--sm"1040                onClick={() => void handleCountDuplicates()}1041                disabled={dedupRunning()}1042              >1043                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1044              </button>1045            }>1046              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1047                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1048              </span>1049            </Show>1050          </div>1051          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1052            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1053              <div class="peach-restore-warning">1054                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1055              </div>1056              <div style="display:flex;gap:6px">1057                <button1058                  class="peach-btn peach-btn--subtle peach-btn--sm"1059                  onClick={() => setDedupCount(null)}1060                >1061                  cancel1062                </button>1063                <button1064                  class="peach-btn peach-btn--accent peach-btn--sm"1065                  onClick={() => void handleDeduplicate()}1066                  disabled={dedupRunning()}1067                >1068                  {dedupRunning() ? "removing..." : "remove duplicates"}1069                </button>1070              </div>1071            </div>1072          </Show>10731074          <p class="peach-settings-sub-label">MAINTENANCE</p>1075          <div class="peach-settings-row peach-settings-row--meta">1076            <span>Trash auto-delete</span>1077            <select1078              class="peach-settings-select"1079              value={String(props.trashRetentionDays)}1080              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1081            >1082              <option value="7">7 days</option>1083              <option value="30">30 days</option>1084              <option value="90">90 days</option>1085              <option value="0">Never</option>1086            </select>1087          </div>1088          <div class="peach-settings-row peach-settings-row--meta">1089            <span>Advanced Vault Health</span>1090            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1091              open1092            </button>1093          </div>1094          <Show when={declinedCardCount() > 0}>1095            <div class="peach-settings-row peach-settings-row--meta">1096              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1097              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1098                Clear1099              </button>1100            </div>1101          </Show>11021103          <p class="peach-settings-sub-label">DATA</p>1104          <div class="peach-settings-row peach-settings-row--meta">1105            <span>Import passwords</span>1106            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1107              open1108            </button>1109          </div>1110          <div class="peach-settings-row peach-settings-row--meta">1111            <span>Export backup</span>1112            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1113              open1114            </button>1115          </div>1116          <div class="peach-settings-row peach-settings-row--meta">1117            <span>1118              Peach Codex1119              <Show when={!isPro()}>1120                <span class="peach-pro-badge">Pro</span>1121              </Show>1122            </span>1123            <button1124              class="peach-btn peach-btn--subtle peach-btn--sm"1125              onClick={() => props.onOpenCodexGenerate?.()}1126            >1127              open1128            </button>1129          </div>1130          {/* Restore from Codex: intentionally never gated — a printed1131              Codex must remain restorable regardless of license state. */}1132          <div class="peach-settings-row peach-settings-row--meta">1133            <span>Restore from Codex</span>1134            <button1135              class="peach-btn peach-btn--subtle peach-btn--sm"1136              onClick={() => props.onOpenCodexRestore?.()}1137            >1138              open1139            </button>1140          </div>11411142          <span class="peach-settings-sub-label">VAULT HISTORY</span>1143          <Show when={historyLoading()}>1144            <p class="peach-settings-hint">Loading…</p>1145          </Show>1146          <Show when={!historyLoading() && historySnapshots().length === 0}>1147            <p class="peach-settings-hint">1148              No snapshots yet. Peach saves automatically before every change —1149              saves, syncs, and imports.1150            </p>1151          </Show>1152          <Show when={!historyLoading() && historySnapshots().length > 0}>1153            <p class="peach-settings-hint">1154              Restoring locks your vault. Unlock with the password that1155              encrypted that snapshot to confirm.1156            </p>1157            <div class="peach-history-list">1158              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1159                {(snap) => (1160                  <div class="peach-history-row">1161                    <div class="peach-history-meta">1162                      <span class="peach-history-time">1163                        {formatSnapshotTime(snap.savedAt)}1164                      </span>1165                      <span class="peach-history-sub">1166                        {[1167                          snap.entryCount !== null1168                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1169                            : null,1170                          reasonLabel(snap.reason),1171                        ]1172                          .filter(Boolean)1173                          .join(" · ")}1174                      </span>1175                    </div>1176                    <button1177                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1178                        historyRestoring() === snap.id ? " is-busy" : ""1179                      }`}1180                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1181                      disabled={historyRestoring() !== null}1182                    >1183                      <Show when={historyRestoring() === snap.id} fallback="Restore">1184                        …1185                      </Show>1186                    </button>1187                  </div>1188                )}1189              </For>1190            </div>1191            <Show when={historySnapshots().length > 3}>1192              <button1193                class="peach-settings-advanced-toggle"1194                onClick={() => setHistoryShowAll((v) => !v)}1195                aria-expanded={historyShowAll()}1196              >1197                <span class="peach-settings-hint" style="margin:0">1198                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1199                </span>1200                <span class="peach-settings-advanced-chevron">1201                  {historyShowAll() ? "▲" : "▼"}1202                </span>1203              </button>1204            </Show>1205            <p class="peach-history-footer">1206              {historySnapshots().length} of 10 snapshots1207            </p>1208          </Show>12091210          <button1211            class="peach-settings-advanced-toggle"1212            onClick={() => setShowVaultAdvanced((v) => !v)}1213            aria-expanded={showVaultAdvanced()}1214          >1215            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1216            <span class="peach-settings-advanced-chevron">1217              {showVaultAdvanced() ? "▲" : "▼"}1218            </span>1219          </button>12201221          <Show when={showVaultAdvanced()}>1222            <div class="peach-danger-card">1223              <div class="peach-danger-card-head">1224                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1225                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1226              </div>1227              <p class="peach-danger-card-copy">1228                Wipes the current vault and restores from a backup file.1229                Recoverable only if your backup is good.1230              </p>1231              <button1232                class="peach-danger-card-button"1233                onClick={() => props.onOpenFileImport?.()}1234              >1235                Choose backup file1236              </button>1237            </div>12381239            <Show when={props.onOpenPanic}>1240              <div class="peach-danger-card peach-danger-card--hazard">1241                <div class="peach-danger-card-head">1242                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1243                  <h3 class="peach-danger-card-title">Destroy vault</h3>1244                </div>1245                <p class="peach-danger-card-copy">1246                  Permanently wipes every entry, setting, and license on this1247                  device. There is no recovery.1248                </p>1249                <button1250                  class="peach-danger-card-button"1251                  onClick={() => props.onOpenPanic?.()}1252                >1253                  Begin destruction1254                </button>1255              </div>1256            </Show>1257          </Show>1258        </div>1259      </div>1260    );1261  }12621263  function renderSync() {1264    return (1265      <div class="peach-settings-body">1266        <div class="peach-settings-group">1267          <p class="peach-settings-sub-label">PAIRING</p>1268          <div class="peach-settings-row peach-settings-row--top">1269            <div class="peach-settings-row-main">1270              <span>1271                Peach Sync1272                <Show when={!isPro()}>1273                  <span class="peach-pro-badge">Pro</span>1274                </Show>1275              </span>1276              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1277                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1278              </span>1279            </div>1280            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1281          </div>1282          <div class="peach-settings-row peach-settings-row--meta">1283            <span>Status</span>1284            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1285          </div>1286          <div class="peach-settings-row peach-settings-row--meta">1287            <span>Sync hub</span>1288            <button1289              class="peach-btn peach-btn--subtle peach-btn--sm"1290              onClick={() => props.onOpenSyncPairing?.()}1291            >1292              open1293            </button>1294          </div>12951296          <p class="peach-settings-sub-label">DEVICE FLOW</p>1297          <div class="peach-settings-row peach-settings-row--top">1298            <div class="peach-settings-row-main">1299              <span>Pair new device</span>1300              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1301                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1302              </span>1303            </div>1304            <button1305              class="peach-btn peach-btn--subtle peach-btn--sm"1306              onClick={() => props.onOpenSyncPairing?.()}1307            >1308              pair1309            </button>1310          </div>1311          <div class="peach-info-notice" style="margin-top:8px">1312            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1313          </div>1314        </div>1315      </div>1316    );1317  }13181319  function renderAppearance() {1320    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1321      { id: "medium", label: "Default" },1322      { id: "large", label: "Large" },1323    ];1324    return (1325      <div class="peach-settings-body">1326        <div class="peach-settings-group">1327          <p class="peach-settings-sub-label">THEME</p>1328          <div class="peach-settings-row peach-settings-row--meta">1329            <span>Theme</span>1330            <button1331              class="peach-btn peach-btn--subtle peach-btn--sm"1332              onClick={() =>1333                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1334              }1335            >1336              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1337            </button>1338          </div>13391340          <p class="peach-settings-sub-label">ACCENT</p>1341          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1342            <span>Accent</span>1343            <div class="peach-accent-picker">1344              <For each={ACCENT_COLORS}>1345                {(color) => (1346                  <button1347                    class="peach-accent-swatch peach-swatch"1348                    classList={{ "is-active": props.accentColor === color.name }}1349                    style={{ background: color.hex }}1350                    onClick={() => void props.onAccentChange(color.name)}1351                    aria-label={color.name}1352                    title={color.name}1353                  />1354                )}1355              </For>1356            </div>1357          </div>13581359          <p class="peach-settings-sub-label">TEXT SIZE</p>1360          <div class="peach-settings-row peach-settings-row--meta">1361            <span>Size</span>1362            <div class="peach-pill-group">1363              <For each={sizeOptions}>1364                {(opt) => (1365                  <button1366                    class="peach-btn peach-btn--ghost peach-btn--sm"1367                    classList={{ "is-active": props.textSize === opt.id }}1368                    onClick={() => void props.onTextSizeChange(opt.id)}1369                  >1370                    {opt.label}1371                  </button>1372                )}1373              </For>1374            </div>1375          </div>13761377          <p class="peach-settings-sub-label">MOTION</p>1378          <div class="peach-settings-row peach-settings-row--meta">1379            <span>Reduce motion</span>1380            <button1381              class="peach-btn peach-btn--subtle peach-btn--sm"1382              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1383            >1384              {props.reduceMotion ? "On" : "Off"}1385            </button>1386          </div>1387        </div>1388      </div>1389    );1390  }13911392  function renderShortcuts() {1393    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1394    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13951396    function startCapture(action: ShortcutAction) {1397      setConflictMsg(null);1398      setCapturingAction(action);1399    }1400    function cancelCapture() {1401      setCapturingAction(null);1402      setConflictMsg(null);1403    }1404    function commitBinding(action: ShortcutAction, binding: Keybinding) {1405      const conflict = findConflict(props.shortcuts, binding, action);1406      if (conflict) {1407        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1408        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1409        return;1410      }1411      const next = { ...props.shortcuts, [action]: binding };1412      setCapturingAction(null);1413      setConflictMsg(null);1414      void props.onShortcutsChange(next);1415    }1416    function resetAction(action: ShortcutAction) {1417      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1418      void props.onShortcutsChange(next);1419    }1420    function resetAll() {1421      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1422    }14231424    // Capture-mode key handler — wired via ref onto the chip element so it1425    // catches the next non-modifier keypress and ignores other bindings.1426    function handleCaptureKey(e: KeyboardEvent) {1427      e.preventDefault();1428      e.stopPropagation();1429      if (e.key === "Escape") { cancelCapture(); return; }1430      const binding = keybindingFromEvent(e);1431      if (!binding) return; // bare modifier — keep waiting1432      const action = capturingAction();1433      if (action) commitBinding(action, binding);1434    }14351436    return (1437      <div class="peach-settings-body">1438        <div class="peach-settings-group peach-shortcuts-group">1439          <p class="peach-settings-sub-label">ENTRY VIEW</p>1440          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1441            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1442          </For>1443        </div>14441445        <div class="peach-settings-group peach-shortcuts-group">1446          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1447          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1448            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1449          </For>1450        </div>14511452        <Show when={conflictMsg()}>1453          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1454        </Show>14551456        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1457          <div class="peach-settings-row peach-settings-row--meta">1458            <span>Inline autofill on page</span>1459            <a1460              class="peach-settings-link"1461              href="chrome://extensions/shortcuts"1462              onClick={(e) => {1463                e.preventDefault();1464                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1465              }}1466            >1467              ⌘⇧P — customize ↗1468            </a>1469          </div>1470          <button1471            class="peach-btn peach-btn--subtle peach-btn--sm"1472            style="align-self:flex-start"1473            onClick={resetAll}1474          >1475            Reset all to defaults1476          </button>1477        </div>1478      </div>1479    );14801481    function renderRow(1482      desc: typeof SHORTCUT_DESCRIPTORS[number],1483      capturing: () => ShortcutAction | null,1484      onStart: (a: ShortcutAction) => void,1485      onCancel: () => void,1486      onReset: (a: ShortcutAction) => void,1487      onKey: (e: KeyboardEvent) => void,1488    ) {1489      const isActive = () => capturing() === desc.action;1490      const binding = () => props.shortcuts[desc.action];1491      const isCustom = () => {1492        const b = binding();1493        const d = DEFAULT_BINDINGS[desc.action];1494        return b.key !== d.key1495          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1496          || Boolean(b.meta)  !== Boolean(d.meta)1497          || Boolean(b.shift) !== Boolean(d.shift)1498          || Boolean(b.alt)   !== Boolean(d.alt);1499      };1500      return (1501        <div class="peach-shortcut-row">1502          <span class="peach-shortcut-row-label">{desc.label}</span>1503          <button1504            class="peach-shortcut-chip"1505            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1506            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1507            onKeyDown={(e) => { if (isActive()) onKey(e); }}1508            ref={(el) => {1509              // Auto-focus when entering capture mode so the keydown listener catches the next press.1510              if (el) {1511                queueMicrotask(() => { if (isActive()) el.focus(); });1512              }1513            }}1514          >1515            {isActive() ? "Press a key…" : formatKeybinding(binding())}1516          </button>1517          <Show when={isCustom() && !isActive()}>1518            <button1519              class="peach-shortcut-reset"1520              title="Reset to default"1521              onClick={() => onReset(desc.action)}1522            >1523              ↺1524            </button>1525          </Show>1526        </div>1527      );1528    }1529  }15301531  const PRO_FEATURES: Array<{1532    feature: ProFeature;1533    name: string;1534    desc: string;1535  }> = [1536    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1537    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1538    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1539    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1540  ];15411542  const PRO_SECURITY_FEATURES: Array<{1543    feature: ProFeature;1544    name: string;1545    desc: string;1546  }> = [1547    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1548    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1549  ];15501551  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1552  const [showKey, setShowKey] = createSignal(false);15531554  const [deactivating, setDeactivating] = createSignal(false);15551556  async function handleDeactivateLicense() {1557    setDeactivating(true);1558    await sendMessage({ type: "DEACTIVATE_LICENSE" });1559    setLicenseInfo(null);1560    await props.onLicenseChange?.();1561    setDeactivating(false);1562  }15631564  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1565    setCanaryTestingId(entry.id);1566    setCanaryTestResult(null);1567    const res = await sendMessage({1568      type: "FIRE_CANARY_PING",1569      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1570    }) as { ok: boolean };1571    setCanaryTestResult({ id: entry.id, ok: res.ok });1572    setCanaryTestingId(null);1573    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1574  }15751576  function renderLicense() {1577    async function onActivate() {1578      await handleActivateLicense();1579      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1580        await props.onLicenseChange?.();1581        triggerCelebration();1582      }1583    }15841585    return (1586      <div class="peach-settings-body">1587        <Show1588          when={isPro()}1589          fallback={1590                <div class="peach-pro-upgrade">1591                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15921593                  <h2 class="peach-pro-headline">1594                    Every<em>thing,</em><br />every<em>where.</em>1595                  </h2>15961597                  <p class="peach-pro-subtitle">1598                    One key unlocks every Pro feature — for the lifetime of the app.1599                  </p>16001601                  <div class="peach-pro-grid">1602                    <For each={PRO_FEATURES}>1603                      {(item, i) => (1604                        <div1605                          class="peach-pro-grid-item"1606                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1607                        >1608                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1609                          <span class="peach-pro-grid-name">{item.name}</span>1610                          <span class="peach-pro-grid-desc">{item.desc}</span>1611                        </div>1612                      )}1613                    </For>1614                  </div>16151616                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1617                  <div class="peach-pro-grid">1618                    <For each={PRO_SECURITY_FEATURES}>1619                      {(item, i) => (1620                        <div1621                          class="peach-pro-grid-item"1622                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1623                        >1624                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1625                          <span class="peach-pro-grid-name">{item.name}</span>1626                          <span class="peach-pro-grid-desc">{item.desc}</span>1627                        </div>1628                      )}1629                    </For>1630                  </div>16311632                  <div class="peach-pro-upgrade-actions">1633                    <p class="peach-pro-pricing">1634                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1635                    </p>1636                    <a1637                      href="https://peachpasswords.com/#pricing"1638                      target="_blank"1639                      rel="noopener noreferrer"1640                      class="peach-pro-upgrade-cta"1641                    >1642                      Get Peach Pro1643                    </a>1644                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1645                    <div class="peach-pro-key-inline">1646                      <input1647                        class="peach-pro-key-inline-input"1648                        type="text"1649                        placeholder="PEACH-XXXX-XXXX-XXXX"1650                        value={licenseKeyInput()}1651                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1652                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1653                        autocomplete="off"1654                        spellcheck={false}1655                      />1656                      <button1657                        class="peach-pro-key-apply"1658                        onClick={() => void onActivate()}1659                        disabled={activatingLicense() || !licenseKeyInput().trim()}1660                      >1661                        {activatingLicense() ? "…" : "APPLY"}1662                      </button>1663                    </div>1664                    <Show when={licenseError()}>1665                      <p class="peach-pro-key-error">{licenseError()}</p>1666                    </Show>1667                  </div>1668                </div>1669          }1670        >1671          <div class="peach-pro-active">1672            <div class="peach-pro-active-hero">1673              <button1674                class="peach-pro-active-mark-wrap"1675                type="button"1676                aria-label="Celebrate"1677                onClick={() => triggerCelebration()}1678              >1679                <PeachMark class="peach-pro-active-mark" />1680                <Show when={justActivated()}>1681                  <div class="peach-pro-sparks" aria-hidden="true">1682                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>1683                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>1684                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>1685                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>1686                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>1687                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>1688                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>1689                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>1690                  </div>1691                </Show>1692              </button>1693              <h2 class="peach-pro-active-title">Peach Pro</h2>1694              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1695              <Show when={justActivated()}>1696                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>1697              </Show>1698            </div>16991700            <Show when={licenseKey()}>1701              <div class="peach-settings-license">1702                <span class="peach-settings-label">LICENSE KEY</span>1703                <div class="peach-settings-license-row">1704                  <Show1705                    when={showKey()}1706                    fallback={1707                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1708                        ••••••••••••••••••••••••••••1709                      </span>1710                    }1711                  >1712                    <span class="peach-settings-license-key">{licenseKey()!}</span>1713                  </Show>1714                  <div class="peach-settings-license-actions">1715                    <button1716                      class="peach-settings-license-copy"1717                      title="Copy license key"1718                      onClick={() => copyLicenseKey(licenseKey()!)}1719                    >1720                      {copied() ? "✓" : "copy"}1721                    </button>1722                    <button1723                      class="peach-settings-license-toggle"1724                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1725                      title={showKey() ? "Hide" : "Reveal"}1726                      onClick={() => setShowKey(v => !v)}1727                    >1728                      <Show1729                        when={showKey()}1730                        fallback={1731                          <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">1732                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1733                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1734                          </svg>1735                        }1736                      >1737                        <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">1738                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1739                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1740                          <line x1="2" y1="2" x2="12" y2="12" />1741                        </svg>1742                      </Show>1743                    </button>1744                  </div>1745                </div>1746              </div>1747            </Show>17481749            <div class="peach-pro-active-features">1750              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1751                {(item) => (1752                  <div class="peach-pro-active-feature">1753                    <span class="peach-pro-active-feature-mark">✦</span>1754                    <span class="peach-pro-active-feature-name">{item.name}</span>1755                  </div>1756                )}1757              </For>1758            </div>17591760            <button1761              class="peach-pro-deactivate"1762              onClick={() => void handleDeactivateLicense()}1763              disabled={deactivating()}1764            >1765              {deactivating() ? "Deactivating…" : "Deactivate"}1766            </button>1767          </div>1768        </Show>1769      </div>1770    );1771  }17721773  function renderFeedback() {1774    return (1775      <div class="peach-settings-body">1776        <div class="peach-settings-group peach-feedback-group">1777          <Show1778            when={feedbackState() === 'sent'}1779            fallback={1780              <>1781                <p class="peach-feedback-intro">1782                  Help us improve Peach. Every report goes directly to our inbox.1783                </p>17841785                <p class="peach-settings-sub-label">TYPE</p>1786                <div class="peach-feedback-chips">1787                  <For each={FEEDBACK_CATEGORIES}>1788                    {(cat) => {1789                      const selected = () => fbCategory() === cat;1790                      return (1791                        <button1792                          type="button"1793                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1794                          aria-pressed={selected()}1795                          onClick={() => setFbCategory(cat)}1796                        >1797                          {cat}1798                        </button>1799                      );1800                    }}1801                  </For>1802                </div>18031804                <p class="peach-settings-sub-label">MESSAGE</p>1805                <textarea1806                  class="peach-feedback-textarea"1807                  placeholder="Describe the issue or idea..."1808                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1809                  value={fbMessage()}1810                  onInput={(e) => setFbMessage(e.currentTarget.value)}1811                />18121813                <p class="peach-feedback-meta">1814                  Version {version} · will be included automatically1815                </p>18161817                <button1818                  type="button"1819                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1820                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}1821                  onClick={() => void handleSubmitFeedback()}1822                >1823                  <Switch>1824                    <Match when={feedbackState() === 'idle'}>Send</Match>1825                    <Match when={feedbackState() === 'sending'}>Sending...</Match>1826                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>1827                  </Switch>1828                </button>18291830                <p class="peach-feedback-anon">1831                  Sent anonymously · no account required1832                </p>1833              </>1834            }1835          >1836            <div class="peach-feedback-thanks" role="status" aria-live="polite">1837              <p class="peach-feedback-thanks-title">Thank you</p>1838              <p>1839                Your note is in the queue. Someone will process it soon.1840              </p>1841            </div>1842          </Show>1843        </div>1844      </div>1845    );1846  }18471848  const bodies: StackBodies = {1849    protection: renderSecurity(),1850    vault: renderVault(),1851    sync: renderSync(),1852    appearance: renderAppearance(),1853    shortcuts: renderShortcuts(),1854    pro: renderLicense(),1855    feedback: renderFeedback(),1856  };18571858  return (1859    <Show1860      when={showNewRecoveryWords()}1861      fallback={1862        <div1863          class="peach-overlay-view peach-settings-overlay"1864          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1865          style="display:flex;flex-direction:column"1866        >1867          <StackDeck1868            active={activeCard()}1869            onActiveChange={setActiveCard}1870            onBack={props.onBack}1871            cards={cards()}1872            bodies={bodies}1873            version={version}1874            isFanning={isFanning()}1875            celebratingCardId={justActivated() ? "pro" : null}1876            isPro={isPro()}1877          />1878        </div>1879      }1880    >1881      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1882    </Show>1883  );1884}