Settings.tsx

67.4 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { LockRuneCanvas } from "../components/LockRuneCanvas";5import type { Canary } from "../../shared/types";6import { ACCENT_COLORS } from "../../shared/types";7import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";8import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";9import {10  DEFAULT_BINDINGS,11  findConflict,12  formatKeybinding,13  keybindingFromEvent,14  SHORTCUT_DESCRIPTORS,15  type Keybinding,16  type ShortcutAction,17} from "../../shared/shortcuts";1819interface LicenseInfo {20  valid: boolean;21  plan: "free" | "pro";22  issued?: string;23}2425interface SettingsProps {26  entriesCount: number;27  breachMonitoring: boolean;28  lockOnClose: boolean;29  autoLockMinutes: number;30  trashRetentionDays: number;31  theme: "dark" | "light";32  accentColor: AccentColorName;33  displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";34  textSize: "small" | "medium" | "large";35  reduceMotion: boolean;36  isPro?: boolean;37  initialSection?: string | null;38  upgradeHighlight?: ProFeature | null;39  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;40  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;41  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;42  onTrashRetentionChange: (days: number) => void | Promise<void>;43  onThemeChange: (theme: "dark" | "light") => void;44  onAccentChange: (name: AccentColorName) => void | Promise<void>;45  onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;46  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;47  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;48  shortcuts: Record<ShortcutAction, Keybinding>;49  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;50  onLicenseChange?: () => void | Promise<void>;51  onBack: () => void;52  onImport: () => void;53  onExport: () => void;54  onBreachCheckDone: () => void;55  onOpenHealth?: () => void;56  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;57  onOpenFileImport?: () => void;58  onOpenPanic?: () => void;59}6061type SectionName =62  | "security"63  | "vault"64  | "sync"65  | "appearance"66  | "shortcuts"67  | "license"68  | "feedback";6970const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7172function formatActivatedSince(issued: string | undefined): string {73  if (!issued) return "Active";74  try {75    const d = new Date(issued);76    if (Number.isNaN(d.getTime())) return "Active";77    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;78  } catch {79    return "Active";80  }81}8283function isSectionName(value: string): value is SectionName {84  return (85    value === "security" ||86    value === "vault" ||87    value === "sync" ||88    value === "appearance" ||89    value === "shortcuts" ||90    value === "license" ||91    value === "feedback"92  );93}9495export function SettingsView(props: SettingsProps) {96  const version = chrome.runtime.getManifest().version;97  const initial = props.initialSection;98  const [activeSection, setActiveSection] = createSignal<SectionName | null>(99    initial && isSectionName(initial) ? (initial as SectionName) : null100  );101102  const [checkingBreaches, setCheckingBreaches] = createSignal(false);103  const [breachResult, setBreachResult] = createSignal<string | null>(null);104  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);105  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");106  const [licenseError, setLicenseError] = createSignal<string | null>(null);107  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);108  const [activatingLicense, setActivatingLicense] = createSignal(false);109110  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);111  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);112  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");113  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);114  // Prefer the prop (cached at App boot), fall back to the sync-state probe.115  // The prop is authoritative once present so license activation reflects116  // immediately without waiting for a sync round-trip.117  const [syncProStatus, setSyncProStatus] = createSignal(false);118  const isPro = () => props.isPro ?? syncProStatus();119  const [syncingNow, setSyncingNow] = createSignal(false);120  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);121  const [passkeySaving, setPasskeySaving] = createSignal(false);122123  const [dedupCount, setDedupCount] = createSignal<number | null>(null);124  const [dedupRunning, setDedupRunning] = createSignal(false);125  const [dedupDone, setDedupDone] = createSignal<number | null>(null);126127  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);128129  const [showChangePw, setShowChangePw] = createSignal(false);130  const [changePwCurrent, setChangePwCurrent] = createSignal("");131  const [changePwNew, setChangePwNew] = createSignal("");132  const [changePwConfirm, setChangePwConfirm] = createSignal("");133  const [changePwSaving, setChangePwSaving] = createSignal(false);134  const [changePwError, setChangePwError] = createSignal<string | null>(null);135  const [changePwSuccess, setChangePwSuccess] = createSignal(false);136137  const [duressEnabled, setDuressEnabled] = createSignal(false);138  const [showDuressSetup, setShowDuressSetup] = createSignal(false);139  const [duressPassword, setDuressPassword] = createSignal("");140  const [duressConfirm, setDuressConfirm] = createSignal("");141  const [duressMainPassword, setDuressMainPassword] = createSignal("");142  const [duressSaving, setDuressSaving] = createSignal(false);143  const [duressError, setDuressError] = createSignal<string | null>(null);144  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);145146  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);147  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);148  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);149150  const [fbCategory, setFbCategory] = createSignal("Bug report");151  const [fbMessage, setFbMessage] = createSignal("");152  const [fbSubmitting, setFbSubmitting] = createSignal(false);153  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);154155  onMount(async () => {156    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([157      sendMessage({ type: "GET_LICENSE" }),158      sendMessage({ type: "GET_SYNC_STATE" }),159      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),160      sendMessage({ type: "IS_DURESS_ENABLED" }),161      sendMessage({ type: "GET_SETTINGS" }),162      sendMessage({ type: "GET_LICENSE_KEY" }),163    ]);164165    if ((licenseRes as { ok: boolean }).ok) {166      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);167    }168169    if ((syncRes as { ok: boolean }).ok) {170      const data = syncRes as { ok: boolean; data?: {171        pairedDevices?: PairedDevice[];172        trustedNetworks?: TrustedNetwork[];173        status?: SyncStatus;174        lastSyncAt?: number;175        isPro?: boolean;176      }};177      if (data.data) {178        setPairedDevices(data.data.pairedDevices ?? []);179        setTrustedNetworks(data.data.trustedNetworks ?? []);180        setSyncStatus(data.data.status ?? "offline");181        setLastSyncAt(data.data.lastSyncAt);182        setSyncProStatus(data.data.isPro ?? false);183      }184    }185186    if ((passkeyRes as { ok: boolean }).ok) {187      setPasskeyState(188        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null189      );190    }191192    if ((duressRes as { ok: boolean }).ok) {193      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);194    }195196    if ((settingsRes as { ok: boolean }).ok) {197      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;198      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);199    }200201    if ((keyRes as { ok: boolean }).ok) {202      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);203    }204205    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });206    if ((entriesRes as { ok: boolean }).ok) {207      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];208      setCanaryEntries(209        entries210          .filter((e) => e.canary?.webhookUrl)211          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))212      );213    }214215    chrome.runtime.onMessage.addListener((msg) => {216      if (msg.type === "SYNC_STATE_UPDATE") {217        setPairedDevices(msg.pairedDevices ?? []);218        setTrustedNetworks(msg.trustedNetworks ?? []);219        setSyncStatus(msg.status ?? "offline");220        setLastSyncAt(msg.lastSyncAt);221        setSyncProStatus(msg.isPro ?? false);222      }223    });224  });225226  async function handleChangePassword() {227    setChangePwError(null);228    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }229    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }230    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }231    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }232    setChangePwSaving(true);233    const res = await sendMessage({234      type: "CHANGE_PASSWORD",235      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },236    }) as { ok: boolean; error?: string };237    if (res.ok) {238      setChangePwSuccess(true);239      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");240      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);241    } else {242      setChangePwError(res.error ?? "failed to change password");243    }244    setChangePwSaving(false);245  }246247  const [copied, setCopied] = createSignal(false);248249  async function copyLicenseKey(key: string) {250    await navigator.clipboard.writeText(key);251    setCopied(true);252    setTimeout(() => setCopied(false), 2000);253  }254255  async function handleActivateLicense() {256    const key = licenseKeyInput().trim();257    if (!key) return;258    setActivatingLicense(true);259    setLicenseError(null);260    setLicenseSuccess(null);261    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });262    setActivatingLicense(false);263    if ((res as { ok: boolean }).ok) {264      const data = (res as { ok: boolean; data?: LicenseInfo }).data;265      if (data?.valid) {266        setLicenseInfo(data);267        setLicenseKey(key);268        setLicenseSuccess("License activated!");269        setLicenseKeyInput("");270      } else {271        setLicenseError("Invalid license key");272      }273    } else {274      setLicenseError("Activation failed");275    }276  }277278  async function handleTogglePasskeys() {279    const current = passkeyState();280    if (!current || !current.supported) return;281    setPasskeySaving(true);282    const res = await sendMessage({283      type: "SET_PASSKEY_PROVIDER_ENABLED",284      payload: { enabled: !current.enabled },285    });286    setPasskeySaving(false);287    if ((res as { ok: boolean }).ok) {288      setPasskeyState(289        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null290      );291    }292  }293294  async function handleCheckAllBreaches() {295    setCheckingBreaches(true);296    setBreachResult(null);297    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });298    setCheckingBreaches(false);299    if ((res as { ok: boolean }).ok) {300      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};301      const breached = Object.values(data).filter((count) => count > 0).length;302      setBreachResult(303        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"304      );305      props.onBreachCheckDone();306    }307  }308309  async function handleSyncNow() {310    if (syncStatus() === "not_trusted") return;311    setSyncingNow(true);312    await sendMessage({ type: "TRIGGER_SYNC" });313    setTimeout(() => setSyncingNow(false), 3000);314  }315316  async function handleRemoveDevice(deviceId: string) {317    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });318  }319320  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {321    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });322  }323324  async function handleMarkNetworkTrusted() {325    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });326  }327328  function formatRelativeTime(timestamp?: number): string {329    if (!timestamp) return "Never";330    const diff = Date.now() - timestamp;331    const minutes = Math.floor(diff / 60000);332    const hours = Math.floor(diff / 3600000);333    const days = Math.floor(diff / 86400000);334    if (minutes < 1) return "Just now";335    if (minutes < 60) return `${minutes}m ago`;336    if (hours < 24) return `${hours}h ago`;337    return `${days}d ago`;338  }339340  function getStatusColor(status: SyncStatus): string {341    switch (status) {342      case "synced": return "var(--peach-sync-ok)";343      case "syncing": return "var(--peach-sync-busy)";344      case "no_peers": return "var(--peach-sync-off)";345      case "not_trusted": return "var(--peach-sync-err)";346      case "offline": return "var(--peach-sync-off)";347      default: return "var(--peach-sync-off)";348    }349  }350351  function getStatusLabel(status: SyncStatus): string {352    switch (status) {353      case "synced": return "Synced";354      case "syncing": return "Syncing";355      case "no_peers": return "No peers visible";356      case "not_trusted": return "Not on a trusted network";357      case "offline": return "Offline";358      default: return "Unknown";359    }360  }361362  function isCurrentNetworkTrusted(): boolean {363    return trustedNetworks().length > 0;364  }365366  async function handleSetupDuress() {367    const mainPw = duressMainPassword().trim();368    const duressPw = duressPassword().trim();369    const confirmPw = duressConfirm().trim();370371    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }372    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }373    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }374    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }375376    setDuressSaving(true);377    setDuressError(null);378379    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };380    if (!verifyRes.ok || !verifyRes.data) {381      setDuressError("incorrect main password");382      setDuressSaving(false);383      return;384    }385386    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };387    setDuressSaving(false);388    if (res.ok) {389      setDuressEnabled(true);390      setShowDuressSetup(false);391      setDuressPassword("");392      setDuressConfirm("");393      setDuressMainPassword("");394      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");395      setTimeout(() => setDuressSuccess(null), 5000);396    } else {397      setDuressError(res.error ?? "failed to set duress password");398    }399  }400401  async function handleClearDeclinedCards() {402    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });403    setDeclinedCardCount(0);404  }405406  async function handleCountDuplicates() {407    setDedupRunning(true);408    setDedupDone(null);409    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };410    setDedupRunning(false);411    if (res.ok) setDedupCount(res.data ?? 0);412  }413414  async function handleDeduplicate() {415    setDedupRunning(true);416    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };417    setDedupRunning(false);418    if (res.ok) {419      setDedupDone(res.data ?? 0);420      setDedupCount(0);421    }422  }423424  async function handleDisableDuress() {425    const mainPw = duressMainPassword().trim();426    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }427428    setDuressSaving(true);429    setDuressError(null);430431    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };432    if (!verifyRes.ok || !verifyRes.data) {433      setDuressError("incorrect main password");434      setDuressSaving(false);435      return;436    }437438    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };439    setDuressSaving(false);440    if (res.ok) {441      setDuressEnabled(false);442      setShowDuressSetup(false);443      setDuressMainPassword("");444      setDuressSuccess("Duress password disabled.");445      setTimeout(() => setDuressSuccess(null), 4000);446    } else {447      setDuressError(res.error ?? "failed to disable duress password");448    }449  }450451  async function handleSubmitFeedback() {452    if (!fbMessage().trim() || fbSubmitting()) return;453    setFbSubmitting(true);454    setFbResult(null);455    const res = await sendMessage({456      type: "SUBMIT_FEEDBACK",457      payload: {458        category: fbCategory(),459        message: fbMessage().trim(),460        version,461        browser: navigator.userAgent,462      },463    }) as { ok: boolean; error?: string };464    setFbSubmitting(false);465    if (res.ok) {466      setFbResult("success");467      setFbMessage("");468      setTimeout(() => setFbResult(null), 4000);469    } else {470      setFbResult("error");471    }472  }473474  function handleHeaderBack() {475    if (activeSection() !== null) {476      setActiveSection(null);477    } else {478      props.onBack();479    }480  }481482  function currentTitle(): string {483    const s = activeSection();484    if (s === null) return "settings";485    if (s === "license") return "account";486    return s;487  }488489  function renderCatRow(opts: {490    section: SectionName;491    name: string;492    desc: string;493    icon: () => any;494    pro?: boolean;495  }) {496    return (497      <button498        class="peach-settings-cat-row"499        onClick={() => setActiveSection(opts.section)}500      >501        <div class="peach-settings-cat-icon">{opts.icon()}</div>502        <div class="peach-settings-cat-content">503          <span class="peach-settings-cat-name">504            {opts.name}505            <Show when={opts.pro && !isPro()}>506              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>507            </Show>508          </span>509          <span class="peach-settings-cat-desc">{opts.desc}</span>510        </div>511        <svg512          class="peach-settings-cat-chevron"513          width="6" height="10" viewBox="0 0 6 10" fill="none"514          stroke="currentColor" stroke-width="1.1"515          stroke-linecap="round" stroke-linejoin="round"516        >517          <path d="M1 1l4 4-4 4" />518        </svg>519      </button>520    );521  }522523  const iconShield = () => (524    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">525      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>526    </svg>527  );528  const iconKey = () => (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="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>531    </svg>532  );533  const iconLock = () => (534    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">535      <rect x="3" y="7" width="10" height="7" rx="1.5"/>536      <path d="M6 7V5a3 3 0 016 0v2"/>537    </svg>538  );539  const iconSync = () => (540    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">541      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>542      <path d="M14 6l-2 2-2-2"/>543      <path d="M2 10l2-2 2 2"/>544    </svg>545  );546  const iconSun = () => (547    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">548      <circle cx="8" cy="8" r="3"/>549      <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"/>550    </svg>551  );552  const iconKeys = () => (553    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">554      <rect x="2" y="5" width="5" height="4" rx="1"/>555      <rect x="9" y="7" width="5" height="4" rx="1"/>556      <path d="M7 7h2m-2 2h2"/>557    </svg>558  );559  const iconBubble = () => (560    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">561      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>562    </svg>563  );564565  function renderHome() {566    return (567      <div class="peach-settings-body">568        <p class="peach-settings-nav-label">PROTECTION</p>569        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}570        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}571572        <p class="peach-settings-nav-label">VAULT</p>573        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}574        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}575576        <p class="peach-settings-nav-label">PREFERENCES</p>577        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}578        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}579580        <p class="peach-settings-nav-label">SUPPORT</p>581        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}582583        <div class="peach-settings-footer">584          <span>Peach {version}</span>585          <span class="peach-divider"></span>586          <span>Browser extension</span>587        </div>588      </div>589    );590  }591592  function renderSecurity() {593    return (594      <div class="peach-settings-body">595        <div class="peach-settings-group">596          <p class="peach-settings-sub-label">LOCKING</p>597          <div class="peach-settings-row">598            <span>Lock on close</span>599            <button600              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}601              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}602              aria-label="Toggle lock on close"603            >604              <span class="peach-toggle-knob"></span>605            </button>606          </div>607          <div class="peach-settings-row">608            <span>Auto-lock after</span>609            <select610              class="peach-settings-select"611              value={String(props.autoLockMinutes)}612              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}613            >614              <option value="1">1 minute</option>615              <option value="5">5 minutes</option>616              <option value="15">15 minutes</option>617              <option value="30">30 minutes</option>618              <option value="0">Never</option>619            </select>620          </div>621622          <p class="peach-settings-sub-label">BREACH PROTECTION</p>623          <div class="peach-settings-row">624            <span>Breach monitoring</span>625            <button626              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}627              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}628              aria-label="Toggle breach monitoring"629            >630              <span class="peach-toggle-knob"></span>631            </button>632          </div>633          <div class="peach-settings-row peach-settings-row--meta">634            <span>Check all passwords</span>635            <button636              class="peach-btn peach-btn--subtle peach-btn--sm"637              onClick={() => void handleCheckAllBreaches()}638              disabled={checkingBreaches()}639            >640              {checkingBreaches() ? "checking..." : "run"}641            </button>642          </div>643          <Show when={breachResult()}>644            <div class="peach-settings-row peach-settings-row--meta">645              <span>Last check</span>646              <span class="peach-settings-value">{breachResult()}</span>647            </div>648          </Show>649650          <p class="peach-settings-sub-label">MASTER PASSWORD</p>651          <div class="peach-settings-row peach-settings-row--meta">652            <span>Master password</span>653            <button654              class="peach-btn peach-btn--subtle peach-btn--sm"655              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}656            >657              {showChangePw() ? "cancel" : "change"}658            </button>659          </div>660          <Show when={showChangePw()}>661            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">662              <input663                class="peach-settings-input"664                type="password"665                placeholder="current password"666                value={changePwCurrent()}667                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}668                autocomplete="current-password"669              />670              <input671                class="peach-settings-input"672                type="password"673                placeholder="new password (min 12 chars)"674                value={changePwNew()}675                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}676                autocomplete="new-password"677              />678              <input679                class="peach-settings-input"680                type="password"681                placeholder="confirm new password"682                value={changePwConfirm()}683                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}684                autocomplete="new-password"685                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}686              />687              <Show when={changePwError()}>688                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>689              </Show>690              <Show when={changePwSuccess()}>691                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>692              </Show>693              <button694                class="peach-btn peach-btn--accent peach-btn--sm"695                style="align-self:flex-start"696                onClick={() => void handleChangePassword()}697                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}698              >699                {changePwSaving() ? "saving…" : "save new password"}700              </button>701            </div>702          </Show>703704          <p class="peach-settings-sub-label">DURESS PASSWORD</p>705          <div class="peach-settings-row peach-settings-row--top">706            <div class="peach-settings-row-main">707              <span>Duress password</span>708              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">709                If entered under coercion, shows a decoy empty vault instead of your real one.710              </span>711            </div>712            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>713              {duressEnabled() ? "on" : "off"}714            </div>715          </div>716717          <Show when={duressEnabled() && !showDuressSetup()}>718            <div class="peach-settings-row peach-settings-row--meta">719              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">720                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.721              </span>722            </div>723          </Show>724725          <Show when={duressSuccess()}>726            <div class="peach-settings-row peach-settings-row--meta">727              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>728            </div>729          </Show>730731          <Show when={!showDuressSetup()}>732            <div class="peach-settings-row peach-settings-row--meta">733              <button734                class="peach-btn peach-btn--subtle peach-btn--sm"735                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}736              >737                {duressEnabled() ? "Change / disable" : "Set up"}738              </button>739            </div>740          </Show>741742          <Show when={showDuressSetup()}>743            <div class="peach-duress-form">744              <label class="peach-label">main password (to confirm)</label>745              <input746                class="peach-settings-input"747                type="password"748                placeholder="your current master password"749                autocomplete="current-password"750                value={duressMainPassword()}751                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}752              />753              <Show when={!duressEnabled()}>754                <label class="peach-label">new duress password</label>755                <input756                  class="peach-settings-input"757                  type="password"758                  placeholder="duress password (min 8 chars)"759                  autocomplete="new-password"760                  value={duressPassword()}761                  onInput={(e) => setDuressPassword(e.currentTarget.value)}762                />763                <label class="peach-label">confirm duress password</label>764                <input765                  class="peach-settings-input"766                  type="password"767                  placeholder="confirm duress password"768                  autocomplete="new-password"769                  value={duressConfirm()}770                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}771                />772              </Show>773774              <Show when={duressError()}>775                <p class="peach-hint peach-hint-error">{duressError()}</p>776              </Show>777778              <div class="peach-duress-actions">779                <button780                  class="peach-btn peach-btn--subtle peach-btn--sm"781                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}782                >783                  cancel784                </button>785                <Show when={duressEnabled()}>786                  <button787                    class="peach-btn peach-btn--ghost peach-btn--sm"788                    style="color:var(--peach-breach)"789                    onClick={() => void handleDisableDuress()}790                    disabled={duressSaving()}791                  >792                    {duressSaving() ? "disabling..." : "disable duress"}793                  </button>794                </Show>795                <Show when={!duressEnabled()}>796                  <button797                    class="peach-btn peach-btn--accent peach-btn--sm"798                    onClick={() => void handleSetupDuress()}799                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}800                  >801                    {duressSaving() ? "saving..." : "save"}802                  </button>803                </Show>804              </div>805            </div>806          </Show>807808          <p class="peach-settings-sub-label">CANARY ENTRIES</p>809          <div class="peach-settings-row peach-settings-row--top">810            <div class="peach-settings-row-main">811              <span>Canary entries</span>812              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">813                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.814              </span>815            </div>816            <span class="peach-settings-value" style="flex-shrink:0">817              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}818            </span>819          </div>820          <Show when={canaryEntries().length > 0}>821            <For each={canaryEntries()}>822              {(entry) => (823                <div class="peach-settings-row peach-settings-row--meta">824                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>825                  <button826                    class="peach-btn peach-btn--subtle peach-btn--sm"827                    onClick={() => void handleTestCanary(entry)}828                    disabled={canaryTestingId() === entry.id}829                    style="flex-shrink:0"830                  >831                    {canaryTestingId() === entry.id832                      ? "sending…"833                      : canaryTestResult()?.id === entry.id834                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")835                        : "test"}836                  </button>837                </div>838              )}839            </For>840          </Show>841842          <p class="peach-settings-sub-label">PASSKEYS</p>843          <div class="peach-settings-row peach-settings-row--top">844            <div class="peach-settings-row-main">845              <span>Intercept passkey requests</span>846              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">847                Peach can handle passkey sign-in and creation on supported Chromium browsers.848              </span>849            </div>850            <button851              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}852              onClick={() => void handleTogglePasskeys()}853              aria-label="Toggle passkey interception"854              disabled={!passkeyState()?.supported || passkeySaving()}855            >856              <span class="peach-toggle-knob"></span>857            </button>858          </div>859          <div class="peach-settings-row peach-settings-row--meta">860            <span>Status</span>861            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>862          </div>863          <div class="peach-settings-row peach-settings-row--meta">864            <span>Browser</span>865            <span class="peach-settings-value">866              {passkeyState()?.browserLabel ?? "Unknown"}867              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}868            </span>869          </div>870          <div class="peach-info-notice" style="margin-top:8px">871            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.872          </div>873          <Show when={passkeyState()?.conflict}>874            <div class="peach-settings-row peach-settings-row--meta">875              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>876            </div>877          </Show>878        </div>879      </div>880    );881  }882883  function renderVault() {884    return (885      <div class="peach-settings-body">886        <div class="peach-settings-group">887          <p class="peach-settings-sub-label">ENTRIES</p>888          <div class="peach-settings-row peach-settings-row--meta">889            <span>Entries</span>890            <span class="peach-settings-value">{props.entriesCount}</span>891          </div>892          <div class="peach-settings-row peach-settings-row--meta">893            <span>Duplicates</span>894            <Show when={dedupDone() !== null} fallback={895              <button896                class="peach-btn peach-btn--subtle peach-btn--sm"897                onClick={() => void handleCountDuplicates()}898                disabled={dedupRunning()}899              >900                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}901              </button>902            }>903              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">904                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}905              </span>906            </Show>907          </div>908          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>909            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">910              <div class="peach-restore-warning">911                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.912              </div>913              <div style="display:flex;gap:6px">914                <button915                  class="peach-btn peach-btn--subtle peach-btn--sm"916                  onClick={() => setDedupCount(null)}917                >918                  cancel919                </button>920                <button921                  class="peach-btn peach-btn--accent peach-btn--sm"922                  onClick={() => void handleDeduplicate()}923                  disabled={dedupRunning()}924                >925                  {dedupRunning() ? "removing..." : "remove duplicates"}926                </button>927              </div>928            </div>929          </Show>930931          <p class="peach-settings-sub-label">MAINTENANCE</p>932          <div class="peach-settings-row peach-settings-row--meta">933            <span>Trash auto-delete</span>934            <select935              class="peach-settings-select"936              value={String(props.trashRetentionDays)}937              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}938            >939              <option value="7">7 days</option>940              <option value="30">30 days</option>941              <option value="90">90 days</option>942              <option value="0">Never</option>943            </select>944          </div>945          <div class="peach-settings-row peach-settings-row--meta">946            <span>Vault Health</span>947            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>948              open949            </button>950          </div>951          <Show when={declinedCardCount() > 0}>952            <div class="peach-settings-row peach-settings-row--meta">953              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>954              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>955                Clear956              </button>957            </div>958          </Show>959960          <p class="peach-settings-sub-label">DATA</p>961          <div class="peach-settings-row peach-settings-row--meta">962            <span>Import passwords</span>963            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>964              open965            </button>966          </div>967          <div class="peach-settings-row peach-settings-row--meta">968            <span>Export backup</span>969            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>970              open971            </button>972          </div>973          <div class="peach-danger-card">974            <div class="peach-danger-card-head">975              <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>976              <h3 class="peach-danger-card-title">Replace vault from backup</h3>977            </div>978            <p class="peach-danger-card-copy">979              Wipes the current vault and restores from a backup file.980              Recoverable only if your backup is good.981            </p>982            <button983              class="peach-danger-card-button"984              onClick={() => props.onOpenFileImport?.()}985            >986              Choose backup file987            </button>988          </div>989990          <Show when={props.onOpenPanic}>991            <div class="peach-danger-card peach-danger-card--hazard">992              <div class="peach-danger-card-head">993                <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>994                <h3 class="peach-danger-card-title">Destroy vault</h3>995              </div>996              <p class="peach-danger-card-copy">997                Permanently wipes every entry, setting, and license on this998                device. There is no recovery.999              </p>1000              <button1001                class="peach-danger-card-button"1002                onClick={() => props.onOpenPanic?.()}1003              >1004                Begin destruction1005              </button>1006            </div>1007          </Show>1008        </div>1009      </div>1010    );1011  }10121013  function renderSync() {1014    if (!isPro()) {1015      return (1016        <div class="peach-settings-body">1017          <div class="peach-pro-upgrade">1018            <div class="peach-pro-upgrade-hero">1019              <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">1020                <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1021                <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1022                <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"/>1023                <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"/>1024                <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>1025                <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1026                <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1027              </svg>1028              <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1029              <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1030            </div>10311032            <div class="peach-pro-features">1033              <div class="peach-pro-feature">1034                <span class="peach-pro-feature-mark">✦</span>1035                <div class="peach-pro-feature-text">1036                  <span class="peach-pro-feature-name">Always in sync</span>1037                  <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1038                </div>1039              </div>1040              <div class="peach-pro-feature">1041                <span class="peach-pro-feature-mark">✦</span>1042                <div class="peach-pro-feature-text">1043                  <span class="peach-pro-feature-name">No cloud. No relay.</span>1044                  <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1045                </div>1046              </div>1047              <div class="peach-pro-feature">1048                <span class="peach-pro-feature-mark">✦</span>1049                <div class="peach-pro-feature-text">1050                  <span class="peach-pro-feature-name">Tailscale supported</span>1051                  <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1052                </div>1053              </div>1054              <div class="peach-pro-feature">1055                <span class="peach-pro-feature-mark">✦</span>1056                <div class="peach-pro-feature-text">1057                  <span class="peach-pro-feature-name">Zero friction after setup</span>1058                  <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1059                </div>1060              </div>1061            </div>10621063            <button1064              class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1065              onClick={() => setActiveSection("license")}1066            >1067              Upgrade to Pro1068            </button>1069            <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1070              Already have a key? Enter it →1071            </button>1072          </div>1073        </div>1074      );1075    }10761077    return (1078      <div class="peach-settings-body">1079        <div class="peach-settings-group">1080          <div class="peach-settings-row">1081            <span>Status</span>1082            <span class="peach-settings-value peach-settings-value--status">1083              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1084              {getStatusLabel(syncStatus())}1085            </span>1086          </div>1087          <div class="peach-settings-row peach-settings-row--meta">1088            <span>Last sync</span>1089            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1090          </div>1091          <div class="peach-settings-row peach-settings-row--meta">1092            <button1093              class="peach-btn peach-btn--subtle peach-btn--sm"1094              onClick={() => void handleSyncNow()}1095              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1096            >1097              {syncingNow() ? "Syncing..." : "Sync now"}1098            </button>1099          </div>11001101          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1102            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1103            {pairedDevices().map((device) => (1104              <div class="peach-sync-device-row">1105                <div class="peach-sync-device-info">1106                  <span class="peach-sync-device-name">{device.displayName}</span>1107                  <span class="peach-sync-device-meta">1108                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1109                  </span>1110                </div>1111                <button1112                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1113                  onClick={() => void handleRemoveDevice(device.deviceId)}1114                >1115                  Remove1116                </button>1117              </div>1118            ))}1119            <div class="peach-settings-actions--row">1120              <button1121                class="peach-btn peach-btn--subtle peach-btn--sm"1122                onClick={() => props.onOpenSyncPairing?.("initiate")}1123              >1124                Pair a new device1125              </button>1126              <button1127                class="peach-btn peach-btn--subtle peach-btn--sm"1128                onClick={() => props.onOpenSyncPairing?.("accept")}1129              >1130                Pair with another device1131              </button>1132            </div>1133          </div>11341135          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1136            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1137            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1138              <div class="peach-sync-device-row">1139                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1140                <button1141                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1142                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1143                >1144                  Remove1145                </button>1146              </div>1147            ))}1148            <div class="peach-settings-actions--row">1149              <Show when={!isCurrentNetworkTrusted()}>1150                <button1151                  class="peach-btn peach-btn--subtle peach-btn--sm"1152                  onClick={() => void handleMarkNetworkTrusted()}1153                >1154                  Mark current network as trusted1155                </button>1156              </Show>1157            </div>1158            <span class="peach-sync-device-meta">1159              Tailscale is always trusted1160            </span>1161          </div>1162        </div>1163      </div>1164    );1165  }11661167  function renderAppearance() {1168    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1169      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1170      { id: "cormorant", label: "Cormorant", preview: "Aa" },1171      { id: "inter", label: "Inter", preview: "Aa" },1172      { id: "system", label: "System", preview: "Aa" },1173    ];1174    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1175      { id: "small", label: "Small" },1176      { id: "medium", label: "Default" },1177      { id: "large", label: "Large" },1178    ];1179    return (1180      <div class="peach-settings-body">1181        <div class="peach-settings-group">1182          <p class="peach-settings-sub-label">THEME</p>1183          <div class="peach-settings-row peach-settings-row--meta">1184            <span>Theme</span>1185            <button1186              class="peach-btn peach-btn--subtle peach-btn--sm"1187              onClick={() =>1188                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1189              }1190            >1191              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1192            </button>1193          </div>11941195          <p class="peach-settings-sub-label">ACCENT</p>1196          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1197            <span>Accent</span>1198            <div class="peach-accent-picker">1199              <For each={ACCENT_COLORS}>1200                {(color) => (1201                  <button1202                    class="peach-accent-swatch"1203                    classList={{ "is-active": props.accentColor === color.name }}1204                    style={{ background: color.hex }}1205                    onClick={() => void props.onAccentChange(color.name)}1206                    aria-label={color.name}1207                    title={color.name}1208                  />1209                )}1210              </For>1211            </div>1212          </div>12131214          <p class="peach-settings-sub-label">DISPLAY FONT</p>1215          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1216            <span>Font</span>1217            <div class="peach-font-picker">1218              <For each={fontOptions}>1219                {(opt) => (1220                  <button1221                    class="peach-font-swatch"1222                    classList={{ "is-active": props.displayFont === opt.id }}1223                    data-font-preview={opt.id}1224                    onClick={() => void props.onDisplayFontChange(opt.id)}1225                    aria-label={opt.label}1226                    title={opt.label}1227                  >1228                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1229                    <span class="peach-font-swatch-name">{opt.label}</span>1230                  </button>1231                )}1232              </For>1233            </div>1234          </div>12351236          <p class="peach-settings-sub-label">TEXT SIZE</p>1237          <div class="peach-settings-row peach-settings-row--meta">1238            <span>Size</span>1239            <div class="peach-pill-group">1240              <For each={sizeOptions}>1241                {(opt) => (1242                  <button1243                    class="peach-btn peach-btn--ghost peach-btn--sm"1244                    classList={{ "is-active": props.textSize === opt.id }}1245                    onClick={() => void props.onTextSizeChange(opt.id)}1246                  >1247                    {opt.label}1248                  </button>1249                )}1250              </For>1251            </div>1252          </div>12531254          <p class="peach-settings-sub-label">MOTION</p>1255          <div class="peach-settings-row peach-settings-row--meta">1256            <span>Reduce motion</span>1257            <button1258              class="peach-btn peach-btn--subtle peach-btn--sm"1259              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1260            >1261              {props.reduceMotion ? "On" : "Off"}1262            </button>1263          </div>1264        </div>1265      </div>1266    );1267  }12681269  function renderShortcuts() {1270    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1271    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12721273    function startCapture(action: ShortcutAction) {1274      setConflictMsg(null);1275      setCapturingAction(action);1276    }1277    function cancelCapture() {1278      setCapturingAction(null);1279      setConflictMsg(null);1280    }1281    function commitBinding(action: ShortcutAction, binding: Keybinding) {1282      const conflict = findConflict(props.shortcuts, binding, action);1283      if (conflict) {1284        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1285        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1286        return;1287      }1288      const next = { ...props.shortcuts, [action]: binding };1289      setCapturingAction(null);1290      setConflictMsg(null);1291      void props.onShortcutsChange(next);1292    }1293    function resetAction(action: ShortcutAction) {1294      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1295      void props.onShortcutsChange(next);1296    }1297    function resetAll() {1298      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1299    }13001301    // Capture-mode key handler — wired via ref onto the chip element so it1302    // catches the next non-modifier keypress and ignores other bindings.1303    function handleCaptureKey(e: KeyboardEvent) {1304      e.preventDefault();1305      e.stopPropagation();1306      if (e.key === "Escape") { cancelCapture(); return; }1307      const binding = keybindingFromEvent(e);1308      if (!binding) return; // bare modifier — keep waiting1309      const action = capturingAction();1310      if (action) commitBinding(action, binding);1311    }13121313    return (1314      <div class="peach-settings-body">1315        <div class="peach-settings-group peach-shortcuts-group">1316          <p class="peach-settings-sub-label">ENTRY VIEW</p>1317          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1318            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1319          </For>1320        </div>13211322        <div class="peach-settings-group peach-shortcuts-group">1323          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1324          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1325            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1326          </For>1327        </div>13281329        <Show when={conflictMsg()}>1330          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1331        </Show>13321333        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1334          <div class="peach-settings-row peach-settings-row--meta">1335            <span>Inline autofill on page</span>1336            <a1337              class="peach-settings-link"1338              href="chrome://extensions/shortcuts"1339              onClick={(e) => {1340                e.preventDefault();1341                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1342              }}1343            >1344              ⌘⇧P — customize ↗1345            </a>1346          </div>1347          <button1348            class="peach-btn peach-btn--subtle peach-btn--sm"1349            style="align-self:flex-start"1350            onClick={resetAll}1351          >1352            Reset all to defaults1353          </button>1354        </div>1355      </div>1356    );13571358    function renderRow(1359      desc: typeof SHORTCUT_DESCRIPTORS[number],1360      capturing: () => ShortcutAction | null,1361      onStart: (a: ShortcutAction) => void,1362      onCancel: () => void,1363      onReset: (a: ShortcutAction) => void,1364      onKey: (e: KeyboardEvent) => void,1365    ) {1366      const isActive = () => capturing() === desc.action;1367      const binding = () => props.shortcuts[desc.action];1368      const isCustom = () => {1369        const b = binding();1370        const d = DEFAULT_BINDINGS[desc.action];1371        return b.key !== d.key1372          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1373          || Boolean(b.meta)  !== Boolean(d.meta)1374          || Boolean(b.shift) !== Boolean(d.shift)1375          || Boolean(b.alt)   !== Boolean(d.alt);1376      };1377      return (1378        <div class="peach-shortcut-row">1379          <span class="peach-shortcut-row-label">{desc.label}</span>1380          <button1381            class="peach-shortcut-chip"1382            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1383            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1384            onKeyDown={(e) => { if (isActive()) onKey(e); }}1385            ref={(el) => {1386              // Auto-focus when entering capture mode so the keydown listener catches the next press.1387              if (el) {1388                queueMicrotask(() => { if (isActive()) el.focus(); });1389              }1390            }}1391          >1392            {isActive() ? "Press a key…" : formatKeybinding(binding())}1393          </button>1394          <Show when={isCustom() && !isActive()}>1395            <button1396              class="peach-shortcut-reset"1397              title="Reset to default"1398              onClick={() => onReset(desc.action)}1399            >1400              ↺1401            </button>1402          </Show>1403        </div>1404      );1405    }1406  }14071408  const PRO_FEATURES: Array<{1409    feature: ProFeature;1410    name: string;1411    desc: string;1412  }> = [1413    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1414    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1415    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1416    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1417    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1418  ];14191420  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14211422  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1423  const [deactivating, setDeactivating] = createSignal(false);14241425  async function handleDeactivateLicense() {1426    setDeactivating(true);1427    await sendMessage({ type: "DEACTIVATE_LICENSE" });1428    setLicenseInfo(null);1429    await props.onLicenseChange?.();1430    setDeactivating(false);1431  }14321433  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1434    setCanaryTestingId(entry.id);1435    setCanaryTestResult(null);1436    const res = await sendMessage({1437      type: "FIRE_CANARY_PING",1438      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1439    }) as { ok: boolean };1440    setCanaryTestResult({ id: entry.id, ok: res.ok });1441    setCanaryTestingId(null);1442    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1443  }14441445  function renderLicense() {1446    async function onActivate() {1447      await handleActivateLicense();1448      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1449        await props.onLicenseChange?.();1450      }1451    }14521453    return (1454      <div class="peach-settings-body">1455        <Show1456          when={isPro()}1457          fallback={1458            <Show1459              when={showKeyEntry()}1460              fallback={1461                <div class="peach-pro-upgrade">1462                  <div class="peach-pro-upgrade-hero">1463                    <div class="peach-pro-upgrade-mark-wrap">1464                      <PeachMark class="peach-pro-upgrade-mark" />1465                    </div>1466                    <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1467                    <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1468                  </div>14691470                  <div class="peach-pro-features">1471                    <For each={PRO_FEATURES}>1472                      {(item) => (1473                        <div1474                          class="peach-pro-feature"1475                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1476                        >1477                          <span class="peach-pro-feature-mark">✦</span>1478                          <div class="peach-pro-feature-text">1479                            <span class="peach-pro-feature-name">{item.name}</span>1480                            <span class="peach-pro-feature-desc">{item.desc}</span>1481                          </div>1482                        </div>1483                      )}1484                    </For>1485                  </div>14861487                  <div class="peach-pro-upgrade-actions">1488                    <p class="peach-pro-pricing">$39 · once · yours forever</p>1489                    <a1490                      href="https://peachpasswords.com/#pricing"1491                      target="_blank"1492                      rel="noopener noreferrer"1493                      class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1494                    >1495                      Get Peach Pro1496                    </a>1497                    <div class="peach-pro-key-divider"><span>or</span></div>1498                    <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1499                      Activate a license key1500                    </button>1501                  </div>1502                </div>1503              }1504            >1505              <div class="peach-pro-key-ceremony">1506                <LockRuneCanvas />1507                <p class="peach-pro-key-ceremony-label">Enter your license key</p>1508                <input1509                  class="peach-underline-input peach-pro-key-input"1510                  type="text"1511                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1512                  value={licenseKeyInput()}1513                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1514                  onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1515                  autocomplete="off"1516                  spellcheck={false}1517                />1518                <Show when={licenseError()}>1519                  <p class="peach-pro-key-error">That key doesn't look right.</p>1520                </Show>1521                <button1522                  class="peach-btn peach-btn--accent peach-pro-key-activate"1523                  onClick={() => void onActivate()}1524                  disabled={activatingLicense() || !licenseKeyInput().trim()}1525                >1526                  {activatingLicense() ? "activating…" : "Activate"}1527                </button>1528                <button1529                  class="peach-pro-key-cancel"1530                  onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1531                >1532                  cancel1533                </button>1534              </div>1535            </Show>1536          }1537        >1538          <div class="peach-pro-active">1539            <div class="peach-pro-active-hero">1540              <div class="peach-pro-active-mark-wrap">1541                <PeachMark class="peach-pro-active-mark" />1542              </div>1543              <h2 class="peach-pro-active-title">Peach Pro</h2>1544              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1545            </div>15461547            <Show when={licenseKey()}>1548              <div class="peach-settings-license">1549                <span class="peach-settings-label">LICENSE KEY</span>1550                <div class="peach-settings-license-row">1551                  <span class="peach-settings-license-key">{licenseKey()!}</span>1552                  <button1553                    class="peach-settings-license-copy"1554                    onClick={() => copyLicenseKey(licenseKey()!)}1555                  >1556                    {copied() ? "✓" : "copy"}1557                  </button>1558                </div>1559              </div>1560            </Show>15611562            <div class="peach-pro-active-features">1563              <For each={PRO_FEATURES}>1564                {(item) => (1565                  <div class="peach-pro-active-feature">1566                    <span class="peach-pro-active-feature-mark">✦</span>1567                    <span class="peach-pro-active-feature-name">{item.name}</span>1568                  </div>1569                )}1570              </For>1571            </div>15721573            <button1574              class="peach-pro-deactivate"1575              onClick={() => void handleDeactivateLicense()}1576              disabled={deactivating()}1577            >1578              {deactivating() ? "Deactivating…" : "Deactivate"}1579            </button>1580          </div>1581        </Show>1582      </div>1583    );1584  }15851586  function renderFeedback() {1587    return (1588      <div class="peach-settings-body">1589        <div class="peach-settings-group peach-feedback-group">1590          <p class="peach-feedback-intro">1591            Help us improve Peach. Every report goes directly to our inbox.1592          </p>15931594          <p class="peach-settings-sub-label">TYPE</p>1595          <div class="peach-feedback-chips">1596            <For each={FEEDBACK_CATEGORIES}>1597              {(cat) => (1598                <button1599                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1600                  onClick={() => setFbCategory(cat)}1601                >1602                  {cat}1603                </button>1604              )}1605            </For>1606          </div>16071608          <p class="peach-settings-sub-label">MESSAGE</p>1609          <textarea1610            class="peach-feedback-textarea"1611            placeholder="Describe the issue or idea..."1612            value={fbMessage()}1613            onInput={(e) => setFbMessage(e.currentTarget.value)}1614          />16151616          <p class="peach-feedback-meta">1617            Version {version} · will be included automatically1618          </p>16191620          <Show when={fbResult() === "success"}>1621            <p class="peach-feedback-result peach-feedback-result--ok">1622              Sent — thank you1623            </p>1624          </Show>1625          <Show when={fbResult() === "error"}>1626            <p class="peach-feedback-result peach-feedback-result--err">1627              Failed to send — try again1628            </p>1629          </Show>16301631          <button1632            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1633            onClick={() => void handleSubmitFeedback()}1634            disabled={fbSubmitting() || !fbMessage().trim()}1635          >1636            {fbSubmitting() ? "Sending..." : "Send feedback"}1637          </button>16381639          <p class="peach-feedback-anon">1640            Sent anonymously · no account required1641          </p>1642        </div>1643      </div>1644    );1645  }16461647  return (1648    <div class="peach-overlay-view ov-enter-right" style="display:flex">1649      <div class="peach-overlay-header">1650        <button1651          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1652          onClick={handleHeaderBack}1653          aria-label="Back"1654        >1655          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1656            <path d="M6 1L1 6L6 11" />1657          </svg>1658        </button>1659        <span class="peach-overlay-title">{currentTitle()}</span>1660        <div class="peach-overlay-spacer"></div>1661      </div>16621663      <Switch fallback={renderHome()}>1664        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1665        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1666        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1667        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1668        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1669        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1670        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1671      </Switch>1672    </div>1673  );1674}