Settings.tsx

61.7 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { ACCENT_COLORS } from "../../shared/types";5import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";6import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";7import {8  DEFAULT_BINDINGS,9  findConflict,10  formatKeybinding,11  keybindingFromEvent,12  SHORTCUT_DESCRIPTORS,13  type Keybinding,14  type ShortcutAction,15} from "../../shared/shortcuts";1617interface LicenseInfo {18  valid: boolean;19  plan: "free" | "pro";20  issued?: string;21}2223interface SettingsProps {24  entriesCount: number;25  breachMonitoring: boolean;26  lockOnClose: boolean;27  autoLockMinutes: number;28  trashRetentionDays: number;29  theme: "dark" | "light";30  accentColor: AccentColorName;31  displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";32  textSize: "small" | "medium" | "large";33  reduceMotion: boolean;34  isPro?: boolean;35  initialSection?: string | null;36  upgradeHighlight?: ProFeature | null;37  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;38  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;39  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;40  onTrashRetentionChange: (days: number) => void | Promise<void>;41  onThemeChange: (theme: "dark" | "light") => void;42  onAccentChange: (name: AccentColorName) => void | Promise<void>;43  onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;44  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;45  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;46  shortcuts: Record<ShortcutAction, Keybinding>;47  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;48  onLicenseChange?: () => void | Promise<void>;49  onBack: () => void;50  onImport: () => void;51  onExport: () => void;52  onBreachCheckDone: () => void;53  onOpenHealth?: () => void;54  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;55  onOpenFileImport?: () => void;56}5758type SectionName =59  | "security"60  | "vault"61  | "sync"62  | "appearance"63  | "shortcuts"64  | "license"65  | "feedback";6667const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];6869function formatActivatedSince(issued: string | undefined): string {70  if (!issued) return "Active";71  try {72    const d = new Date(issued);73    if (Number.isNaN(d.getTime())) return "Active";74    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;75  } catch {76    return "Active";77  }78}7980function isSectionName(value: string): value is SectionName {81  return (82    value === "security" ||83    value === "vault" ||84    value === "sync" ||85    value === "appearance" ||86    value === "shortcuts" ||87    value === "license" ||88    value === "feedback"89  );90}9192export function SettingsView(props: SettingsProps) {93  const version = chrome.runtime.getManifest().version;94  const initial = props.initialSection;95  const [activeSection, setActiveSection] = createSignal<SectionName | null>(96    initial && isSectionName(initial) ? (initial as SectionName) : null97  );9899  const [checkingBreaches, setCheckingBreaches] = createSignal(false);100  const [breachResult, setBreachResult] = createSignal<string | null>(null);101  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);102  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");103  const [licenseError, setLicenseError] = createSignal<string | null>(null);104  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);105  const [activatingLicense, setActivatingLicense] = createSignal(false);106107  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);108  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);109  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");110  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);111  // Prefer the prop (cached at App boot), fall back to the sync-state probe.112  // The prop is authoritative once present so license activation reflects113  // immediately without waiting for a sync round-trip.114  const [syncProStatus, setSyncProStatus] = createSignal(false);115  const isPro = () => props.isPro ?? syncProStatus();116  const [syncingNow, setSyncingNow] = createSignal(false);117  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);118  const [passkeySaving, setPasskeySaving] = createSignal(false);119120  const [dedupCount, setDedupCount] = createSignal<number | null>(null);121  const [dedupRunning, setDedupRunning] = createSignal(false);122  const [dedupDone, setDedupDone] = createSignal<number | null>(null);123124  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);125126  const [showChangePw, setShowChangePw] = createSignal(false);127  const [changePwCurrent, setChangePwCurrent] = createSignal("");128  const [changePwNew, setChangePwNew] = createSignal("");129  const [changePwConfirm, setChangePwConfirm] = createSignal("");130  const [changePwSaving, setChangePwSaving] = createSignal(false);131  const [changePwError, setChangePwError] = createSignal<string | null>(null);132  const [changePwSuccess, setChangePwSuccess] = createSignal(false);133134  const [duressEnabled, setDuressEnabled] = createSignal(false);135  const [showDuressSetup, setShowDuressSetup] = createSignal(false);136  const [duressPassword, setDuressPassword] = createSignal("");137  const [duressConfirm, setDuressConfirm] = createSignal("");138  const [duressMainPassword, setDuressMainPassword] = createSignal("");139  const [duressSaving, setDuressSaving] = createSignal(false);140  const [duressError, setDuressError] = createSignal<string | null>(null);141  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);142143  const [fbCategory, setFbCategory] = createSignal("Bug report");144  const [fbMessage, setFbMessage] = createSignal("");145  const [fbSubmitting, setFbSubmitting] = createSignal(false);146  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);147148  onMount(async () => {149    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([150      sendMessage({ type: "GET_LICENSE" }),151      sendMessage({ type: "GET_SYNC_STATE" }),152      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),153      sendMessage({ type: "IS_DURESS_ENABLED" }),154      sendMessage({ type: "GET_SETTINGS" }),155    ]);156157    if ((licenseRes as { ok: boolean }).ok) {158      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);159    }160161    if ((syncRes as { ok: boolean }).ok) {162      const data = syncRes as { ok: boolean; data?: {163        pairedDevices?: PairedDevice[];164        trustedNetworks?: TrustedNetwork[];165        status?: SyncStatus;166        lastSyncAt?: number;167        isPro?: boolean;168      }};169      if (data.data) {170        setPairedDevices(data.data.pairedDevices ?? []);171        setTrustedNetworks(data.data.trustedNetworks ?? []);172        setSyncStatus(data.data.status ?? "offline");173        setLastSyncAt(data.data.lastSyncAt);174        setSyncProStatus(data.data.isPro ?? false);175      }176    }177178    if ((passkeyRes as { ok: boolean }).ok) {179      setPasskeyState(180        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null181      );182    }183184    if ((duressRes as { ok: boolean }).ok) {185      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);186    }187188    if ((settingsRes as { ok: boolean }).ok) {189      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;190      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);191    }192193    chrome.runtime.onMessage.addListener((msg) => {194      if (msg.type === "SYNC_STATE_UPDATE") {195        setPairedDevices(msg.pairedDevices ?? []);196        setTrustedNetworks(msg.trustedNetworks ?? []);197        setSyncStatus(msg.status ?? "offline");198        setLastSyncAt(msg.lastSyncAt);199        setSyncProStatus(msg.isPro ?? false);200      }201    });202  });203204  async function handleChangePassword() {205    setChangePwError(null);206    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }207    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }208    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }209    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }210    setChangePwSaving(true);211    const res = await sendMessage({212      type: "CHANGE_PASSWORD",213      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },214    }) as { ok: boolean; error?: string };215    if (res.ok) {216      setChangePwSuccess(true);217      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");218      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);219    } else {220      setChangePwError(res.error ?? "failed to change password");221    }222    setChangePwSaving(false);223  }224225  async function handleActivateLicense() {226    const key = licenseKeyInput().trim();227    if (!key) return;228    setActivatingLicense(true);229    setLicenseError(null);230    setLicenseSuccess(null);231    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });232    setActivatingLicense(false);233    if ((res as { ok: boolean }).ok) {234      const data = (res as { ok: boolean; data?: LicenseInfo }).data;235      if (data?.valid) {236        setLicenseInfo(data);237        setLicenseSuccess("License activated!");238        setLicenseKeyInput("");239      } else {240        setLicenseError("Invalid license key");241      }242    } else {243      setLicenseError("Activation failed");244    }245  }246247  async function handleTogglePasskeys() {248    const current = passkeyState();249    if (!current || !current.supported) return;250    setPasskeySaving(true);251    const res = await sendMessage({252      type: "SET_PASSKEY_PROVIDER_ENABLED",253      payload: { enabled: !current.enabled },254    });255    setPasskeySaving(false);256    if ((res as { ok: boolean }).ok) {257      setPasskeyState(258        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null259      );260    }261  }262263  async function handleCheckAllBreaches() {264    setCheckingBreaches(true);265    setBreachResult(null);266    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });267    setCheckingBreaches(false);268    if ((res as { ok: boolean }).ok) {269      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};270      const breached = Object.values(data).filter((count) => count > 0).length;271      setBreachResult(272        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"273      );274      props.onBreachCheckDone();275    }276  }277278  async function handleSyncNow() {279    if (syncStatus() === "not_trusted") return;280    setSyncingNow(true);281    await sendMessage({ type: "TRIGGER_SYNC" });282    setTimeout(() => setSyncingNow(false), 3000);283  }284285  async function handleRemoveDevice(deviceId: string) {286    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });287  }288289  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {290    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });291  }292293  async function handleMarkNetworkTrusted() {294    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });295  }296297  function formatRelativeTime(timestamp?: number): string {298    if (!timestamp) return "Never";299    const diff = Date.now() - timestamp;300    const minutes = Math.floor(diff / 60000);301    const hours = Math.floor(diff / 3600000);302    const days = Math.floor(diff / 86400000);303    if (minutes < 1) return "Just now";304    if (minutes < 60) return `${minutes}m ago`;305    if (hours < 24) return `${hours}h ago`;306    return `${days}d ago`;307  }308309  function getStatusColor(status: SyncStatus): string {310    switch (status) {311      case "synced": return "var(--peach-sync-ok)";312      case "syncing": return "var(--peach-sync-busy)";313      case "no_peers": return "var(--peach-sync-off)";314      case "not_trusted": return "var(--peach-sync-err)";315      case "offline": return "var(--peach-sync-off)";316      default: return "var(--peach-sync-off)";317    }318  }319320  function getStatusLabel(status: SyncStatus): string {321    switch (status) {322      case "synced": return "Synced";323      case "syncing": return "Syncing";324      case "no_peers": return "No peers visible";325      case "not_trusted": return "Not on a trusted network";326      case "offline": return "Offline";327      default: return "Unknown";328    }329  }330331  function isCurrentNetworkTrusted(): boolean {332    return trustedNetworks().length > 0;333  }334335  async function handleSetupDuress() {336    const mainPw = duressMainPassword().trim();337    const duressPw = duressPassword().trim();338    const confirmPw = duressConfirm().trim();339340    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }341    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }342    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }343    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }344345    setDuressSaving(true);346    setDuressError(null);347348    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };349    if (!verifyRes.ok || !verifyRes.data) {350      setDuressError("incorrect main password");351      setDuressSaving(false);352      return;353    }354355    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };356    setDuressSaving(false);357    if (res.ok) {358      setDuressEnabled(true);359      setShowDuressSetup(false);360      setDuressPassword("");361      setDuressConfirm("");362      setDuressMainPassword("");363      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");364      setTimeout(() => setDuressSuccess(null), 5000);365    } else {366      setDuressError(res.error ?? "failed to set duress password");367    }368  }369370  async function handleClearDeclinedCards() {371    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });372    setDeclinedCardCount(0);373  }374375  async function handleCountDuplicates() {376    setDedupRunning(true);377    setDedupDone(null);378    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };379    setDedupRunning(false);380    if (res.ok) setDedupCount(res.data ?? 0);381  }382383  async function handleDeduplicate() {384    setDedupRunning(true);385    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };386    setDedupRunning(false);387    if (res.ok) {388      setDedupDone(res.data ?? 0);389      setDedupCount(0);390    }391  }392393  async function handleDisableDuress() {394    const mainPw = duressMainPassword().trim();395    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }396397    setDuressSaving(true);398    setDuressError(null);399400    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };401    if (!verifyRes.ok || !verifyRes.data) {402      setDuressError("incorrect main password");403      setDuressSaving(false);404      return;405    }406407    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };408    setDuressSaving(false);409    if (res.ok) {410      setDuressEnabled(false);411      setShowDuressSetup(false);412      setDuressMainPassword("");413      setDuressSuccess("Duress password disabled.");414      setTimeout(() => setDuressSuccess(null), 4000);415    } else {416      setDuressError(res.error ?? "failed to disable duress password");417    }418  }419420  async function handleSubmitFeedback() {421    if (!fbMessage().trim() || fbSubmitting()) return;422    setFbSubmitting(true);423    setFbResult(null);424    const res = await sendMessage({425      type: "SUBMIT_FEEDBACK",426      payload: {427        category: fbCategory(),428        message: fbMessage().trim(),429        version,430        browser: navigator.userAgent,431      },432    }) as { ok: boolean; error?: string };433    setFbSubmitting(false);434    if (res.ok) {435      setFbResult("success");436      setFbMessage("");437      setTimeout(() => setFbResult(null), 4000);438    } else {439      setFbResult("error");440    }441  }442443  function handleHeaderBack() {444    if (activeSection() !== null) {445      setActiveSection(null);446    } else {447      props.onBack();448    }449  }450451  function currentTitle(): string {452    const s = activeSection();453    if (s === null) return "settings";454    if (s === "license") return "account";455    return s;456  }457458  function renderCatRow(opts: {459    section: SectionName;460    name: string;461    desc: string;462    icon: () => any;463    pro?: boolean;464  }) {465    return (466      <button467        class="peach-settings-cat-row"468        onClick={() => setActiveSection(opts.section)}469      >470        <div class="peach-settings-cat-icon">{opts.icon()}</div>471        <div class="peach-settings-cat-content">472          <span class="peach-settings-cat-name">473            {opts.name}474            <Show when={opts.pro && !isPro()}>475              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>476            </Show>477          </span>478          <span class="peach-settings-cat-desc">{opts.desc}</span>479        </div>480        <svg481          class="peach-settings-cat-chevron"482          width="6" height="10" viewBox="0 0 6 10" fill="none"483          stroke="currentColor" stroke-width="1.1"484          stroke-linecap="round" stroke-linejoin="round"485        >486          <path d="M1 1l4 4-4 4" />487        </svg>488      </button>489    );490  }491492  const iconShield = () => (493    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">494      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>495    </svg>496  );497  const iconKey = () => (498    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">499      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>500    </svg>501  );502  const iconLock = () => (503    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">504      <rect x="3" y="7" width="10" height="7" rx="1.5"/>505      <path d="M6 7V5a3 3 0 016 0v2"/>506    </svg>507  );508  const iconSync = () => (509    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">510      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>511      <path d="M14 6l-2 2-2-2"/>512      <path d="M2 10l2-2 2 2"/>513    </svg>514  );515  const iconSun = () => (516    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">517      <circle cx="8" cy="8" r="3"/>518      <path d="M8 2v1m0 10v1M2 8h1m10 0h1m-2.1-4.1-.7.7M4.8 11.3l-.7.7m0-7.7.7.7m6.5 6.3-.7.7"/>519    </svg>520  );521  const iconKeys = () => (522    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">523      <rect x="2" y="5" width="5" height="4" rx="1"/>524      <rect x="9" y="7" width="5" height="4" rx="1"/>525      <path d="M7 7h2m-2 2h2"/>526    </svg>527  );528  const iconBubble = () => (529    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">530      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>531    </svg>532  );533534  function renderHome() {535    return (536      <div class="peach-settings-body">537        <p class="peach-settings-nav-label">PROTECTION</p>538        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}539        {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}540541        <p class="peach-settings-nav-label">VAULT</p>542        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}543        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}544545        <p class="peach-settings-nav-label">PREFERENCES</p>546        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}547        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}548549        <p class="peach-settings-nav-label">SUPPORT</p>550        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}551552        <div class="peach-settings-footer">553          <span>Peach {version}</span>554          <span class="peach-divider"></span>555          <span>Browser extension</span>556        </div>557      </div>558    );559  }560561  function renderSecurity() {562    return (563      <div class="peach-settings-body">564        <div class="peach-settings-group">565          <p class="peach-settings-sub-label">LOCKING</p>566          <div class="peach-settings-row">567            <span>Lock on close</span>568            <button569              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}570              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}571              aria-label="Toggle lock on close"572            >573              <span class="peach-toggle-knob"></span>574            </button>575          </div>576          <div class="peach-settings-row">577            <span>Auto-lock after</span>578            <select579              class="peach-settings-select"580              value={String(props.autoLockMinutes)}581              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}582            >583              <option value="1">1 minute</option>584              <option value="5">5 minutes</option>585              <option value="15">15 minutes</option>586              <option value="30">30 minutes</option>587              <option value="0">Never</option>588            </select>589          </div>590591          <p class="peach-settings-sub-label">BREACH PROTECTION</p>592          <div class="peach-settings-row">593            <span>Breach monitoring</span>594            <button595              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}596              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}597              aria-label="Toggle breach monitoring"598            >599              <span class="peach-toggle-knob"></span>600            </button>601          </div>602          <div class="peach-settings-row peach-settings-row--meta">603            <span>Check all passwords</span>604            <button605              class="peach-btn peach-btn--subtle peach-btn--sm"606              onClick={() => void handleCheckAllBreaches()}607              disabled={checkingBreaches()}608            >609              {checkingBreaches() ? "checking..." : "run"}610            </button>611          </div>612          <Show when={breachResult()}>613            <div class="peach-settings-row peach-settings-row--meta">614              <span>Last check</span>615              <span class="peach-settings-value">{breachResult()}</span>616            </div>617          </Show>618619          <p class="peach-settings-sub-label">MASTER PASSWORD</p>620          <div class="peach-settings-row peach-settings-row--meta">621            <span>Master password</span>622            <button623              class="peach-btn peach-btn--subtle peach-btn--sm"624              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}625            >626              {showChangePw() ? "cancel" : "change"}627            </button>628          </div>629          <Show when={showChangePw()}>630            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">631              <input632                class="peach-settings-input"633                type="password"634                placeholder="current password"635                value={changePwCurrent()}636                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}637                autocomplete="current-password"638              />639              <input640                class="peach-settings-input"641                type="password"642                placeholder="new password (min 12 chars)"643                value={changePwNew()}644                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}645                autocomplete="new-password"646              />647              <input648                class="peach-settings-input"649                type="password"650                placeholder="confirm new password"651                value={changePwConfirm()}652                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}653                autocomplete="new-password"654                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}655              />656              <Show when={changePwError()}>657                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>658              </Show>659              <Show when={changePwSuccess()}>660                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>661              </Show>662              <button663                class="peach-btn peach-btn--accent peach-btn--sm"664                style="align-self:flex-start"665                onClick={() => void handleChangePassword()}666                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}667              >668                {changePwSaving() ? "saving…" : "save new password"}669              </button>670            </div>671          </Show>672673          <p class="peach-settings-sub-label">DURESS PASSWORD</p>674          <div class="peach-settings-row peach-settings-row--top">675            <div class="peach-settings-row-main">676              <span>Duress password</span>677              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">678                If entered under coercion, shows a decoy empty vault instead of your real one.679              </span>680            </div>681            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>682              {duressEnabled() ? "on" : "off"}683            </div>684          </div>685686          <Show when={duressEnabled() && !showDuressSetup()}>687            <div class="peach-settings-row peach-settings-row--meta">688              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">689                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.690              </span>691            </div>692          </Show>693694          <Show when={duressSuccess()}>695            <div class="peach-settings-row peach-settings-row--meta">696              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>697            </div>698          </Show>699700          <Show when={!showDuressSetup()}>701            <div class="peach-settings-row peach-settings-row--meta">702              <button703                class="peach-btn peach-btn--subtle peach-btn--sm"704                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}705              >706                {duressEnabled() ? "Change / disable" : "Set up"}707              </button>708            </div>709          </Show>710711          <Show when={showDuressSetup()}>712            <div class="peach-duress-form">713              <label class="peach-label">main password (to confirm)</label>714              <input715                class="peach-settings-input"716                type="password"717                placeholder="your current master password"718                autocomplete="current-password"719                value={duressMainPassword()}720                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}721              />722              <Show when={!duressEnabled()}>723                <label class="peach-label">new duress password</label>724                <input725                  class="peach-settings-input"726                  type="password"727                  placeholder="duress password (min 8 chars)"728                  autocomplete="new-password"729                  value={duressPassword()}730                  onInput={(e) => setDuressPassword(e.currentTarget.value)}731                />732                <label class="peach-label">confirm duress password</label>733                <input734                  class="peach-settings-input"735                  type="password"736                  placeholder="confirm duress password"737                  autocomplete="new-password"738                  value={duressConfirm()}739                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}740                />741              </Show>742743              <Show when={duressError()}>744                <p class="peach-hint peach-hint-error">{duressError()}</p>745              </Show>746747              <div class="peach-duress-actions">748                <button749                  class="peach-btn peach-btn--subtle peach-btn--sm"750                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}751                >752                  cancel753                </button>754                <Show when={duressEnabled()}>755                  <button756                    class="peach-btn peach-btn--ghost peach-btn--sm"757                    style="color:var(--peach-breach)"758                    onClick={() => void handleDisableDuress()}759                    disabled={duressSaving()}760                  >761                    {duressSaving() ? "disabling..." : "disable duress"}762                  </button>763                </Show>764                <Show when={!duressEnabled()}>765                  <button766                    class="peach-btn peach-btn--accent peach-btn--sm"767                    onClick={() => void handleSetupDuress()}768                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}769                  >770                    {duressSaving() ? "saving..." : "save"}771                  </button>772                </Show>773              </div>774            </div>775          </Show>776777          <p class="peach-settings-sub-label">PASSKEYS</p>778          <div class="peach-settings-row peach-settings-row--top">779            <div class="peach-settings-row-main">780              <span>Intercept passkey requests</span>781              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">782                Peach can handle passkey sign-in and creation on supported Chromium browsers.783              </span>784            </div>785            <button786              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}787              onClick={() => void handleTogglePasskeys()}788              aria-label="Toggle passkey interception"789              disabled={!passkeyState()?.supported || passkeySaving()}790            >791              <span class="peach-toggle-knob"></span>792            </button>793          </div>794          <div class="peach-settings-row peach-settings-row--meta">795            <span>Status</span>796            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>797          </div>798          <div class="peach-settings-row peach-settings-row--meta">799            <span>Browser</span>800            <span class="peach-settings-value">801              {passkeyState()?.browserLabel ?? "Unknown"}802              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}803            </span>804          </div>805          <div class="peach-info-notice" style="margin-top:8px">806            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.807          </div>808          <Show when={passkeyState()?.conflict}>809            <div class="peach-settings-row peach-settings-row--meta">810              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>811            </div>812          </Show>813        </div>814      </div>815    );816  }817818  function renderVault() {819    return (820      <div class="peach-settings-body">821        <div class="peach-settings-group">822          <p class="peach-settings-sub-label">ENTRIES</p>823          <div class="peach-settings-row peach-settings-row--meta">824            <span>Entries</span>825            <span class="peach-settings-value">{props.entriesCount}</span>826          </div>827          <div class="peach-settings-row peach-settings-row--meta">828            <span>Duplicates</span>829            <Show when={dedupDone() !== null} fallback={830              <button831                class="peach-btn peach-btn--subtle peach-btn--sm"832                onClick={() => void handleCountDuplicates()}833                disabled={dedupRunning()}834              >835                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}836              </button>837            }>838              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">839                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}840              </span>841            </Show>842          </div>843          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>844            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">845              <div class="peach-restore-warning">846                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.847              </div>848              <div style="display:flex;gap:6px">849                <button850                  class="peach-btn peach-btn--subtle peach-btn--sm"851                  onClick={() => setDedupCount(null)}852                >853                  cancel854                </button>855                <button856                  class="peach-btn peach-btn--accent peach-btn--sm"857                  onClick={() => void handleDeduplicate()}858                  disabled={dedupRunning()}859                >860                  {dedupRunning() ? "removing..." : "remove duplicates"}861                </button>862              </div>863            </div>864          </Show>865866          <p class="peach-settings-sub-label">MAINTENANCE</p>867          <div class="peach-settings-row peach-settings-row--meta">868            <span>Trash auto-delete</span>869            <select870              class="peach-settings-select"871              value={String(props.trashRetentionDays)}872              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}873            >874              <option value="7">7 days</option>875              <option value="30">30 days</option>876              <option value="90">90 days</option>877              <option value="0">Never</option>878            </select>879          </div>880          <div class="peach-settings-row peach-settings-row--meta">881            <span>Vault Health</span>882            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>883              open884            </button>885          </div>886          <Show when={declinedCardCount() > 0}>887            <div class="peach-settings-row peach-settings-row--meta">888              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>889              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>890                Clear891              </button>892            </div>893          </Show>894895          <p class="peach-settings-sub-label">DATA</p>896          <div class="peach-settings-row peach-settings-row--meta">897            <span>Import passwords</span>898            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>899              open900            </button>901          </div>902          <div class="peach-settings-row peach-settings-row--meta">903            <span>Export backup</span>904            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>905              open906            </button>907          </div>908          <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">909            <span>Replace vault from backup</span>910            <div class="peach-restore-warning" style="margin-top:2px">911              This will permanently replace your current vault with the backup.912            </div>913            <div style="display:flex;gap:6px">914              <button915                class="peach-btn peach-btn--subtle peach-btn--sm"916                onClick={() => props.onOpenFileImport?.()}917              >918                backup file919              </button>920            </div>921          </div>922        </div>923      </div>924    );925  }926927  function renderSync() {928    if (!isPro()) {929      return (930        <div class="peach-settings-body">931          <div class="peach-pro-upgrade">932            <div class="peach-pro-upgrade-hero">933              <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">934                <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>935                <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>936                <path d="M13.5 15.5C15 12 17 10 18 10C19 10 21 12 22.5 15.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>937                <path d="M13.5 20.5C15 24 17 26 18 26C19 26 21 24 22.5 20.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>938                <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>939                <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>940                <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>941              </svg>942              <h2 class="peach-pro-upgrade-title">Peach Sync</h2>943              <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>944            </div>945946            <div class="peach-pro-features">947              <div class="peach-pro-feature">948                <span class="peach-pro-feature-mark">✦</span>949                <div class="peach-pro-feature-text">950                  <span class="peach-pro-feature-name">Always in sync</span>951                  <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>952                </div>953              </div>954              <div class="peach-pro-feature">955                <span class="peach-pro-feature-mark">✦</span>956                <div class="peach-pro-feature-text">957                  <span class="peach-pro-feature-name">No cloud. No relay.</span>958                  <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>959                </div>960              </div>961              <div class="peach-pro-feature">962                <span class="peach-pro-feature-mark">✦</span>963                <div class="peach-pro-feature-text">964                  <span class="peach-pro-feature-name">Tailscale supported</span>965                  <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>966                </div>967              </div>968              <div class="peach-pro-feature">969                <span class="peach-pro-feature-mark">✦</span>970                <div class="peach-pro-feature-text">971                  <span class="peach-pro-feature-name">Zero friction after setup</span>972                  <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>973                </div>974              </div>975            </div>976977            <button978              class="peach-btn peach-btn--accent peach-pro-upgrade-cta"979              onClick={() => setActiveSection("license")}980            >981              Upgrade to Pro982            </button>983            <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>984              Already have a key? Enter it →985            </button>986          </div>987        </div>988      );989    }990991    return (992      <div class="peach-settings-body">993        <div class="peach-settings-group">994          <div class="peach-settings-row">995            <span>Status</span>996            <span class="peach-settings-value peach-settings-value--status">997              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>998              {getStatusLabel(syncStatus())}999            </span>1000          </div>1001          <div class="peach-settings-row peach-settings-row--meta">1002            <span>Last sync</span>1003            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1004          </div>1005          <div class="peach-settings-row peach-settings-row--meta">1006            <button1007              class="peach-btn peach-btn--subtle peach-btn--sm"1008              onClick={() => void handleSyncNow()}1009              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1010            >1011              {syncingNow() ? "Syncing..." : "Sync now"}1012            </button>1013          </div>10141015          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1016            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1017            {pairedDevices().map((device) => (1018              <div class="peach-sync-device-row">1019                <div class="peach-sync-device-info">1020                  <span class="peach-sync-device-name">{device.displayName}</span>1021                  <span class="peach-sync-device-meta">1022                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1023                  </span>1024                </div>1025                <button1026                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1027                  onClick={() => void handleRemoveDevice(device.deviceId)}1028                >1029                  Remove1030                </button>1031              </div>1032            ))}1033            <div class="peach-settings-actions--row">1034              <button1035                class="peach-btn peach-btn--subtle peach-btn--sm"1036                onClick={() => props.onOpenSyncPairing?.("initiate")}1037              >1038                Pair a new device1039              </button>1040              <button1041                class="peach-btn peach-btn--subtle peach-btn--sm"1042                onClick={() => props.onOpenSyncPairing?.("accept")}1043              >1044                Pair with another device1045              </button>1046            </div>1047          </div>10481049          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1050            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1051            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1052              <div class="peach-sync-device-row">1053                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1054                <button1055                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1056                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1057                >1058                  Remove1059                </button>1060              </div>1061            ))}1062            <div class="peach-settings-actions--row">1063              <Show when={!isCurrentNetworkTrusted()}>1064                <button1065                  class="peach-btn peach-btn--subtle peach-btn--sm"1066                  onClick={() => void handleMarkNetworkTrusted()}1067                >1068                  Mark current network as trusted1069                </button>1070              </Show>1071            </div>1072            <span class="peach-sync-device-meta">1073              Tailscale is always trusted1074            </span>1075          </div>1076        </div>1077      </div>1078    );1079  }10801081  function renderAppearance() {1082    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1083      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1084      { id: "cormorant", label: "Cormorant", preview: "Aa" },1085      { id: "inter", label: "Inter", preview: "Aa" },1086      { id: "system", label: "System", preview: "Aa" },1087    ];1088    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1089      { id: "small", label: "Small" },1090      { id: "medium", label: "Default" },1091      { id: "large", label: "Large" },1092    ];1093    return (1094      <div class="peach-settings-body">1095        <div class="peach-settings-group">1096          <p class="peach-settings-sub-label">THEME</p>1097          <div class="peach-settings-row peach-settings-row--meta">1098            <span>Theme</span>1099            <button1100              class="peach-btn peach-btn--subtle peach-btn--sm"1101              onClick={() =>1102                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1103              }1104            >1105              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1106            </button>1107          </div>11081109          <p class="peach-settings-sub-label">ACCENT</p>1110          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1111            <span>Accent</span>1112            <div class="peach-accent-picker">1113              <For each={ACCENT_COLORS}>1114                {(color) => (1115                  <button1116                    class="peach-accent-swatch"1117                    classList={{ "is-active": props.accentColor === color.name }}1118                    style={{ background: color.hex }}1119                    onClick={() => void props.onAccentChange(color.name)}1120                    aria-label={color.name}1121                    title={color.name}1122                  />1123                )}1124              </For>1125            </div>1126          </div>11271128          <p class="peach-settings-sub-label">DISPLAY FONT</p>1129          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1130            <span>Font</span>1131            <div class="peach-font-picker">1132              <For each={fontOptions}>1133                {(opt) => (1134                  <button1135                    class="peach-font-swatch"1136                    classList={{ "is-active": props.displayFont === opt.id }}1137                    data-font-preview={opt.id}1138                    onClick={() => void props.onDisplayFontChange(opt.id)}1139                    aria-label={opt.label}1140                    title={opt.label}1141                  >1142                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1143                    <span class="peach-font-swatch-name">{opt.label}</span>1144                  </button>1145                )}1146              </For>1147            </div>1148          </div>11491150          <p class="peach-settings-sub-label">TEXT SIZE</p>1151          <div class="peach-settings-row peach-settings-row--meta">1152            <span>Size</span>1153            <div class="peach-pill-group">1154              <For each={sizeOptions}>1155                {(opt) => (1156                  <button1157                    class="peach-btn peach-btn--ghost peach-btn--sm"1158                    classList={{ "is-active": props.textSize === opt.id }}1159                    onClick={() => void props.onTextSizeChange(opt.id)}1160                  >1161                    {opt.label}1162                  </button>1163                )}1164              </For>1165            </div>1166          </div>11671168          <p class="peach-settings-sub-label">MOTION</p>1169          <div class="peach-settings-row peach-settings-row--meta">1170            <span>Reduce motion</span>1171            <button1172              class="peach-btn peach-btn--subtle peach-btn--sm"1173              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1174            >1175              {props.reduceMotion ? "On" : "Off"}1176            </button>1177          </div>1178        </div>1179      </div>1180    );1181  }11821183  function renderShortcuts() {1184    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1185    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);11861187    function startCapture(action: ShortcutAction) {1188      setConflictMsg(null);1189      setCapturingAction(action);1190    }1191    function cancelCapture() {1192      setCapturingAction(null);1193      setConflictMsg(null);1194    }1195    function commitBinding(action: ShortcutAction, binding: Keybinding) {1196      const conflict = findConflict(props.shortcuts, binding, action);1197      if (conflict) {1198        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1199        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1200        return;1201      }1202      const next = { ...props.shortcuts, [action]: binding };1203      setCapturingAction(null);1204      setConflictMsg(null);1205      void props.onShortcutsChange(next);1206    }1207    function resetAction(action: ShortcutAction) {1208      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1209      void props.onShortcutsChange(next);1210    }1211    function resetAll() {1212      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1213    }12141215    // Capture-mode key handler — wired via ref onto the chip element so it1216    // catches the next non-modifier keypress and ignores other bindings.1217    function handleCaptureKey(e: KeyboardEvent) {1218      e.preventDefault();1219      e.stopPropagation();1220      if (e.key === "Escape") { cancelCapture(); return; }1221      const binding = keybindingFromEvent(e);1222      if (!binding) return; // bare modifier — keep waiting1223      const action = capturingAction();1224      if (action) commitBinding(action, binding);1225    }12261227    return (1228      <div class="peach-settings-body">1229        <div class="peach-settings-group peach-shortcuts-group">1230          <p class="peach-settings-sub-label">ENTRY VIEW</p>1231          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1232            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1233          </For>1234        </div>12351236        <div class="peach-settings-group peach-shortcuts-group">1237          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1238          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1239            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1240          </For>1241        </div>12421243        <Show when={conflictMsg()}>1244          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1245        </Show>12461247        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1248          <div class="peach-settings-row peach-settings-row--meta">1249            <span>Inline autofill on page</span>1250            <a1251              class="peach-settings-link"1252              href="chrome://extensions/shortcuts"1253              onClick={(e) => {1254                e.preventDefault();1255                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1256              }}1257            >1258              ⌘⇧P — customize ↗1259            </a>1260          </div>1261          <button1262            class="peach-btn peach-btn--subtle peach-btn--sm"1263            style="align-self:flex-start"1264            onClick={resetAll}1265          >1266            Reset all to defaults1267          </button>1268        </div>1269      </div>1270    );12711272    function renderRow(1273      desc: typeof SHORTCUT_DESCRIPTORS[number],1274      capturing: () => ShortcutAction | null,1275      onStart: (a: ShortcutAction) => void,1276      onCancel: () => void,1277      onReset: (a: ShortcutAction) => void,1278      onKey: (e: KeyboardEvent) => void,1279    ) {1280      const isActive = () => capturing() === desc.action;1281      const binding = () => props.shortcuts[desc.action];1282      const isCustom = () => {1283        const b = binding();1284        const d = DEFAULT_BINDINGS[desc.action];1285        return b.key !== d.key1286          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1287          || Boolean(b.meta)  !== Boolean(d.meta)1288          || Boolean(b.shift) !== Boolean(d.shift)1289          || Boolean(b.alt)   !== Boolean(d.alt);1290      };1291      return (1292        <div class="peach-shortcut-row">1293          <span class="peach-shortcut-row-label">{desc.label}</span>1294          <button1295            class="peach-shortcut-chip"1296            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1297            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1298            onKeyDown={(e) => { if (isActive()) onKey(e); }}1299            ref={(el) => {1300              // Auto-focus when entering capture mode so the keydown listener catches the next press.1301              if (el) {1302                queueMicrotask(() => { if (isActive()) el.focus(); });1303              }1304            }}1305          >1306            {isActive() ? "Press a key…" : formatKeybinding(binding())}1307          </button>1308          <Show when={isCustom() && !isActive()}>1309            <button1310              class="peach-shortcut-reset"1311              title="Reset to default"1312              onClick={() => onReset(desc.action)}1313            >1314              ↺1315            </button>1316          </Show>1317        </div>1318      );1319    }1320  }13211322  const PRO_FEATURES: Array<{1323    feature: ProFeature;1324    name: string;1325    desc: string;1326  }> = [1327    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1328    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1329    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1330    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1331    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1332  ];13331334  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1335  const [deactivating, setDeactivating] = createSignal(false);13361337  async function handleDeactivateLicense() {1338    setDeactivating(true);1339    await sendMessage({ type: "DEACTIVATE_LICENSE" });1340    setLicenseInfo(null);1341    await props.onLicenseChange?.();1342    setDeactivating(false);1343  }13441345  function renderLicense() {1346    return (1347      <div class="peach-settings-body">1348        <Show1349          when={isPro()}1350          fallback={1351            <div class="peach-pro-upgrade">1352              <div class="peach-pro-upgrade-hero">1353                <PeachMark class="peach-pro-upgrade-mark" />1354                <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1355                <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1356              </div>13571358              <div class="peach-pro-features">1359                <For each={PRO_FEATURES}>1360                  {(item) => (1361                    <div1362                      class="peach-pro-feature"1363                      classList={{1364                        "is-highlighted": props.upgradeHighlight === item.feature,1365                      }}1366                    >1367                      <span class="peach-pro-feature-mark">✦</span>1368                      <div class="peach-pro-feature-text">1369                        <span class="peach-pro-feature-name">{item.name}</span>1370                        <span class="peach-pro-feature-desc">{item.desc}</span>1371                      </div>1372                    </div>1373                  )}1374                </For>1375              </div>13761377              <p class="peach-pro-pricing">$39 · once · yours forever</p>13781379              <a1380                href="https://peachpasswords.com/#pricing"1381                target="_blank"1382                rel="noopener noreferrer"1383                class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1384              >1385                Get Peach Pro1386              </a>13871388              <Show1389                when={showKeyEntry()}1390                fallback={1391                  <button1392                    class="peach-pro-key-link"1393                    onClick={() => setShowKeyEntry(true)}1394                  >1395                    Already have a key? Enter it →1396                  </button>1397                }1398              >1399                <div class="peach-pro-key-entry">1400                  <input1401                    class="peach-underline-input peach-pro-key-input"1402                    type="text"1403                    placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1404                    value={licenseKeyInput()}1405                    onInput={(e) =>1406                      setLicenseKeyInput(e.currentTarget.value)1407                    }1408                    onKeyDown={(e) => {1409                      if (e.key === "Enter") void onActivate();1410                    }}1411                    autocomplete="off"1412                    spellcheck={false}1413                  />1414                  <Show when={licenseError()}>1415                    <p class="peach-pro-key-error">That key doesn't look right.</p>1416                  </Show>1417                  <button1418                    class="peach-btn peach-btn--accent peach-btn--sm"1419                    onClick={() => void onActivate()}1420                    disabled={activatingLicense() || !licenseKeyInput().trim()}1421                  >1422                    {activatingLicense() ? "activating…" : "Activate"}1423                  </button>1424                </div>1425              </Show>1426            </div>1427          }1428        >1429          <div class="peach-pro-active">1430            <div class="peach-pro-active-head">1431              <span class="peach-pro-active-mark" aria-hidden="true">🍑</span>1432              <h3 class="peach-pro-active-title">Peach Pro</h3>1433            </div>1434            <p class="peach-pro-active-since">1435              {formatActivatedSince(licenseInfo()?.issued)}1436            </p>1437            <div class="peach-pro-active-divider"></div>1438            <p class="peach-pro-active-thanks">Thank you.</p>1439            <button1440              class="peach-pro-deactivate"1441              onClick={() => void handleDeactivateLicense()}1442              disabled={deactivating()}1443            >1444              {deactivating() ? "Deactivating…" : "Deactivate →"}1445            </button>1446          </div>1447        </Show>1448      </div>1449    );14501451    async function onActivate() {1452      await handleActivateLicense();1453      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1454        await props.onLicenseChange?.();1455      }1456    }1457  }14581459  function renderFeedback() {1460    return (1461      <div class="peach-settings-body">1462        <div class="peach-settings-group peach-feedback-group">1463          <p class="peach-feedback-intro">1464            Help us improve Peach. Every report goes directly to our inbox.1465          </p>14661467          <p class="peach-settings-sub-label">TYPE</p>1468          <div class="peach-feedback-chips">1469            <For each={FEEDBACK_CATEGORIES}>1470              {(cat) => (1471                <button1472                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1473                  onClick={() => setFbCategory(cat)}1474                >1475                  {cat}1476                </button>1477              )}1478            </For>1479          </div>14801481          <p class="peach-settings-sub-label">MESSAGE</p>1482          <textarea1483            class="peach-feedback-textarea"1484            placeholder="Describe the issue or idea..."1485            value={fbMessage()}1486            onInput={(e) => setFbMessage(e.currentTarget.value)}1487          />14881489          <p class="peach-feedback-meta">1490            Version {version} · will be included automatically1491          </p>14921493          <Show when={fbResult() === "success"}>1494            <p class="peach-feedback-result peach-feedback-result--ok">1495              Sent — thank you1496            </p>1497          </Show>1498          <Show when={fbResult() === "error"}>1499            <p class="peach-feedback-result peach-feedback-result--err">1500              Failed to send — try again1501            </p>1502          </Show>15031504          <button1505            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1506            onClick={() => void handleSubmitFeedback()}1507            disabled={fbSubmitting() || !fbMessage().trim()}1508          >1509            {fbSubmitting() ? "Sending..." : "Send feedback"}1510          </button>15111512          <p class="peach-feedback-anon">1513            Sent anonymously · no account required1514          </p>1515        </div>1516      </div>1517    );1518  }15191520  return (1521    <div class="peach-overlay-view ov-enter-right" style="display:flex">1522      <div class="peach-overlay-header">1523        <button1524          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1525          onClick={handleHeaderBack}1526          aria-label="Back"1527        >1528          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1529            <path d="M6 1L1 6L6 11" />1530          </svg>1531        </button>1532        <span class="peach-overlay-title">{currentTitle()}</span>1533        <div class="peach-overlay-spacer"></div>1534      </div>15351536      <Switch fallback={renderHome()}>1537        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1538        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1539        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1540        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1541        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1542        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1543        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1544      </Switch>1545    </div>1546  );1547}