Settings.tsx

69.6 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { Capacitor, registerPlugin } from "@capacitor/core";3import { sendMessage } from "../App";4import { PeachMark } from "../components/PeachMark";5import { LockRuneCanvas } from "../components/LockRuneCanvas";6import { biometricAvailable } from "../../shared/biometric";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";10import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";11import {12  DEFAULT_BINDINGS,13  findConflict,14  formatKeybinding,15  keybindingFromEvent,16  SHORTCUT_DESCRIPTORS,17  type Keybinding,18  type ShortcutAction,19} from "../../shared/shortcuts";2021interface LicenseInfo {22  valid: boolean;23  plan: "free" | "pro";24  issued?: string;25}2627interface SettingsProps {28  entriesCount: number;29  breachMonitoring: boolean;30  lockOnClose: boolean;31  autoLockMinutes: number;32  trashRetentionDays: number;33  theme: "dark" | "light";34  accentColor: AccentColorName;35  displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";36  textSize: "small" | "medium" | "large";37  reduceMotion: boolean;38  isPro?: boolean;39  initialSection?: string | null;40  upgradeHighlight?: ProFeature | null;41  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;42  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;43  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;44  onTrashRetentionChange: (days: number) => void | Promise<void>;45  onThemeChange: (theme: "dark" | "light") => void;46  onAccentChange: (name: AccentColorName) => void | Promise<void>;47  onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;48  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;49  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;50  shortcuts: Record<ShortcutAction, Keybinding>;51  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;52  onLicenseChange?: () => void | Promise<void>;53  onBack: () => void;54  onImport: () => void;55  onExport: () => void;56  onBreachCheckDone: () => void;57  onOpenHealth?: () => void;58  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;59  onOpenFileImport?: () => void;60  onOpenPanic?: () => void;61}6263type SectionName =64  | "security"65  | "vault"66  | "sync"67  | "appearance"68  | "shortcuts"69  | "license"70  | "feedback"71  | "mobile";7273const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7475function formatActivatedSince(issued: string | undefined): string {76  if (!issued) return "Active";77  try {78    const d = new Date(issued);79    if (Number.isNaN(d.getTime())) return "Active";80    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;81  } catch {82    return "Active";83  }84}8586function isSectionName(value: string): value is SectionName {87  return (88    value === "security" ||89    value === "vault" ||90    value === "sync" ||91    value === "appearance" ||92    value === "shortcuts" ||93    value === "license" ||94    value === "feedback" ||95    value === "mobile"96  );97}9899export function SettingsView(props: SettingsProps) {100  const version = chrome.runtime.getManifest().version;101  const initial = props.initialSection;102  const [activeSection, setActiveSection] = createSignal<SectionName | null>(103    initial && isSectionName(initial) ? (initial as SectionName) : null104  );105106  const [checkingBreaches, setCheckingBreaches] = createSignal(false);107  const [breachResult, setBreachResult] = createSignal<string | null>(null);108  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);109  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");110  const [licenseError, setLicenseError] = createSignal<string | null>(null);111  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);112  const [activatingLicense, setActivatingLicense] = createSignal(false);113114  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);115  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);116  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");117  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);118  // Prefer the prop (cached at App boot), fall back to the sync-state probe.119  // The prop is authoritative once present so license activation reflects120  // immediately without waiting for a sync round-trip.121  const [syncProStatus, setSyncProStatus] = createSignal(false);122  const isPro = () => props.isPro ?? syncProStatus();123  const [syncingNow, setSyncingNow] = createSignal(false);124  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);125  const [passkeySaving, setPasskeySaving] = createSignal(false);126127  const [dedupCount, setDedupCount] = createSignal<number | null>(null);128  const [dedupRunning, setDedupRunning] = createSignal(false);129  const [dedupDone, setDedupDone] = createSignal<number | null>(null);130131  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);132133  const [showChangePw, setShowChangePw] = createSignal(false);134  const [changePwCurrent, setChangePwCurrent] = createSignal("");135  const [changePwNew, setChangePwNew] = createSignal("");136  const [changePwConfirm, setChangePwConfirm] = createSignal("");137  const [changePwSaving, setChangePwSaving] = createSignal(false);138  const [changePwError, setChangePwError] = createSignal<string | null>(null);139  const [changePwSuccess, setChangePwSuccess] = createSignal(false);140141  const [duressEnabled, setDuressEnabled] = createSignal(false);142  const [showDuressSetup, setShowDuressSetup] = createSignal(false);143  const [duressPassword, setDuressPassword] = createSignal("");144  const [duressConfirm, setDuressConfirm] = createSignal("");145  const [duressMainPassword, setDuressMainPassword] = createSignal("");146  const [duressSaving, setDuressSaving] = createSignal(false);147  const [duressError, setDuressError] = createSignal<string | null>(null);148  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);149150  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);151152  // Mobile-only state153  function getAutofillPlugin() {154    try {155      return registerPlugin<{156        isEnabledAsAutofillService(): Promise<{ enabled: boolean }>;157        openAutofillSettings(): Promise<void>;158      }>("Autofill");159    } catch {160      return null;161    }162  }163  const AutofillPlugin = getAutofillPlugin();164  const [autofillEnabled, setAutofillEnabled] = createSignal(false);165  const [biometricEnabled, setBiometricEnabled] = createSignal(false);166  const isAndroid = Capacitor.getPlatform() === "android";167  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);168  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);169170  const [fbCategory, setFbCategory] = createSignal("Bug report");171  const [fbMessage, setFbMessage] = createSignal("");172  const [fbSubmitting, setFbSubmitting] = createSignal(false);173  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);174175  onMount(async () => {176    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([177      sendMessage({ type: "GET_LICENSE" }),178      sendMessage({ type: "GET_SYNC_STATE" }),179      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),180      sendMessage({ type: "IS_DURESS_ENABLED" }),181      sendMessage({ type: "GET_SETTINGS" }),182      sendMessage({ type: "GET_LICENSE_KEY" }),183    ]);184185    if ((licenseRes as { ok: boolean }).ok) {186      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);187    }188189    if ((syncRes as { ok: boolean }).ok) {190      const data = syncRes as { ok: boolean; data?: {191        pairedDevices?: PairedDevice[];192        trustedNetworks?: TrustedNetwork[];193        status?: SyncStatus;194        lastSyncAt?: number;195        isPro?: boolean;196      }};197      if (data.data) {198        setPairedDevices(data.data.pairedDevices ?? []);199        setTrustedNetworks(data.data.trustedNetworks ?? []);200        setSyncStatus(data.data.status ?? "offline");201        setLastSyncAt(data.data.lastSyncAt);202        setSyncProStatus(data.data.isPro ?? false);203      }204    }205206    if ((passkeyRes as { ok: boolean }).ok) {207      setPasskeyState(208        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null209      );210    }211212    if ((duressRes as { ok: boolean }).ok) {213      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);214    }215216    if ((settingsRes as { ok: boolean }).ok) {217      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;218      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);219    }220221    if ((keyRes as { ok: boolean }).ok) {222      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);223    }224225    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });226    if ((entriesRes as { ok: boolean }).ok) {227      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];228      setCanaryEntries(229        entries230          .filter((e) => e.canary?.webhookUrl)231          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))232      );233    }234235    // Android-only: check autofill and biometric status236    if (isAndroid) {237      if (AutofillPlugin) {238        const { enabled } = await AutofillPlugin.isEnabledAsAutofillService();239        setAutofillEnabled(enabled);240      }241      const bio = await biometricAvailable();242      setBiometricEnabled(bio);243    }244245    chrome.runtime.onMessage.addListener((msg) => {246      if (msg.type === "SYNC_STATE_UPDATE") {247        setPairedDevices(msg.pairedDevices ?? []);248        setTrustedNetworks(msg.trustedNetworks ?? []);249        setSyncStatus(msg.status ?? "offline");250        setLastSyncAt(msg.lastSyncAt);251        setSyncProStatus(msg.isPro ?? false);252      }253    });254  });255256  async function handleChangePassword() {257    setChangePwError(null);258    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }259    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }260    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }261    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }262    setChangePwSaving(true);263    const res = await sendMessage({264      type: "CHANGE_PASSWORD",265      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },266    }) as { ok: boolean; error?: string };267    if (res.ok) {268      setChangePwSuccess(true);269      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");270      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);271    } else {272      setChangePwError(res.error ?? "failed to change password");273    }274    setChangePwSaving(false);275  }276277  const [copied, setCopied] = createSignal(false);278279  async function copyLicenseKey(key: string) {280    await navigator.clipboard.writeText(key);281    setCopied(true);282    setTimeout(() => setCopied(false), 2000);283  }284285  async function handleActivateLicense() {286    const key = licenseKeyInput().trim();287    if (!key) return;288    setActivatingLicense(true);289    setLicenseError(null);290    setLicenseSuccess(null);291    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });292    setActivatingLicense(false);293    if ((res as { ok: boolean }).ok) {294      const data = (res as { ok: boolean; data?: LicenseInfo }).data;295      if (data?.valid) {296        setLicenseInfo(data);297        setLicenseKey(key);298        setLicenseSuccess("License activated!");299        setLicenseKeyInput("");300      } else {301        setLicenseError("Invalid license key");302      }303    } else {304      setLicenseError("Activation failed");305    }306  }307308  async function handleTogglePasskeys() {309    const current = passkeyState();310    if (!current || !current.supported) return;311    setPasskeySaving(true);312    const res = await sendMessage({313      type: "SET_PASSKEY_PROVIDER_ENABLED",314      payload: { enabled: !current.enabled },315    });316    setPasskeySaving(false);317    if ((res as { ok: boolean }).ok) {318      setPasskeyState(319        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null320      );321    }322  }323324  async function handleCheckAllBreaches() {325    setCheckingBreaches(true);326    setBreachResult(null);327    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });328    setCheckingBreaches(false);329    if ((res as { ok: boolean }).ok) {330      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};331      const breached = Object.values(data).filter((count) => count > 0).length;332      setBreachResult(333        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"334      );335      props.onBreachCheckDone();336    }337  }338339  async function handleSyncNow() {340    if (syncStatus() === "not_trusted") return;341    setSyncingNow(true);342    await sendMessage({ type: "TRIGGER_SYNC" });343    setTimeout(() => setSyncingNow(false), 3000);344  }345346  async function handleRemoveDevice(deviceId: string) {347    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });348  }349350  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {351    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });352  }353354  async function handleMarkNetworkTrusted() {355    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });356  }357358  function formatRelativeTime(timestamp?: number): string {359    if (!timestamp) return "Never";360    const diff = Date.now() - timestamp;361    const minutes = Math.floor(diff / 60000);362    const hours = Math.floor(diff / 3600000);363    const days = Math.floor(diff / 86400000);364    if (minutes < 1) return "Just now";365    if (minutes < 60) return `${minutes}m ago`;366    if (hours < 24) return `${hours}h ago`;367    return `${days}d ago`;368  }369370  function getStatusColor(status: SyncStatus): string {371    switch (status) {372      case "synced": return "var(--peach-sync-ok)";373      case "syncing": return "var(--peach-sync-busy)";374      case "no_peers": return "var(--peach-sync-off)";375      case "not_trusted": return "var(--peach-sync-err)";376      case "offline": return "var(--peach-sync-off)";377      default: return "var(--peach-sync-off)";378    }379  }380381  function getStatusLabel(status: SyncStatus): string {382    switch (status) {383      case "synced": return "Synced";384      case "syncing": return "Syncing";385      case "no_peers": return "No peers visible";386      case "not_trusted": return "Not on a trusted network";387      case "offline": return "Offline";388      default: return "Unknown";389    }390  }391392  function isCurrentNetworkTrusted(): boolean {393    return trustedNetworks().length > 0;394  }395396  async function handleSetupDuress() {397    const mainPw = duressMainPassword().trim();398    const duressPw = duressPassword().trim();399    const confirmPw = duressConfirm().trim();400401    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }402    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }403    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }404    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }405406    setDuressSaving(true);407    setDuressError(null);408409    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };410    if (!verifyRes.ok || !verifyRes.data) {411      setDuressError("incorrect main password");412      setDuressSaving(false);413      return;414    }415416    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };417    setDuressSaving(false);418    if (res.ok) {419      setDuressEnabled(true);420      setShowDuressSetup(false);421      setDuressPassword("");422      setDuressConfirm("");423      setDuressMainPassword("");424      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");425      setTimeout(() => setDuressSuccess(null), 5000);426    } else {427      setDuressError(res.error ?? "failed to set duress password");428    }429  }430431  async function handleClearDeclinedCards() {432    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });433    setDeclinedCardCount(0);434  }435436  async function handleCountDuplicates() {437    setDedupRunning(true);438    setDedupDone(null);439    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };440    setDedupRunning(false);441    if (res.ok) setDedupCount(res.data ?? 0);442  }443444  async function handleDeduplicate() {445    setDedupRunning(true);446    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };447    setDedupRunning(false);448    if (res.ok) {449      setDedupDone(res.data ?? 0);450      setDedupCount(0);451    }452  }453454  async function handleDisableDuress() {455    const mainPw = duressMainPassword().trim();456    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }457458    setDuressSaving(true);459    setDuressError(null);460461    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };462    if (!verifyRes.ok || !verifyRes.data) {463      setDuressError("incorrect main password");464      setDuressSaving(false);465      return;466    }467468    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };469    setDuressSaving(false);470    if (res.ok) {471      setDuressEnabled(false);472      setShowDuressSetup(false);473      setDuressMainPassword("");474      setDuressSuccess("Duress password disabled.");475      setTimeout(() => setDuressSuccess(null), 4000);476    } else {477      setDuressError(res.error ?? "failed to disable duress password");478    }479  }480481  async function handleSubmitFeedback() {482    if (!fbMessage().trim() || fbSubmitting()) return;483    setFbSubmitting(true);484    setFbResult(null);485    const res = await sendMessage({486      type: "SUBMIT_FEEDBACK",487      payload: {488        category: fbCategory(),489        message: fbMessage().trim(),490        version,491        browser: navigator.userAgent,492      },493    }) as { ok: boolean; error?: string };494    setFbSubmitting(false);495    if (res.ok) {496      setFbResult("success");497      setFbMessage("");498      setTimeout(() => setFbResult(null), 4000);499    } else {500      setFbResult("error");501    }502  }503504  function handleHeaderBack() {505    if (activeSection() !== null) {506      setActiveSection(null);507    } else {508      props.onBack();509    }510  }511512  function currentTitle(): string {513    const s = activeSection();514    if (s === null) return "settings";515    if (s === "license") return "account";516    return s;517  }518519  function renderCatRow(opts: {520    section: SectionName;521    name: string;522    desc: string;523    icon: () => any;524    pro?: boolean;525  }) {526    return (527      <button528        class="peach-settings-cat-row"529        onClick={() => setActiveSection(opts.section)}530      >531        <div class="peach-settings-cat-icon">{opts.icon()}</div>532        <div class="peach-settings-cat-content">533          <span class="peach-settings-cat-name">534            {opts.name}535            <Show when={opts.pro && !isPro()}>536              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>537            </Show>538          </span>539          <span class="peach-settings-cat-desc">{opts.desc}</span>540        </div>541        <svg542          class="peach-settings-cat-chevron"543          width="6" height="10" viewBox="0 0 6 10" fill="none"544          stroke="currentColor" stroke-width="1.1"545          stroke-linecap="round" stroke-linejoin="round"546        >547          <path d="M1 1l4 4-4 4" />548        </svg>549      </button>550    );551  }552553  const iconShield = () => (554    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">555      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>556    </svg>557  );558  const iconKey = () => (559    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">560      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>561    </svg>562  );563  const iconLock = () => (564    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">565      <rect x="3" y="7" width="10" height="7" rx="1.5"/>566      <path d="M6 7V5a3 3 0 016 0v2"/>567    </svg>568  );569  const iconSync = () => (570    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">571      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>572      <path d="M14 6l-2 2-2-2"/>573      <path d="M2 10l2-2 2 2"/>574    </svg>575  );576  const iconSun = () => (577    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">578      <circle cx="8" cy="8" r="3"/>579      <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"/>580    </svg>581  );582  const iconKeys = () => (583    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">584      <rect x="2" y="5" width="5" height="4" rx="1"/>585      <rect x="9" y="7" width="5" height="4" rx="1"/>586      <path d="M7 7h2m-2 2h2"/>587    </svg>588  );589  const iconBubble = () => (590    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">591      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>592    </svg>593  );594595  function renderHome() {596    return (597      <div class="peach-settings-body">598        <p class="peach-settings-nav-label">PROTECTION</p>599        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}600        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}601602        <p class="peach-settings-nav-label">VAULT</p>603        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}604        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}605606        <p class="peach-settings-nav-label">PREFERENCES</p>607        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}608        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}609        <Show when={isAndroid}>610          <p class="peach-settings-nav-label">MOBILE</p>611          {renderCatRow({ section: "mobile", name: "Mobile", desc: "Autofill & biometric settings", icon: iconSync })}612        </Show>613614        <p class="peach-settings-nav-label">SUPPORT</p>615        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}616617        <div class="peach-settings-footer">618          <span>Peach {version}</span>619          <span class="peach-divider"></span>620          <span>Browser extension</span>621        </div>622      </div>623    );624  }625626  function renderSecurity() {627    return (628      <div class="peach-settings-body">629        <div class="peach-settings-group">630          <p class="peach-settings-sub-label">LOCKING</p>631          <div class="peach-settings-row">632            <span>Lock on close</span>633            <button634              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}635              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}636              aria-label="Toggle lock on close"637            >638              <span class="peach-toggle-knob"></span>639            </button>640          </div>641          <div class="peach-settings-row">642            <span>Auto-lock after</span>643            <select644              class="peach-settings-select"645              value={String(props.autoLockMinutes)}646              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}647            >648              <option value="1">1 minute</option>649              <option value="5">5 minutes</option>650              <option value="15">15 minutes</option>651              <option value="30">30 minutes</option>652              <option value="0">Never</option>653            </select>654          </div>655656          <p class="peach-settings-sub-label">BREACH PROTECTION</p>657          <div class="peach-settings-row">658            <span>Breach monitoring</span>659            <button660              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}661              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}662              aria-label="Toggle breach monitoring"663            >664              <span class="peach-toggle-knob"></span>665            </button>666          </div>667          <div class="peach-settings-row peach-settings-row--meta">668            <span>Check all passwords</span>669            <button670              class="peach-btn peach-btn--subtle peach-btn--sm"671              onClick={() => void handleCheckAllBreaches()}672              disabled={checkingBreaches()}673            >674              {checkingBreaches() ? "checking..." : "run"}675            </button>676          </div>677          <Show when={breachResult()}>678            <div class="peach-settings-row peach-settings-row--meta">679              <span>Last check</span>680              <span class="peach-settings-value">{breachResult()}</span>681            </div>682          </Show>683684          <p class="peach-settings-sub-label">MASTER PASSWORD</p>685          <div class="peach-settings-row peach-settings-row--meta">686            <span>Master password</span>687            <button688              class="peach-btn peach-btn--subtle peach-btn--sm"689              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}690            >691              {showChangePw() ? "cancel" : "change"}692            </button>693          </div>694          <Show when={showChangePw()}>695            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">696              <input697                class="peach-settings-input"698                type="password"699                placeholder="current password"700                value={changePwCurrent()}701                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}702                autocomplete="current-password"703              />704              <input705                class="peach-settings-input"706                type="password"707                placeholder="new password (min 12 chars)"708                value={changePwNew()}709                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}710                autocomplete="new-password"711              />712              <input713                class="peach-settings-input"714                type="password"715                placeholder="confirm new password"716                value={changePwConfirm()}717                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}718                autocomplete="new-password"719                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}720              />721              <Show when={changePwError()}>722                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>723              </Show>724              <Show when={changePwSuccess()}>725                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>726              </Show>727              <button728                class="peach-btn peach-btn--accent peach-btn--sm"729                style="align-self:flex-start"730                onClick={() => void handleChangePassword()}731                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}732              >733                {changePwSaving() ? "saving…" : "save new password"}734              </button>735            </div>736          </Show>737738          <p class="peach-settings-sub-label">DURESS PASSWORD</p>739          <div class="peach-settings-row peach-settings-row--top">740            <div class="peach-settings-row-main">741              <span>Duress password</span>742              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">743                If entered under coercion, shows a decoy empty vault instead of your real one.744              </span>745            </div>746            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>747              {duressEnabled() ? "on" : "off"}748            </div>749          </div>750751          <Show when={duressEnabled() && !showDuressSetup()}>752            <div class="peach-settings-row peach-settings-row--meta">753              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">754                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.755              </span>756            </div>757          </Show>758759          <Show when={duressSuccess()}>760            <div class="peach-settings-row peach-settings-row--meta">761              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>762            </div>763          </Show>764765          <Show when={!showDuressSetup()}>766            <div class="peach-settings-row peach-settings-row--meta">767              <button768                class="peach-btn peach-btn--subtle peach-btn--sm"769                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}770              >771                {duressEnabled() ? "Change / disable" : "Set up"}772              </button>773            </div>774          </Show>775776          <Show when={showDuressSetup()}>777            <div class="peach-duress-form">778              <label class="peach-label">main password (to confirm)</label>779              <input780                class="peach-settings-input"781                type="password"782                placeholder="your current master password"783                autocomplete="current-password"784                value={duressMainPassword()}785                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}786              />787              <Show when={!duressEnabled()}>788                <label class="peach-label">new duress password</label>789                <input790                  class="peach-settings-input"791                  type="password"792                  placeholder="duress password (min 8 chars)"793                  autocomplete="new-password"794                  value={duressPassword()}795                  onInput={(e) => setDuressPassword(e.currentTarget.value)}796                />797                <label class="peach-label">confirm duress password</label>798                <input799                  class="peach-settings-input"800                  type="password"801                  placeholder="confirm duress password"802                  autocomplete="new-password"803                  value={duressConfirm()}804                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}805                />806              </Show>807808              <Show when={duressError()}>809                <p class="peach-hint peach-hint-error">{duressError()}</p>810              </Show>811812              <div class="peach-duress-actions">813                <button814                  class="peach-btn peach-btn--subtle peach-btn--sm"815                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}816                >817                  cancel818                </button>819                <Show when={duressEnabled()}>820                  <button821                    class="peach-btn peach-btn--ghost peach-btn--sm"822                    style="color:var(--peach-breach)"823                    onClick={() => void handleDisableDuress()}824                    disabled={duressSaving()}825                  >826                    {duressSaving() ? "disabling..." : "disable duress"}827                  </button>828                </Show>829                <Show when={!duressEnabled()}>830                  <button831                    class="peach-btn peach-btn--accent peach-btn--sm"832                    onClick={() => void handleSetupDuress()}833                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}834                  >835                    {duressSaving() ? "saving..." : "save"}836                  </button>837                </Show>838              </div>839            </div>840          </Show>841842          <p class="peach-settings-sub-label">CANARY ENTRIES</p>843          <div class="peach-settings-row peach-settings-row--top">844            <div class="peach-settings-row-main">845              <span>Canary entries</span>846              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">847                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.848              </span>849            </div>850            <span class="peach-settings-value" style="flex-shrink:0">851              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}852            </span>853          </div>854          <Show when={canaryEntries().length > 0}>855            <For each={canaryEntries()}>856              {(entry) => (857                <div class="peach-settings-row peach-settings-row--meta">858                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>859                  <button860                    class="peach-btn peach-btn--subtle peach-btn--sm"861                    onClick={() => void handleTestCanary(entry)}862                    disabled={canaryTestingId() === entry.id}863                    style="flex-shrink:0"864                  >865                    {canaryTestingId() === entry.id866                      ? "sending…"867                      : canaryTestResult()?.id === entry.id868                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")869                        : "test"}870                  </button>871                </div>872              )}873            </For>874          </Show>875876          <p class="peach-settings-sub-label">PASSKEYS</p>877          <div class="peach-settings-row peach-settings-row--top">878            <div class="peach-settings-row-main">879              <span>Intercept passkey requests</span>880              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">881                Peach can handle passkey sign-in and creation on supported Chromium browsers.882              </span>883            </div>884            <button885              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}886              onClick={() => void handleTogglePasskeys()}887              aria-label="Toggle passkey interception"888              disabled={!passkeyState()?.supported || passkeySaving()}889            >890              <span class="peach-toggle-knob"></span>891            </button>892          </div>893          <div class="peach-settings-row peach-settings-row--meta">894            <span>Status</span>895            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>896          </div>897          <div class="peach-settings-row peach-settings-row--meta">898            <span>Browser</span>899            <span class="peach-settings-value">900              {passkeyState()?.browserLabel ?? "Unknown"}901              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}902            </span>903          </div>904          <div class="peach-info-notice" style="margin-top:8px">905            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.906          </div>907          <Show when={passkeyState()?.conflict}>908            <div class="peach-settings-row peach-settings-row--meta">909              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>910            </div>911          </Show>912        </div>913      </div>914    );915  }916917  function renderVault() {918    return (919      <div class="peach-settings-body">920        <div class="peach-settings-group">921          <p class="peach-settings-sub-label">ENTRIES</p>922          <div class="peach-settings-row peach-settings-row--meta">923            <span>Entries</span>924            <span class="peach-settings-value">{props.entriesCount}</span>925          </div>926          <div class="peach-settings-row peach-settings-row--meta">927            <span>Duplicates</span>928            <Show when={dedupDone() !== null} fallback={929              <button930                class="peach-btn peach-btn--subtle peach-btn--sm"931                onClick={() => void handleCountDuplicates()}932                disabled={dedupRunning()}933              >934                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}935              </button>936            }>937              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">938                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}939              </span>940            </Show>941          </div>942          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>943            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">944              <div class="peach-restore-warning">945                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.946              </div>947              <div style="display:flex;gap:6px">948                <button949                  class="peach-btn peach-btn--subtle peach-btn--sm"950                  onClick={() => setDedupCount(null)}951                >952                  cancel953                </button>954                <button955                  class="peach-btn peach-btn--accent peach-btn--sm"956                  onClick={() => void handleDeduplicate()}957                  disabled={dedupRunning()}958                >959                  {dedupRunning() ? "removing..." : "remove duplicates"}960                </button>961              </div>962            </div>963          </Show>964965          <p class="peach-settings-sub-label">MAINTENANCE</p>966          <div class="peach-settings-row peach-settings-row--meta">967            <span>Trash auto-delete</span>968            <select969              class="peach-settings-select"970              value={String(props.trashRetentionDays)}971              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}972            >973              <option value="7">7 days</option>974              <option value="30">30 days</option>975              <option value="90">90 days</option>976              <option value="0">Never</option>977            </select>978          </div>979          <div class="peach-settings-row peach-settings-row--meta">980            <span>Vault Health</span>981            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>982              open983            </button>984          </div>985          <Show when={declinedCardCount() > 0}>986            <div class="peach-settings-row peach-settings-row--meta">987              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>988              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>989                Clear990              </button>991            </div>992          </Show>993994          <p class="peach-settings-sub-label">DATA</p>995          <div class="peach-settings-row peach-settings-row--meta">996            <span>Import passwords</span>997            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>998              open999            </button>1000          </div>1001          <div class="peach-settings-row peach-settings-row--meta">1002            <span>Export backup</span>1003            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1004              open1005            </button>1006          </div>1007          <div class="peach-danger-card">1008            <div class="peach-danger-card-head">1009              <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1010              <h3 class="peach-danger-card-title">Replace vault from backup</h3>1011            </div>1012            <p class="peach-danger-card-copy">1013              Wipes the current vault and restores from a backup file.1014              Recoverable only if your backup is good.1015            </p>1016            <button1017              class="peach-danger-card-button"1018              onClick={() => props.onOpenFileImport?.()}1019            >1020              Choose backup file1021            </button>1022          </div>10231024          <Show when={props.onOpenPanic}>1025            <div class="peach-danger-card peach-danger-card--hazard">1026              <div class="peach-danger-card-head">1027                <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1028                <h3 class="peach-danger-card-title">Destroy vault</h3>1029              </div>1030              <p class="peach-danger-card-copy">1031                Permanently wipes every entry, setting, and license on this1032                device. There is no recovery.1033              </p>1034              <button1035                class="peach-danger-card-button"1036                onClick={() => props.onOpenPanic?.()}1037              >1038                Begin destruction1039              </button>1040            </div>1041          </Show>1042        </div>1043      </div>1044    );1045  }10461047  function renderSync() {1048    if (!isPro()) {1049      return (1050        <div class="peach-settings-body">1051          <div class="peach-pro-upgrade">1052            <div class="peach-pro-upgrade-hero">1053              <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">1054                <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1055                <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1056                <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"/>1057                <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"/>1058                <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>1059                <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1060                <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1061              </svg>1062              <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1063              <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1064            </div>10651066            <div class="peach-pro-features">1067              <div class="peach-pro-feature">1068                <span class="peach-pro-feature-mark">✦</span>1069                <div class="peach-pro-feature-text">1070                  <span class="peach-pro-feature-name">Always in sync</span>1071                  <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1072                </div>1073              </div>1074              <div class="peach-pro-feature">1075                <span class="peach-pro-feature-mark">✦</span>1076                <div class="peach-pro-feature-text">1077                  <span class="peach-pro-feature-name">No cloud. No relay.</span>1078                  <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1079                </div>1080              </div>1081              <div class="peach-pro-feature">1082                <span class="peach-pro-feature-mark">✦</span>1083                <div class="peach-pro-feature-text">1084                  <span class="peach-pro-feature-name">Tailscale supported</span>1085                  <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1086                </div>1087              </div>1088              <div class="peach-pro-feature">1089                <span class="peach-pro-feature-mark">✦</span>1090                <div class="peach-pro-feature-text">1091                  <span class="peach-pro-feature-name">Zero friction after setup</span>1092                  <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1093                </div>1094              </div>1095            </div>10961097            <button1098              class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1099              onClick={() => setActiveSection("license")}1100            >1101              Upgrade to Pro1102            </button>1103            <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1104              Already have a key? Enter it →1105            </button>1106          </div>1107        </div>1108      );1109    }11101111    return (1112      <div class="peach-settings-body">1113        <div class="peach-settings-group">1114          <div class="peach-settings-row">1115            <span>Status</span>1116            <span class="peach-settings-value peach-settings-value--status">1117              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1118              {getStatusLabel(syncStatus())}1119            </span>1120          </div>1121          <div class="peach-settings-row peach-settings-row--meta">1122            <span>Last sync</span>1123            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1124          </div>1125          <div class="peach-settings-row peach-settings-row--meta">1126            <button1127              class="peach-btn peach-btn--subtle peach-btn--sm"1128              onClick={() => void handleSyncNow()}1129              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1130            >1131              {syncingNow() ? "Syncing..." : "Sync now"}1132            </button>1133          </div>11341135          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1136            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1137            {pairedDevices().map((device) => (1138              <div class="peach-sync-device-row">1139                <div class="peach-sync-device-info">1140                  <span class="peach-sync-device-name">{device.displayName}</span>1141                  <span class="peach-sync-device-meta">1142                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1143                  </span>1144                </div>1145                <button1146                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1147                  onClick={() => void handleRemoveDevice(device.deviceId)}1148                >1149                  Remove1150                </button>1151              </div>1152            ))}1153            <div class="peach-settings-actions--row">1154              <button1155                class="peach-btn peach-btn--subtle peach-btn--sm"1156                onClick={() => props.onOpenSyncPairing?.("initiate")}1157              >1158                Pair a new device1159              </button>1160              <button1161                class="peach-btn peach-btn--subtle peach-btn--sm"1162                onClick={() => props.onOpenSyncPairing?.("accept")}1163              >1164                Pair with another device1165              </button>1166            </div>1167          </div>11681169          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1170            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1171            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1172              <div class="peach-sync-device-row">1173                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1174                <button1175                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1176                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1177                >1178                  Remove1179                </button>1180              </div>1181            ))}1182            <div class="peach-settings-actions--row">1183              <Show when={!isCurrentNetworkTrusted()}>1184                <button1185                  class="peach-btn peach-btn--subtle peach-btn--sm"1186                  onClick={() => void handleMarkNetworkTrusted()}1187                >1188                  Mark current network as trusted1189                </button>1190              </Show>1191            </div>1192            <span class="peach-sync-device-meta">1193              Tailscale is always trusted1194            </span>1195          </div>1196        </div>1197      </div>1198    );1199  }12001201  function renderAppearance() {1202    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1203      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1204      { id: "cormorant", label: "Cormorant", preview: "Aa" },1205      { id: "inter", label: "Inter", preview: "Aa" },1206      { id: "system", label: "System", preview: "Aa" },1207    ];1208    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1209      { id: "small", label: "Small" },1210      { id: "medium", label: "Default" },1211      { id: "large", label: "Large" },1212    ];1213    return (1214      <div class="peach-settings-body">1215        <div class="peach-settings-group">1216          <p class="peach-settings-sub-label">THEME</p>1217          <div class="peach-settings-row peach-settings-row--meta">1218            <span>Theme</span>1219            <button1220              class="peach-btn peach-btn--subtle peach-btn--sm"1221              onClick={() =>1222                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1223              }1224            >1225              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1226            </button>1227          </div>12281229          <p class="peach-settings-sub-label">ACCENT</p>1230          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1231            <span>Accent</span>1232            <div class="peach-accent-picker">1233              <For each={ACCENT_COLORS}>1234                {(color) => (1235                  <button1236                    class="peach-accent-swatch"1237                    classList={{ "is-active": props.accentColor === color.name }}1238                    style={{ background: color.hex }}1239                    onClick={() => void props.onAccentChange(color.name)}1240                    aria-label={color.name}1241                    title={color.name}1242                  />1243                )}1244              </For>1245            </div>1246          </div>12471248          <p class="peach-settings-sub-label">DISPLAY FONT</p>1249          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1250            <span>Font</span>1251            <div class="peach-font-picker">1252              <For each={fontOptions}>1253                {(opt) => (1254                  <button1255                    class="peach-font-swatch"1256                    classList={{ "is-active": props.displayFont === opt.id }}1257                    data-font-preview={opt.id}1258                    onClick={() => void props.onDisplayFontChange(opt.id)}1259                    aria-label={opt.label}1260                    title={opt.label}1261                  >1262                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1263                    <span class="peach-font-swatch-name">{opt.label}</span>1264                  </button>1265                )}1266              </For>1267            </div>1268          </div>12691270          <p class="peach-settings-sub-label">TEXT SIZE</p>1271          <div class="peach-settings-row peach-settings-row--meta">1272            <span>Size</span>1273            <div class="peach-pill-group">1274              <For each={sizeOptions}>1275                {(opt) => (1276                  <button1277                    class="peach-btn peach-btn--ghost peach-btn--sm"1278                    classList={{ "is-active": props.textSize === opt.id }}1279                    onClick={() => void props.onTextSizeChange(opt.id)}1280                  >1281                    {opt.label}1282                  </button>1283                )}1284              </For>1285            </div>1286          </div>12871288          <p class="peach-settings-sub-label">MOTION</p>1289          <div class="peach-settings-row peach-settings-row--meta">1290            <span>Reduce motion</span>1291            <button1292              class="peach-btn peach-btn--subtle peach-btn--sm"1293              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1294            >1295              {props.reduceMotion ? "On" : "Off"}1296            </button>1297          </div>1298        </div>1299      </div>1300    );1301  }13021303  function renderShortcuts() {1304    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1305    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13061307    function startCapture(action: ShortcutAction) {1308      setConflictMsg(null);1309      setCapturingAction(action);1310    }1311    function cancelCapture() {1312      setCapturingAction(null);1313      setConflictMsg(null);1314    }1315    function commitBinding(action: ShortcutAction, binding: Keybinding) {1316      const conflict = findConflict(props.shortcuts, binding, action);1317      if (conflict) {1318        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1319        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1320        return;1321      }1322      const next = { ...props.shortcuts, [action]: binding };1323      setCapturingAction(null);1324      setConflictMsg(null);1325      void props.onShortcutsChange(next);1326    }1327    function resetAction(action: ShortcutAction) {1328      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1329      void props.onShortcutsChange(next);1330    }1331    function resetAll() {1332      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1333    }13341335    // Capture-mode key handler — wired via ref onto the chip element so it1336    // catches the next non-modifier keypress and ignores other bindings.1337    function handleCaptureKey(e: KeyboardEvent) {1338      e.preventDefault();1339      e.stopPropagation();1340      if (e.key === "Escape") { cancelCapture(); return; }1341      const binding = keybindingFromEvent(e);1342      if (!binding) return; // bare modifier — keep waiting1343      const action = capturingAction();1344      if (action) commitBinding(action, binding);1345    }13461347    return (1348      <div class="peach-settings-body">1349        <div class="peach-settings-group peach-shortcuts-group">1350          <p class="peach-settings-sub-label">ENTRY VIEW</p>1351          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1352            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1353          </For>1354        </div>13551356        <div class="peach-settings-group peach-shortcuts-group">1357          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1358          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1359            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1360          </For>1361        </div>13621363        <Show when={conflictMsg()}>1364          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1365        </Show>13661367        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1368          <div class="peach-settings-row peach-settings-row--meta">1369            <span>Inline autofill on page</span>1370            <a1371              class="peach-settings-link"1372              href="chrome://extensions/shortcuts"1373              onClick={(e) => {1374                e.preventDefault();1375                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1376              }}1377            >1378              ⌘⇧P — customize ↗1379            </a>1380          </div>1381          <button1382            class="peach-btn peach-btn--subtle peach-btn--sm"1383            style="align-self:flex-start"1384            onClick={resetAll}1385          >1386            Reset all to defaults1387          </button>1388        </div>1389      </div>1390    );13911392    function renderRow(1393      desc: typeof SHORTCUT_DESCRIPTORS[number],1394      capturing: () => ShortcutAction | null,1395      onStart: (a: ShortcutAction) => void,1396      onCancel: () => void,1397      onReset: (a: ShortcutAction) => void,1398      onKey: (e: KeyboardEvent) => void,1399    ) {1400      const isActive = () => capturing() === desc.action;1401      const binding = () => props.shortcuts[desc.action];1402      const isCustom = () => {1403        const b = binding();1404        const d = DEFAULT_BINDINGS[desc.action];1405        return b.key !== d.key1406          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1407          || Boolean(b.meta)  !== Boolean(d.meta)1408          || Boolean(b.shift) !== Boolean(d.shift)1409          || Boolean(b.alt)   !== Boolean(d.alt);1410      };1411      return (1412        <div class="peach-shortcut-row">1413          <span class="peach-shortcut-row-label">{desc.label}</span>1414          <button1415            class="peach-shortcut-chip"1416            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1417            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1418            onKeyDown={(e) => { if (isActive()) onKey(e); }}1419            ref={(el) => {1420              // Auto-focus when entering capture mode so the keydown listener catches the next press.1421              if (el) {1422                queueMicrotask(() => { if (isActive()) el.focus(); });1423              }1424            }}1425          >1426            {isActive() ? "Press a key…" : formatKeybinding(binding())}1427          </button>1428          <Show when={isCustom() && !isActive()}>1429            <button1430              class="peach-shortcut-reset"1431              title="Reset to default"1432              onClick={() => onReset(desc.action)}1433            >1434              ↺1435            </button>1436          </Show>1437        </div>1438      );1439    }1440  }14411442  const PRO_FEATURES: Array<{1443    feature: ProFeature;1444    name: string;1445    desc: string;1446  }> = [1447    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1448    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1449    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1450    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1451    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1452  ];14531454  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14551456  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1457  const [deactivating, setDeactivating] = createSignal(false);14581459  async function handleDeactivateLicense() {1460    setDeactivating(true);1461    await sendMessage({ type: "DEACTIVATE_LICENSE" });1462    setLicenseInfo(null);1463    await props.onLicenseChange?.();1464    setDeactivating(false);1465  }14661467  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1468    setCanaryTestingId(entry.id);1469    setCanaryTestResult(null);1470    const res = await sendMessage({1471      type: "FIRE_CANARY_PING",1472      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1473    }) as { ok: boolean };1474    setCanaryTestResult({ id: entry.id, ok: res.ok });1475    setCanaryTestingId(null);1476    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1477  }14781479  function renderLicense() {1480    async function onActivate() {1481      await handleActivateLicense();1482      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1483        await props.onLicenseChange?.();1484      }1485    }14861487    return (1488      <div class="peach-settings-body">1489        <Show1490          when={isPro()}1491          fallback={1492            <Show1493              when={showKeyEntry()}1494              fallback={1495                <div class="peach-pro-upgrade">1496                  <div class="peach-pro-upgrade-hero">1497                    <div class="peach-pro-upgrade-mark-wrap">1498                      <PeachMark class="peach-pro-upgrade-mark" />1499                    </div>1500                    <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1501                    <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1502                  </div>15031504                  <div class="peach-pro-features">1505                    <For each={PRO_FEATURES}>1506                      {(item) => (1507                        <div1508                          class="peach-pro-feature"1509                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1510                        >1511                          <span class="peach-pro-feature-mark">✦</span>1512                          <div class="peach-pro-feature-text">1513                            <span class="peach-pro-feature-name">{item.name}</span>1514                            <span class="peach-pro-feature-desc">{item.desc}</span>1515                          </div>1516                        </div>1517                      )}1518                    </For>1519                  </div>15201521                  <div class="peach-pro-upgrade-actions">1522                    <p class="peach-pro-pricing">$39 · once · yours forever</p>1523                    <a1524                      href="https://peachpasswords.com/#pricing"1525                      target="_blank"1526                      rel="noopener noreferrer"1527                      class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1528                    >1529                      Get Peach Pro1530                    </a>1531                    <div class="peach-pro-key-divider"><span>or</span></div>1532                    <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1533                      Activate a license key1534                    </button>1535                  </div>1536                </div>1537              }1538            >1539              <div class="peach-pro-key-ceremony">1540                <LockRuneCanvas />1541                <p class="peach-pro-key-ceremony-label">Enter your license key</p>1542                <input1543                  class="peach-underline-input peach-pro-key-input"1544                  type="text"1545                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1546                  value={licenseKeyInput()}1547                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1548                  onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1549                  autocomplete="off"1550                  spellcheck={false}1551                />1552                <Show when={licenseError()}>1553                  <p class="peach-pro-key-error">That key doesn't look right.</p>1554                </Show>1555                <button1556                  class="peach-btn peach-btn--accent peach-pro-key-activate"1557                  onClick={() => void onActivate()}1558                  disabled={activatingLicense() || !licenseKeyInput().trim()}1559                >1560                  {activatingLicense() ? "activating…" : "Activate"}1561                </button>1562                <button1563                  class="peach-pro-key-cancel"1564                  onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1565                >1566                  cancel1567                </button>1568              </div>1569            </Show>1570          }1571        >1572          <div class="peach-pro-active">1573            <div class="peach-pro-active-hero">1574              <div class="peach-pro-active-mark-wrap">1575                <PeachMark class="peach-pro-active-mark" />1576              </div>1577              <h2 class="peach-pro-active-title">Peach Pro</h2>1578              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1579            </div>15801581            <Show when={licenseKey()}>1582              <div class="peach-settings-license">1583                <span class="peach-settings-label">LICENSE KEY</span>1584                <div class="peach-settings-license-row">1585                  <span class="peach-settings-license-key">{licenseKey()!}</span>1586                  <button1587                    class="peach-settings-license-copy"1588                    onClick={() => copyLicenseKey(licenseKey()!)}1589                  >1590                    {copied() ? "✓" : "copy"}1591                  </button>1592                </div>1593              </div>1594            </Show>15951596            <div class="peach-pro-active-features">1597              <For each={PRO_FEATURES}>1598                {(item) => (1599                  <div class="peach-pro-active-feature">1600                    <span class="peach-pro-active-feature-mark">✦</span>1601                    <span class="peach-pro-active-feature-name">{item.name}</span>1602                  </div>1603                )}1604              </For>1605            </div>16061607            <button1608              class="peach-pro-deactivate"1609              onClick={() => void handleDeactivateLicense()}1610              disabled={deactivating()}1611            >1612              {deactivating() ? "Deactivating…" : "Deactivate"}1613            </button>1614          </div>1615        </Show>1616      </div>1617    );1618  }16191620  function renderFeedback() {1621    return (1622      <div class="peach-settings-body">1623        <div class="peach-settings-group peach-feedback-group">1624          <p class="peach-feedback-intro">1625            Help us improve Peach. Every report goes directly to our inbox.1626          </p>16271628          <p class="peach-settings-sub-label">TYPE</p>1629          <div class="peach-feedback-chips">1630            <For each={FEEDBACK_CATEGORIES}>1631              {(cat) => (1632                <button1633                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1634                  onClick={() => setFbCategory(cat)}1635                >1636                  {cat}1637                </button>1638              )}1639            </For>1640          </div>16411642          <p class="peach-settings-sub-label">MESSAGE</p>1643          <textarea1644            class="peach-feedback-textarea"1645            placeholder="Describe the issue or idea..."1646            value={fbMessage()}1647            onInput={(e) => setFbMessage(e.currentTarget.value)}1648          />16491650          <p class="peach-feedback-meta">1651            Version {version} · will be included automatically1652          </p>16531654          <Show when={fbResult() === "success"}>1655            <p class="peach-feedback-result peach-feedback-result--ok">1656              Sent — thank you1657            </p>1658          </Show>1659          <Show when={fbResult() === "error"}>1660            <p class="peach-feedback-result peach-feedback-result--err">1661              Failed to send — try again1662            </p>1663          </Show>16641665          <button1666            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1667            onClick={() => void handleSubmitFeedback()}1668            disabled={fbSubmitting() || !fbMessage().trim()}1669          >1670            {fbSubmitting() ? "Sending..." : "Send feedback"}1671          </button>16721673          <p class="peach-feedback-anon">1674            Sent anonymously · no account required1675          </p>1676        </div>1677      </div>1678    );1679  }16801681  function renderMobile() {1682    return (1683      <div class="peach-settings-body">1684        <div class="peach-settings-group">1685          <p class="peach-settings-label">Autofill service</p>1686          <div class="peach-mobile-row">1687            <span class="peach-mobile-status">1688              {autofillEnabled() ? "Enabled" : "Disabled"}1689            </span>1690            <button1691              class="peach-btn peach-btn--subtle peach-btn--sm"1692              onClick={() => void AutofillPlugin?.openAutofillSettings()}1693            >1694              {autofillEnabled() ? "Change" : "Enable"}1695            </button>1696          </div>1697        </div>16981699        <div class="peach-settings-group">1700          <p class="peach-settings-label">Biometric unlock</p>1701          <div class="peach-mobile-row">1702            <span class="peach-mobile-status">1703              {biometricEnabled() ? "Available" : "Not available"}1704            </span>1705          </div>1706        </div>1707      </div>1708    );1709  }17101711  return (1712    <div class="peach-overlay-view ov-enter-right" style="display:flex">1713      <div class="peach-overlay-header">1714        <button1715          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1716          onClick={handleHeaderBack}1717          aria-label="Back"1718        >1719          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1720            <path d="M6 1L1 6L6 11" />1721          </svg>1722        </button>1723        <span class="peach-overlay-title">{currentTitle()}</span>1724        <div class="peach-overlay-spacer"></div>1725      </div>17261727      <Switch fallback={renderHome()}>1728        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1729        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1730        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1731        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1732        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1733        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1734        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1735        <Match when={activeSection() === "mobile"}>{renderMobile()}</Match>1736      </Switch>1737    </div>1738  );1739}