Settings.tsx

55.0 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { ACCENT_COLORS } from "../../shared/types";4import type { AccentColorName, PasskeyProviderState } from "../../shared/types";5import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";6import {7  DEFAULT_BINDINGS,8  findConflict,9  formatKeybinding,10  keybindingFromEvent,11  SHORTCUT_DESCRIPTORS,12  type Keybinding,13  type ShortcutAction,14} from "../../shared/shortcuts";1516interface LicenseInfo {17  valid: boolean;18  plan: "free" | "pro";19  issued?: string;20}2122interface SettingsProps {23  entriesCount: number;24  breachMonitoring: boolean;25  lockOnClose: boolean;26  autoLockMinutes: number;27  trashRetentionDays: number;28  theme: "dark" | "light";29  accentColor: AccentColorName;30  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;31  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;32  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;33  onTrashRetentionChange: (days: number) => void | Promise<void>;34  onThemeChange: (theme: "dark" | "light") => void;35  onAccentChange: (name: AccentColorName) => void | Promise<void>;36  shortcuts: Record<ShortcutAction, Keybinding>;37  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;38  onBack: () => void;39  onImport: () => void;40  onExport: () => void;41  onBreachCheckDone: () => void;42  onOpenHealth?: () => void;43  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;44  onOpenCJKImport?: () => void;45  onOpenFileImport?: () => void;46}4748type SectionName =49  | "security"50  | "vault"51  | "sync"52  | "appearance"53  | "shortcuts"54  | "license"55  | "feedback";5657const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];5859export function SettingsView(props: SettingsProps) {60  const version = chrome.runtime.getManifest().version;61  const [activeSection, setActiveSection] = createSignal<SectionName | null>(null);6263  const [checkingBreaches, setCheckingBreaches] = createSignal(false);64  const [breachResult, setBreachResult] = createSignal<string | null>(null);65  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);66  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");67  const [licenseError, setLicenseError] = createSignal<string | null>(null);68  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);69  const [activatingLicense, setActivatingLicense] = createSignal(false);7071  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);72  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);73  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");74  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);75  const [isPro, setIsPro] = createSignal(false);76  const [syncingNow, setSyncingNow] = createSignal(false);77  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);78  const [passkeySaving, setPasskeySaving] = createSignal(false);7980  const [dedupCount, setDedupCount] = createSignal<number | null>(null);81  const [dedupRunning, setDedupRunning] = createSignal(false);82  const [dedupDone, setDedupDone] = createSignal<number | null>(null);8384  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);8586  const [showChangePw, setShowChangePw] = createSignal(false);87  const [changePwCurrent, setChangePwCurrent] = createSignal("");88  const [changePwNew, setChangePwNew] = createSignal("");89  const [changePwConfirm, setChangePwConfirm] = createSignal("");90  const [changePwSaving, setChangePwSaving] = createSignal(false);91  const [changePwError, setChangePwError] = createSignal<string | null>(null);92  const [changePwSuccess, setChangePwSuccess] = createSignal(false);9394  const [duressEnabled, setDuressEnabled] = createSignal(false);95  const [showDuressSetup, setShowDuressSetup] = createSignal(false);96  const [duressPassword, setDuressPassword] = createSignal("");97  const [duressConfirm, setDuressConfirm] = createSignal("");98  const [duressMainPassword, setDuressMainPassword] = createSignal("");99  const [duressSaving, setDuressSaving] = createSignal(false);100  const [duressError, setDuressError] = createSignal<string | null>(null);101  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);102103  const [fbCategory, setFbCategory] = createSignal("Bug report");104  const [fbMessage, setFbMessage] = createSignal("");105  const [fbSubmitting, setFbSubmitting] = createSignal(false);106  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);107108  onMount(async () => {109    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([110      sendMessage({ type: "GET_LICENSE" }),111      sendMessage({ type: "GET_SYNC_STATE" }),112      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),113      sendMessage({ type: "IS_DURESS_ENABLED" }),114      sendMessage({ type: "GET_SETTINGS" }),115    ]);116117    if ((licenseRes as { ok: boolean }).ok) {118      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);119    }120121    if ((syncRes as { ok: boolean }).ok) {122      const data = syncRes as { ok: boolean; data?: {123        pairedDevices?: PairedDevice[];124        trustedNetworks?: TrustedNetwork[];125        status?: SyncStatus;126        lastSyncAt?: number;127        isPro?: boolean;128      }};129      if (data.data) {130        setPairedDevices(data.data.pairedDevices ?? []);131        setTrustedNetworks(data.data.trustedNetworks ?? []);132        setSyncStatus(data.data.status ?? "offline");133        setLastSyncAt(data.data.lastSyncAt);134        setIsPro(data.data.isPro ?? false);135      }136    }137138    if ((passkeyRes as { ok: boolean }).ok) {139      setPasskeyState(140        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null141      );142    }143144    if ((duressRes as { ok: boolean }).ok) {145      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);146    }147148    if ((settingsRes as { ok: boolean }).ok) {149      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;150      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);151    }152153    chrome.runtime.onMessage.addListener((msg) => {154      if (msg.type === "SYNC_STATE_UPDATE") {155        setPairedDevices(msg.pairedDevices ?? []);156        setTrustedNetworks(msg.trustedNetworks ?? []);157        setSyncStatus(msg.status ?? "offline");158        setLastSyncAt(msg.lastSyncAt);159        setIsPro(msg.isPro ?? false);160      }161    });162  });163164  async function handleChangePassword() {165    setChangePwError(null);166    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }167    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }168    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }169    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }170    setChangePwSaving(true);171    const res = await sendMessage({172      type: "CHANGE_PASSWORD",173      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },174    }) as { ok: boolean; error?: string };175    if (res.ok) {176      setChangePwSuccess(true);177      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");178      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);179    } else {180      setChangePwError(res.error ?? "failed to change password");181    }182    setChangePwSaving(false);183  }184185  async function handleActivateLicense() {186    const key = licenseKeyInput().trim();187    if (!key) return;188    setActivatingLicense(true);189    setLicenseError(null);190    setLicenseSuccess(null);191    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });192    setActivatingLicense(false);193    if ((res as { ok: boolean }).ok) {194      const data = (res as { ok: boolean; data?: LicenseInfo }).data;195      if (data?.valid) {196        setLicenseInfo(data);197        setLicenseSuccess("License activated!");198        setLicenseKeyInput("");199      } else {200        setLicenseError("Invalid license key");201      }202    } else {203      setLicenseError("Activation failed");204    }205  }206207  async function handleTogglePasskeys() {208    const current = passkeyState();209    if (!current || !current.supported) return;210    setPasskeySaving(true);211    const res = await sendMessage({212      type: "SET_PASSKEY_PROVIDER_ENABLED",213      payload: { enabled: !current.enabled },214    });215    setPasskeySaving(false);216    if ((res as { ok: boolean }).ok) {217      setPasskeyState(218        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null219      );220    }221  }222223  async function handleCheckAllBreaches() {224    setCheckingBreaches(true);225    setBreachResult(null);226    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });227    setCheckingBreaches(false);228    if ((res as { ok: boolean }).ok) {229      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};230      const breached = Object.values(data).filter((count) => count > 0).length;231      setBreachResult(232        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"233      );234      props.onBreachCheckDone();235    }236  }237238  async function handleSyncNow() {239    if (syncStatus() === "not_trusted") return;240    setSyncingNow(true);241    await sendMessage({ type: "TRIGGER_SYNC" });242    setTimeout(() => setSyncingNow(false), 3000);243  }244245  async function handleRemoveDevice(deviceId: string) {246    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });247  }248249  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {250    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });251  }252253  async function handleMarkNetworkTrusted() {254    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });255  }256257  function formatRelativeTime(timestamp?: number): string {258    if (!timestamp) return "Never";259    const diff = Date.now() - timestamp;260    const minutes = Math.floor(diff / 60000);261    const hours = Math.floor(diff / 3600000);262    const days = Math.floor(diff / 86400000);263    if (minutes < 1) return "Just now";264    if (minutes < 60) return `${minutes}m ago`;265    if (hours < 24) return `${hours}h ago`;266    return `${days}d ago`;267  }268269  function getStatusColor(status: SyncStatus): string {270    switch (status) {271      case "synced": return "var(--peach-sync-ok)";272      case "syncing": return "var(--peach-sync-busy)";273      case "no_peers": return "var(--peach-sync-off)";274      case "not_trusted": return "var(--peach-sync-err)";275      case "offline": return "var(--peach-sync-off)";276      default: return "var(--peach-sync-off)";277    }278  }279280  function getStatusLabel(status: SyncStatus): string {281    switch (status) {282      case "synced": return "Synced";283      case "syncing": return "Syncing";284      case "no_peers": return "No peers visible";285      case "not_trusted": return "Not on a trusted network";286      case "offline": return "Offline";287      default: return "Unknown";288    }289  }290291  function isCurrentNetworkTrusted(): boolean {292    return trustedNetworks().length > 0;293  }294295  async function handleSetupDuress() {296    const mainPw = duressMainPassword().trim();297    const duressPw = duressPassword().trim();298    const confirmPw = duressConfirm().trim();299300    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }301    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }302    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }303    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }304305    setDuressSaving(true);306    setDuressError(null);307308    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };309    if (!verifyRes.ok || !verifyRes.data) {310      setDuressError("incorrect main password");311      setDuressSaving(false);312      return;313    }314315    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };316    setDuressSaving(false);317    if (res.ok) {318      setDuressEnabled(true);319      setShowDuressSetup(false);320      setDuressPassword("");321      setDuressConfirm("");322      setDuressMainPassword("");323      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");324      setTimeout(() => setDuressSuccess(null), 5000);325    } else {326      setDuressError(res.error ?? "failed to set duress password");327    }328  }329330  async function handleClearDeclinedCards() {331    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });332    setDeclinedCardCount(0);333  }334335  async function handleCountDuplicates() {336    setDedupRunning(true);337    setDedupDone(null);338    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };339    setDedupRunning(false);340    if (res.ok) setDedupCount(res.data ?? 0);341  }342343  async function handleDeduplicate() {344    setDedupRunning(true);345    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };346    setDedupRunning(false);347    if (res.ok) {348      setDedupDone(res.data ?? 0);349      setDedupCount(0);350    }351  }352353  async function handleDisableDuress() {354    const mainPw = duressMainPassword().trim();355    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }356357    setDuressSaving(true);358    setDuressError(null);359360    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };361    if (!verifyRes.ok || !verifyRes.data) {362      setDuressError("incorrect main password");363      setDuressSaving(false);364      return;365    }366367    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };368    setDuressSaving(false);369    if (res.ok) {370      setDuressEnabled(false);371      setShowDuressSetup(false);372      setDuressMainPassword("");373      setDuressSuccess("Duress password disabled.");374      setTimeout(() => setDuressSuccess(null), 4000);375    } else {376      setDuressError(res.error ?? "failed to disable duress password");377    }378  }379380  async function handleSubmitFeedback() {381    if (!fbMessage().trim() || fbSubmitting()) return;382    setFbSubmitting(true);383    setFbResult(null);384    const res = await sendMessage({385      type: "SUBMIT_FEEDBACK",386      payload: {387        category: fbCategory(),388        message: fbMessage().trim(),389        version,390        browser: navigator.userAgent,391      },392    }) as { ok: boolean; error?: string };393    setFbSubmitting(false);394    if (res.ok) {395      setFbResult("success");396      setFbMessage("");397      setTimeout(() => setFbResult(null), 4000);398    } else {399      setFbResult("error");400    }401  }402403  function handleHeaderBack() {404    if (activeSection() !== null) {405      setActiveSection(null);406    } else {407      props.onBack();408    }409  }410411  function currentTitle(): string {412    const s = activeSection();413    if (s === null) return "settings";414    if (s === "license") return "account";415    return s;416  }417418  function renderCatRow(opts: {419    section: SectionName;420    name: string;421    desc: string;422    icon: () => any;423    pro?: boolean;424  }) {425    return (426      <button427        class="peach-settings-cat-row"428        onClick={() => setActiveSection(opts.section)}429      >430        <div class="peach-settings-cat-icon">{opts.icon()}</div>431        <div class="peach-settings-cat-content">432          <span class="peach-settings-cat-name">433            {opts.name}434            <Show when={opts.pro && !isPro()}>435              <span class="peach-sync-pro-badge" style="margin-left:6px">Pro</span>436            </Show>437          </span>438          <span class="peach-settings-cat-desc">{opts.desc}</span>439        </div>440        <svg441          class="peach-settings-cat-chevron"442          width="6" height="10" viewBox="0 0 6 10" fill="none"443          stroke="currentColor" stroke-width="1.1"444          stroke-linecap="round" stroke-linejoin="round"445        >446          <path d="M1 1l4 4-4 4" />447        </svg>448      </button>449    );450  }451452  const iconShield = () => (453    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">454      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>455    </svg>456  );457  const iconKey = () => (458    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">459      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>460    </svg>461  );462  const iconLock = () => (463    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">464      <rect x="3" y="7" width="10" height="7" rx="1.5"/>465      <path d="M6 7V5a3 3 0 016 0v2"/>466    </svg>467  );468  const iconSync = () => (469    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">470      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>471      <path d="M14 6l-2 2-2-2"/>472      <path d="M2 10l2-2 2 2"/>473    </svg>474  );475  const iconSun = () => (476    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">477      <circle cx="8" cy="8" r="3"/>478      <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"/>479    </svg>480  );481  const iconKeys = () => (482    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">483      <rect x="2" y="5" width="5" height="4" rx="1"/>484      <rect x="9" y="7" width="5" height="4" rx="1"/>485      <path d="M7 7h2m-2 2h2"/>486    </svg>487  );488  const iconBubble = () => (489    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">490      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>491    </svg>492  );493494  function renderHome() {495    return (496      <div class="peach-settings-body">497        <p class="peach-settings-nav-label">PROTECTION</p>498        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}499        {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}500501        <p class="peach-settings-nav-label">VAULT</p>502        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}503        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}504505        <p class="peach-settings-nav-label">PREFERENCES</p>506        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}507        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}508509        <p class="peach-settings-nav-label">SUPPORT</p>510        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}511512        <div class="peach-settings-footer">513          <span>Peach {version}</span>514          <span class="peach-divider"></span>515          <span>Browser extension</span>516        </div>517      </div>518    );519  }520521  function renderSecurity() {522    return (523      <div class="peach-settings-body">524        <div class="peach-settings-group">525          <p class="peach-settings-sub-label">LOCKING</p>526          <div class="peach-settings-row">527            <span>Lock on close</span>528            <button529              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}530              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}531              aria-label="Toggle lock on close"532            >533              <span class="peach-toggle-knob"></span>534            </button>535          </div>536          <div class="peach-settings-row">537            <span>Auto-lock after</span>538            <select539              class="peach-settings-select"540              value={String(props.autoLockMinutes)}541              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}542            >543              <option value="1">1 minute</option>544              <option value="5">5 minutes</option>545              <option value="15">15 minutes</option>546              <option value="30">30 minutes</option>547              <option value="0">Never</option>548            </select>549          </div>550551          <p class="peach-settings-sub-label">BREACH PROTECTION</p>552          <div class="peach-settings-row">553            <span>Breach monitoring</span>554            <button555              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}556              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}557              aria-label="Toggle breach monitoring"558            >559              <span class="peach-toggle-knob"></span>560            </button>561          </div>562          <div class="peach-settings-row peach-settings-row--meta">563            <span>Check all passwords</span>564            <button565              class="peach-btn peach-btn--subtle peach-btn--sm"566              onClick={() => void handleCheckAllBreaches()}567              disabled={checkingBreaches()}568            >569              {checkingBreaches() ? "checking..." : "run"}570            </button>571          </div>572          <Show when={breachResult()}>573            <div class="peach-settings-row peach-settings-row--meta">574              <span>Last check</span>575              <span class="peach-settings-value">{breachResult()}</span>576            </div>577          </Show>578579          <p class="peach-settings-sub-label">MASTER PASSWORD</p>580          <div class="peach-settings-row peach-settings-row--meta">581            <span>Master password</span>582            <button583              class="peach-btn peach-btn--subtle peach-btn--sm"584              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}585            >586              {showChangePw() ? "cancel" : "change"}587            </button>588          </div>589          <Show when={showChangePw()}>590            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">591              <input592                class="peach-settings-input"593                type="password"594                placeholder="current password"595                value={changePwCurrent()}596                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}597                autocomplete="current-password"598              />599              <input600                class="peach-settings-input"601                type="password"602                placeholder="new password (min 12 chars)"603                value={changePwNew()}604                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}605                autocomplete="new-password"606              />607              <input608                class="peach-settings-input"609                type="password"610                placeholder="confirm new password"611                value={changePwConfirm()}612                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}613                autocomplete="new-password"614                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}615              />616              <Show when={changePwError()}>617                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>618              </Show>619              <Show when={changePwSuccess()}>620                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>621              </Show>622              <button623                class="peach-btn peach-btn--accent peach-btn--sm"624                style="align-self:flex-start"625                onClick={() => void handleChangePassword()}626                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}627              >628                {changePwSaving() ? "saving…" : "save new password"}629              </button>630            </div>631          </Show>632633          <p class="peach-settings-sub-label">DURESS PASSWORD</p>634          <div class="peach-settings-row peach-settings-row--top">635            <div class="peach-settings-row-main">636              <span>Duress password</span>637              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">638                If entered under coercion, shows a decoy empty vault instead of your real one.639              </span>640            </div>641            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>642              {duressEnabled() ? "on" : "off"}643            </div>644          </div>645646          <Show when={duressEnabled() && !showDuressSetup()}>647            <div class="peach-settings-row peach-settings-row--meta">648              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">649                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.650              </span>651            </div>652          </Show>653654          <Show when={duressSuccess()}>655            <div class="peach-settings-row peach-settings-row--meta">656              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>657            </div>658          </Show>659660          <Show when={!showDuressSetup()}>661            <div class="peach-settings-row peach-settings-row--meta">662              <button663                class="peach-btn peach-btn--subtle peach-btn--sm"664                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}665              >666                {duressEnabled() ? "Change / disable" : "Set up"}667              </button>668            </div>669          </Show>670671          <Show when={showDuressSetup()}>672            <div class="peach-duress-form">673              <label class="peach-label">main password (to confirm)</label>674              <input675                class="peach-settings-input"676                type="password"677                placeholder="your current master password"678                autocomplete="current-password"679                value={duressMainPassword()}680                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}681              />682              <Show when={!duressEnabled()}>683                <label class="peach-label">new duress password</label>684                <input685                  class="peach-settings-input"686                  type="password"687                  placeholder="duress password (min 8 chars)"688                  autocomplete="new-password"689                  value={duressPassword()}690                  onInput={(e) => setDuressPassword(e.currentTarget.value)}691                />692                <label class="peach-label">confirm duress password</label>693                <input694                  class="peach-settings-input"695                  type="password"696                  placeholder="confirm duress password"697                  autocomplete="new-password"698                  value={duressConfirm()}699                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}700                />701              </Show>702703              <Show when={duressError()}>704                <p class="peach-hint peach-hint-error">{duressError()}</p>705              </Show>706707              <div class="peach-duress-actions">708                <button709                  class="peach-btn peach-btn--subtle peach-btn--sm"710                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}711                >712                  cancel713                </button>714                <Show when={duressEnabled()}>715                  <button716                    class="peach-btn peach-btn--ghost peach-btn--sm"717                    style="color:var(--peach-breach)"718                    onClick={() => void handleDisableDuress()}719                    disabled={duressSaving()}720                  >721                    {duressSaving() ? "disabling..." : "disable duress"}722                  </button>723                </Show>724                <Show when={!duressEnabled()}>725                  <button726                    class="peach-btn peach-btn--accent peach-btn--sm"727                    onClick={() => void handleSetupDuress()}728                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}729                  >730                    {duressSaving() ? "saving..." : "save"}731                  </button>732                </Show>733              </div>734            </div>735          </Show>736737          <p class="peach-settings-sub-label">PASSKEYS</p>738          <div class="peach-settings-row peach-settings-row--top">739            <div class="peach-settings-row-main">740              <span>Intercept passkey requests</span>741              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">742                Peach can handle passkey sign-in and creation on supported Chromium browsers.743              </span>744            </div>745            <button746              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}747              onClick={() => void handleTogglePasskeys()}748              aria-label="Toggle passkey interception"749              disabled={!passkeyState()?.supported || passkeySaving()}750            >751              <span class="peach-toggle-knob"></span>752            </button>753          </div>754          <div class="peach-settings-row peach-settings-row--meta">755            <span>Status</span>756            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>757          </div>758          <div class="peach-settings-row peach-settings-row--meta">759            <span>Browser</span>760            <span class="peach-settings-value">761              {passkeyState()?.browserLabel ?? "Unknown"}762              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}763            </span>764          </div>765          <div class="peach-info-notice" style="margin-top:8px">766            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.767          </div>768          <Show when={passkeyState()?.conflict}>769            <div class="peach-settings-row peach-settings-row--meta">770              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>771            </div>772          </Show>773        </div>774      </div>775    );776  }777778  function renderVault() {779    return (780      <div class="peach-settings-body">781        <div class="peach-settings-group">782          <p class="peach-settings-sub-label">ENTRIES</p>783          <div class="peach-settings-row peach-settings-row--meta">784            <span>Entries</span>785            <span class="peach-settings-value">{props.entriesCount}</span>786          </div>787          <div class="peach-settings-row peach-settings-row--meta">788            <span>Duplicates</span>789            <Show when={dedupDone() !== null} fallback={790              <button791                class="peach-btn peach-btn--subtle peach-btn--sm"792                onClick={() => void handleCountDuplicates()}793                disabled={dedupRunning()}794              >795                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}796              </button>797            }>798              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">799                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}800              </span>801            </Show>802          </div>803          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>804            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">805              <div class="peach-restore-warning">806                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.807              </div>808              <div style="display:flex;gap:6px">809                <button810                  class="peach-btn peach-btn--subtle peach-btn--sm"811                  onClick={() => setDedupCount(null)}812                >813                  cancel814                </button>815                <button816                  class="peach-btn peach-btn--accent peach-btn--sm"817                  onClick={() => void handleDeduplicate()}818                  disabled={dedupRunning()}819                >820                  {dedupRunning() ? "removing..." : "remove duplicates"}821                </button>822              </div>823            </div>824          </Show>825826          <p class="peach-settings-sub-label">MAINTENANCE</p>827          <div class="peach-settings-row peach-settings-row--meta">828            <span>Trash auto-delete</span>829            <select830              class="peach-settings-select"831              value={String(props.trashRetentionDays)}832              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}833            >834              <option value="7">7 days</option>835              <option value="30">30 days</option>836              <option value="90">90 days</option>837              <option value="0">Never</option>838            </select>839          </div>840          <div class="peach-settings-row peach-settings-row--meta">841            <span>Vault Health</span>842            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>843              open844            </button>845          </div>846          <Show when={declinedCardCount() > 0}>847            <div class="peach-settings-row peach-settings-row--meta">848              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>849              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>850                Clear851              </button>852            </div>853          </Show>854855          <p class="peach-settings-sub-label">DATA</p>856          <div class="peach-settings-row peach-settings-row--meta">857            <span>Import passwords</span>858            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>859              open860            </button>861          </div>862          <div class="peach-settings-row peach-settings-row--meta">863            <span>Export backup</span>864            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>865              open866            </button>867          </div>868          <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">869            <span>Replace vault from backup</span>870            <div class="peach-restore-warning" style="margin-top:2px">871              This will permanently replace your current vault with the backup.872            </div>873            <div style="display:flex;gap:6px">874              <button875                class="peach-btn peach-btn--subtle peach-btn--sm"876                onClick={() => props.onOpenCJKImport?.()}877              >878                CJK phrase879              </button>880              <button881                class="peach-btn peach-btn--subtle peach-btn--sm"882                onClick={() => props.onOpenFileImport?.()}883              >884                backup file885              </button>886            </div>887          </div>888        </div>889      </div>890    );891  }892893  function renderSync() {894    if (!isPro()) {895      return (896        <div class="peach-settings-body">897          <div class="peach-settings-group" style="position:relative">898            <div class="peach-sync-pro-gate">899              <div class="peach-sync-pro-gate-blur">900                <div class="peach-settings-row">901                  <span>Status</span>902                  <span class="peach-settings-value peach-settings-value--status">903                    <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>904                    {getStatusLabel(syncStatus())}905                  </span>906                </div>907                <div class="peach-settings-row peach-settings-row--meta">908                  <span>Last sync</span>909                  <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>910                </div>911              </div>912              <div class="peach-sync-pro-gate-overlay">913                <span class="peach-sync-pro-badge">Pro</span>914                <a href="https://peachpasswords.com/#pricing" target="_blank" rel="noopener noreferrer" class="peach-link" style="color:var(--peach-accent);font-size:13px;font-weight:500">915                  Upgrade to Pro →916                </a>917              </div>918            </div>919          </div>920        </div>921      );922    }923924    return (925      <div class="peach-settings-body">926        <div class="peach-settings-group">927          <div class="peach-settings-row">928            <span>Status</span>929            <span class="peach-settings-value peach-settings-value--status">930              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>931              {getStatusLabel(syncStatus())}932            </span>933          </div>934          <div class="peach-settings-row peach-settings-row--meta">935            <span>Last sync</span>936            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>937          </div>938          <div class="peach-settings-row peach-settings-row--meta">939            <button940              class="peach-btn peach-btn--subtle peach-btn--sm"941              onClick={() => void handleSyncNow()}942              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}943            >944              {syncingNow() ? "Syncing..." : "Sync now"}945            </button>946          </div>947948          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">949            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>950            {pairedDevices().map((device) => (951              <div class="peach-sync-device-row">952                <div class="peach-sync-device-info">953                  <span class="peach-sync-device-name">{device.displayName}</span>954                  <span class="peach-sync-device-meta">955                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}956                  </span>957                </div>958                <button959                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"960                  onClick={() => void handleRemoveDevice(device.deviceId)}961                >962                  Remove963                </button>964              </div>965            ))}966            <div class="peach-settings-actions--row">967              <button968                class="peach-btn peach-btn--subtle peach-btn--sm"969                onClick={() => props.onOpenSyncPairing?.("initiate")}970              >971                Pair a new device972              </button>973              <button974                class="peach-btn peach-btn--subtle peach-btn--sm"975                onClick={() => props.onOpenSyncPairing?.("accept")}976              >977                Pair with another device978              </button>979            </div>980          </div>981982          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">983            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>984            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (985              <div class="peach-sync-device-row">986                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>987                <button988                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"989                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}990                >991                  Remove992                </button>993              </div>994            ))}995            <div class="peach-settings-actions--row">996              <Show when={!isCurrentNetworkTrusted()}>997                <button998                  class="peach-btn peach-btn--subtle peach-btn--sm"999                  onClick={() => void handleMarkNetworkTrusted()}1000                >1001                  Mark current network as trusted1002                </button>1003              </Show>1004            </div>1005            <span class="peach-sync-device-meta">1006              Tailscale is always trusted1007            </span>1008          </div>1009        </div>1010      </div>1011    );1012  }10131014  function renderAppearance() {1015    return (1016      <div class="peach-settings-body">1017        <div class="peach-settings-group">1018          <p class="peach-settings-sub-label">THEME</p>1019          <div class="peach-settings-row peach-settings-row--meta">1020            <span>Theme</span>1021            <button1022              class="peach-btn peach-btn--subtle peach-btn--sm"1023              onClick={() =>1024                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1025              }1026            >1027              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1028            </button>1029          </div>10301031          <p class="peach-settings-sub-label">ACCENT</p>1032          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1033            <span>Accent</span>1034            <div class="peach-accent-picker">1035              <For each={ACCENT_COLORS}>1036                {(color) => (1037                  <button1038                    class="peach-accent-swatch"1039                    classList={{ "is-active": props.accentColor === color.name }}1040                    style={{ background: color.hex }}1041                    onClick={() => void props.onAccentChange(color.name)}1042                    aria-label={color.name}1043                    title={color.name}1044                  />1045                )}1046              </For>1047            </div>1048          </div>1049        </div>1050      </div>1051    );1052  }10531054  function renderShortcuts() {1055    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1056    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);10571058    function startCapture(action: ShortcutAction) {1059      setConflictMsg(null);1060      setCapturingAction(action);1061    }1062    function cancelCapture() {1063      setCapturingAction(null);1064      setConflictMsg(null);1065    }1066    function commitBinding(action: ShortcutAction, binding: Keybinding) {1067      const conflict = findConflict(props.shortcuts, binding, action);1068      if (conflict) {1069        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1070        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1071        return;1072      }1073      const next = { ...props.shortcuts, [action]: binding };1074      setCapturingAction(null);1075      setConflictMsg(null);1076      void props.onShortcutsChange(next);1077    }1078    function resetAction(action: ShortcutAction) {1079      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1080      void props.onShortcutsChange(next);1081    }1082    function resetAll() {1083      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1084    }10851086    // Capture-mode key handler — wired via ref onto the chip element so it1087    // catches the next non-modifier keypress and ignores other bindings.1088    function handleCaptureKey(e: KeyboardEvent) {1089      e.preventDefault();1090      e.stopPropagation();1091      if (e.key === "Escape") { cancelCapture(); return; }1092      const binding = keybindingFromEvent(e);1093      if (!binding) return; // bare modifier — keep waiting1094      const action = capturingAction();1095      if (action) commitBinding(action, binding);1096    }10971098    return (1099      <div class="peach-settings-body">1100        <div class="peach-settings-group peach-shortcuts-group">1101          <p class="peach-settings-sub-label">ENTRY VIEW</p>1102          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1103            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1104          </For>1105        </div>11061107        <div class="peach-settings-group peach-shortcuts-group">1108          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1109          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1110            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1111          </For>1112        </div>11131114        <Show when={conflictMsg()}>1115          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1116        </Show>11171118        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1119          <div class="peach-settings-row peach-settings-row--meta">1120            <span>Inline autofill on page</span>1121            <a1122              class="peach-settings-link"1123              href="chrome://extensions/shortcuts"1124              onClick={(e) => {1125                e.preventDefault();1126                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1127              }}1128            >1129              ⌘⇧P — customize ↗1130            </a>1131          </div>1132          <button1133            class="peach-btn peach-btn--subtle peach-btn--sm"1134            style="align-self:flex-start"1135            onClick={resetAll}1136          >1137            Reset all to defaults1138          </button>1139        </div>1140      </div>1141    );11421143    function renderRow(1144      desc: typeof SHORTCUT_DESCRIPTORS[number],1145      capturing: () => ShortcutAction | null,1146      onStart: (a: ShortcutAction) => void,1147      onCancel: () => void,1148      onReset: (a: ShortcutAction) => void,1149      onKey: (e: KeyboardEvent) => void,1150    ) {1151      const isActive = () => capturing() === desc.action;1152      const binding = () => props.shortcuts[desc.action];1153      const isCustom = () => {1154        const b = binding();1155        const d = DEFAULT_BINDINGS[desc.action];1156        return b.key !== d.key1157          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1158          || Boolean(b.meta)  !== Boolean(d.meta)1159          || Boolean(b.shift) !== Boolean(d.shift)1160          || Boolean(b.alt)   !== Boolean(d.alt);1161      };1162      return (1163        <div class="peach-shortcut-row">1164          <span class="peach-shortcut-row-label">{desc.label}</span>1165          <button1166            class="peach-shortcut-chip"1167            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1168            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1169            onKeyDown={(e) => { if (isActive()) onKey(e); }}1170            ref={(el) => {1171              // Auto-focus when entering capture mode so the keydown listener catches the next press.1172              if (el) {1173                queueMicrotask(() => { if (isActive()) el.focus(); });1174              }1175            }}1176          >1177            {isActive() ? "Press a key…" : formatKeybinding(binding())}1178          </button>1179          <Show when={isCustom() && !isActive()}>1180            <button1181              class="peach-shortcut-reset"1182              title="Reset to default"1183              onClick={() => onReset(desc.action)}1184            >1185              ↺1186            </button>1187          </Show>1188        </div>1189      );1190    }1191  }11921193  function renderLicense() {1194    return (1195      <div class="peach-settings-body">1196        <div class="peach-settings-group">1197          <p class="peach-settings-sub-label">PLAN</p>1198          <Show1199            when={licenseInfo()?.valid && licenseInfo()?.plan === "pro"}1200            fallback={1201              <>1202                <div class="peach-pro-promo">1203                  <div class="peach-pro-promo-glow" aria-hidden="true"></div>1204                  <div class="peach-pro-promo-inner">1205                    <h3 class="peach-pro-promo-title">Peach Pro</h3>1206                    <p class="peach-pro-promo-tagline">One-time purchase. Yours forever.</p>1207                    <ul class="peach-pro-promo-list">1208                      <li>1209                        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1210                          <path d="M3 8a5 5 0 0 1 9-3" />1211                          <path d="M13 8a5 5 0 0 1-9 3" />1212                          <path d="M12 2v3h-3" />1213                          <path d="M4 14v-3h3" />1214                        </svg>1215                        <span>Sync across every device, end-to-end encrypted</span>1216                      </li>1217                      <li>1218                        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1219                          <circle cx="6" cy="9" r="3" />1220                          <path d="M9 9h5" />1221                          <path d="M12 9v2.5" />1222                          <path d="M14 9v2" />1223                        </svg>1224                        <span>Create and store passkeys directly in Peach</span>1225                      </li>1226                      <li>1227                        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1228                          <path d="M8 2.5l1.6 3.4 3.7.4-2.8 2.5.8 3.7L8 10.7l-3.3 1.8.8-3.7L2.7 6.3l3.7-.4z" />1229                        </svg>1230                        <span>Every future Pro feature, no extra cost</span>1231                      </li>1232                    </ul>1233                    <a1234                      href="https://peachpasswords.com/#pricing"1235                      target="_blank"1236                      rel="noopener noreferrer"1237                      class="peach-btn peach-pro-promo-cta"1238                    >1239                      Upgrade to Pro →1240                    </a>1241                  </div>1242                </div>1243                <p class="peach-pro-or-divider"><span>or activate a key</span></p>1244                <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">1245                  <input1246                    class="peach-underline-input"1247                    type="text"1248                    placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1249                    value={licenseKeyInput()}1250                    onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1251                    style="width:100%;box-sizing:border-box"1252                  />1253                  <button1254                    class="peach-btn peach-btn--subtle peach-btn--sm"1255                    onClick={() => void handleActivateLicense()}1256                    disabled={activatingLicense() || !licenseKeyInput().trim()}1257                  >1258                    {activatingLicense() ? "activating..." : "Activate"}1259                  </button>1260                </div>1261                <Show when={licenseError()}>1262                  <div class="peach-settings-row peach-settings-row--meta">1263                    <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>1264                  </div>1265                </Show>1266                <Show when={licenseSuccess()}>1267                  <div class="peach-settings-row peach-settings-row--meta">1268                    <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>1269                  </div>1270                </Show>1271              </>1272            }1273          >1274            <div class="peach-settings-row peach-settings-row--meta">1275              <span>Peach Pro</span>1276              <span class="peach-settings-value">1277                activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}1278              </span>1279            </div>1280          </Show>1281        </div>1282      </div>1283    );1284  }12851286  function renderFeedback() {1287    return (1288      <div class="peach-settings-body">1289        <div class="peach-settings-group peach-feedback-group">1290          <p class="peach-feedback-intro">1291            Help us improve Peach. Every report goes directly to our inbox.1292          </p>12931294          <p class="peach-settings-sub-label">TYPE</p>1295          <div class="peach-feedback-chips">1296            <For each={FEEDBACK_CATEGORIES}>1297              {(cat) => (1298                <button1299                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1300                  onClick={() => setFbCategory(cat)}1301                >1302                  {cat}1303                </button>1304              )}1305            </For>1306          </div>13071308          <p class="peach-settings-sub-label">MESSAGE</p>1309          <textarea1310            class="peach-feedback-textarea"1311            placeholder="Describe the issue or idea..."1312            value={fbMessage()}1313            onInput={(e) => setFbMessage(e.currentTarget.value)}1314          />13151316          <p class="peach-feedback-meta">1317            Version {version} · will be included automatically1318          </p>13191320          <Show when={fbResult() === "success"}>1321            <p class="peach-feedback-result peach-feedback-result--ok">1322              Sent — thank you1323            </p>1324          </Show>1325          <Show when={fbResult() === "error"}>1326            <p class="peach-feedback-result peach-feedback-result--err">1327              Failed to send — try again1328            </p>1329          </Show>13301331          <button1332            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1333            onClick={() => void handleSubmitFeedback()}1334            disabled={fbSubmitting() || !fbMessage().trim()}1335          >1336            {fbSubmitting() ? "Sending..." : "Send feedback"}1337          </button>13381339          <p class="peach-feedback-anon">1340            Sent anonymously · no account required1341          </p>1342        </div>1343      </div>1344    );1345  }13461347  return (1348    <div class="peach-overlay-view ov-enter-right" style="display:flex">1349      <div class="peach-overlay-header">1350        <button1351          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1352          onClick={handleHeaderBack}1353          aria-label="Back"1354        >1355          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1356            <path d="M6 1L1 6L6 11" />1357          </svg>1358        </button>1359        <span class="peach-overlay-title">{currentTitle()}</span>1360        <div class="peach-overlay-spacer"></div>1361      </div>13621363      <Switch fallback={renderHome()}>1364        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1365        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1366        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1367        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1368        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1369        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1370        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1371      </Switch>1372    </div>1373  );1374}