Settings.tsx

61.7 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { ACCENT_COLORS } from "../../shared/types";5import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";6import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";7import {8  DEFAULT_BINDINGS,9  findConflict,10  formatKeybinding,11  keybindingFromEvent,12  SHORTCUT_DESCRIPTORS,13  type Keybinding,14  type ShortcutAction,15} from "../../shared/shortcuts";1617interface LicenseInfo {18  valid: boolean;19  plan: "free" | "pro";20  issued?: string;21}2223interface SettingsProps {24  entriesCount: number;25  breachMonitoring: boolean;26  lockOnClose: boolean;27  autoLockMinutes: number;28  trashRetentionDays: number;29  theme: "dark" | "light";30  accentColor: AccentColorName;31  displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";32  textSize: "small" | "medium" | "large";33  reduceMotion: boolean;34  isPro?: boolean;35  initialSection?: string | null;36  upgradeHighlight?: ProFeature | null;37  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;38  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;39  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;40  onTrashRetentionChange: (days: number) => void | Promise<void>;41  onThemeChange: (theme: "dark" | "light") => void;42  onAccentChange: (name: AccentColorName) => void | Promise<void>;43  onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;44  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;45  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;46  shortcuts: Record<ShortcutAction, Keybinding>;47  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;48  onLicenseChange?: () => void | Promise<void>;49  onBack: () => void;50  onImport: () => void;51  onExport: () => void;52  onBreachCheckDone: () => void;53  onOpenHealth?: () => void;54  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;55  onOpenCJKImport?: () => void;56  onOpenFileImport?: () => void;57}5859type SectionName =60  | "security"61  | "vault"62  | "sync"63  | "appearance"64  | "shortcuts"65  | "license"66  | "feedback";6768const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];6970function formatActivatedSince(issued: string | undefined): string {71  if (!issued) return "Active";72  try {73    const d = new Date(issued);74    if (Number.isNaN(d.getTime())) return "Active";75    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;76  } catch {77    return "Active";78  }79}8081function isSectionName(value: string): value is SectionName {82  return (83    value === "security" ||84    value === "vault" ||85    value === "sync" ||86    value === "appearance" ||87    value === "shortcuts" ||88    value === "license" ||89    value === "feedback"90  );91}9293export function SettingsView(props: SettingsProps) {94  const version = chrome.runtime.getManifest().version;95  const initial = props.initialSection;96  const [activeSection, setActiveSection] = createSignal<SectionName | null>(97    initial && isSectionName(initial) ? (initial as SectionName) : null98  );99100  const [checkingBreaches, setCheckingBreaches] = createSignal(false);101  const [breachResult, setBreachResult] = createSignal<string | null>(null);102  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);103  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");104  const [licenseError, setLicenseError] = createSignal<string | null>(null);105  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);106  const [activatingLicense, setActivatingLicense] = createSignal(false);107108  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);109  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);110  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");111  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);112  // Prefer the prop (cached at App boot), fall back to the sync-state probe.113  // The prop is authoritative once present so license activation reflects114  // immediately without waiting for a sync round-trip.115  const [syncProStatus, setSyncProStatus] = createSignal(false);116  const isPro = () => props.isPro ?? syncProStatus();117  const [syncingNow, setSyncingNow] = createSignal(false);118  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);119  const [passkeySaving, setPasskeySaving] = createSignal(false);120121  const [dedupCount, setDedupCount] = createSignal<number | null>(null);122  const [dedupRunning, setDedupRunning] = createSignal(false);123  const [dedupDone, setDedupDone] = createSignal<number | null>(null);124125  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);126127  const [showChangePw, setShowChangePw] = createSignal(false);128  const [changePwCurrent, setChangePwCurrent] = createSignal("");129  const [changePwNew, setChangePwNew] = createSignal("");130  const [changePwConfirm, setChangePwConfirm] = createSignal("");131  const [changePwSaving, setChangePwSaving] = createSignal(false);132  const [changePwError, setChangePwError] = createSignal<string | null>(null);133  const [changePwSuccess, setChangePwSuccess] = createSignal(false);134135  const [duressEnabled, setDuressEnabled] = createSignal(false);136  const [showDuressSetup, setShowDuressSetup] = createSignal(false);137  const [duressPassword, setDuressPassword] = createSignal("");138  const [duressConfirm, setDuressConfirm] = createSignal("");139  const [duressMainPassword, setDuressMainPassword] = createSignal("");140  const [duressSaving, setDuressSaving] = createSignal(false);141  const [duressError, setDuressError] = createSignal<string | null>(null);142  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);143144  const [fbCategory, setFbCategory] = createSignal("Bug report");145  const [fbMessage, setFbMessage] = createSignal("");146  const [fbSubmitting, setFbSubmitting] = createSignal(false);147  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);148149  onMount(async () => {150    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([151      sendMessage({ type: "GET_LICENSE" }),152      sendMessage({ type: "GET_SYNC_STATE" }),153      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),154      sendMessage({ type: "IS_DURESS_ENABLED" }),155      sendMessage({ type: "GET_SETTINGS" }),156    ]);157158    if ((licenseRes as { ok: boolean }).ok) {159      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);160    }161162    if ((syncRes as { ok: boolean }).ok) {163      const data = syncRes as { ok: boolean; data?: {164        pairedDevices?: PairedDevice[];165        trustedNetworks?: TrustedNetwork[];166        status?: SyncStatus;167        lastSyncAt?: number;168        isPro?: boolean;169      }};170      if (data.data) {171        setPairedDevices(data.data.pairedDevices ?? []);172        setTrustedNetworks(data.data.trustedNetworks ?? []);173        setSyncStatus(data.data.status ?? "offline");174        setLastSyncAt(data.data.lastSyncAt);175        setSyncProStatus(data.data.isPro ?? false);176      }177    }178179    if ((passkeyRes as { ok: boolean }).ok) {180      setPasskeyState(181        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null182      );183    }184185    if ((duressRes as { ok: boolean }).ok) {186      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);187    }188189    if ((settingsRes as { ok: boolean }).ok) {190      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;191      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);192    }193194    chrome.runtime.onMessage.addListener((msg) => {195      if (msg.type === "SYNC_STATE_UPDATE") {196        setPairedDevices(msg.pairedDevices ?? []);197        setTrustedNetworks(msg.trustedNetworks ?? []);198        setSyncStatus(msg.status ?? "offline");199        setLastSyncAt(msg.lastSyncAt);200        setSyncProStatus(msg.isPro ?? false);201      }202    });203  });204205  async function handleChangePassword() {206    setChangePwError(null);207    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }208    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }209    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }210    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }211    setChangePwSaving(true);212    const res = await sendMessage({213      type: "CHANGE_PASSWORD",214      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },215    }) as { ok: boolean; error?: string };216    if (res.ok) {217      setChangePwSuccess(true);218      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");219      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);220    } else {221      setChangePwError(res.error ?? "failed to change password");222    }223    setChangePwSaving(false);224  }225226  async function handleActivateLicense() {227    const key = licenseKeyInput().trim();228    if (!key) return;229    setActivatingLicense(true);230    setLicenseError(null);231    setLicenseSuccess(null);232    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });233    setActivatingLicense(false);234    if ((res as { ok: boolean }).ok) {235      const data = (res as { ok: boolean; data?: LicenseInfo }).data;236      if (data?.valid) {237        setLicenseInfo(data);238        setLicenseSuccess("License activated!");239        setLicenseKeyInput("");240      } else {241        setLicenseError("Invalid license key");242      }243    } else {244      setLicenseError("Activation failed");245    }246  }247248  async function handleTogglePasskeys() {249    const current = passkeyState();250    if (!current || !current.supported) return;251    setPasskeySaving(true);252    const res = await sendMessage({253      type: "SET_PASSKEY_PROVIDER_ENABLED",254      payload: { enabled: !current.enabled },255    });256    setPasskeySaving(false);257    if ((res as { ok: boolean }).ok) {258      setPasskeyState(259        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null260      );261    }262  }263264  async function handleCheckAllBreaches() {265    setCheckingBreaches(true);266    setBreachResult(null);267    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });268    setCheckingBreaches(false);269    if ((res as { ok: boolean }).ok) {270      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};271      const breached = Object.values(data).filter((count) => count > 0).length;272      setBreachResult(273        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"274      );275      props.onBreachCheckDone();276    }277  }278279  async function handleSyncNow() {280    if (syncStatus() === "not_trusted") return;281    setSyncingNow(true);282    await sendMessage({ type: "TRIGGER_SYNC" });283    setTimeout(() => setSyncingNow(false), 3000);284  }285286  async function handleRemoveDevice(deviceId: string) {287    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });288  }289290  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {291    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });292  }293294  async function handleMarkNetworkTrusted() {295    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });296  }297298  function formatRelativeTime(timestamp?: number): string {299    if (!timestamp) return "Never";300    const diff = Date.now() - timestamp;301    const minutes = Math.floor(diff / 60000);302    const hours = Math.floor(diff / 3600000);303    const days = Math.floor(diff / 86400000);304    if (minutes < 1) return "Just now";305    if (minutes < 60) return `${minutes}m ago`;306    if (hours < 24) return `${hours}h ago`;307    return `${days}d ago`;308  }309310  function getStatusColor(status: SyncStatus): string {311    switch (status) {312      case "synced": return "var(--peach-sync-ok)";313      case "syncing": return "var(--peach-sync-busy)";314      case "no_peers": return "var(--peach-sync-off)";315      case "not_trusted": return "var(--peach-sync-err)";316      case "offline": return "var(--peach-sync-off)";317      default: return "var(--peach-sync-off)";318    }319  }320321  function getStatusLabel(status: SyncStatus): string {322    switch (status) {323      case "synced": return "Synced";324      case "syncing": return "Syncing";325      case "no_peers": return "No peers visible";326      case "not_trusted": return "Not on a trusted network";327      case "offline": return "Offline";328      default: return "Unknown";329    }330  }331332  function isCurrentNetworkTrusted(): boolean {333    return trustedNetworks().length > 0;334  }335336  async function handleSetupDuress() {337    const mainPw = duressMainPassword().trim();338    const duressPw = duressPassword().trim();339    const confirmPw = duressConfirm().trim();340341    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }342    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }343    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }344    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }345346    setDuressSaving(true);347    setDuressError(null);348349    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };350    if (!verifyRes.ok || !verifyRes.data) {351      setDuressError("incorrect main password");352      setDuressSaving(false);353      return;354    }355356    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };357    setDuressSaving(false);358    if (res.ok) {359      setDuressEnabled(true);360      setShowDuressSetup(false);361      setDuressPassword("");362      setDuressConfirm("");363      setDuressMainPassword("");364      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");365      setTimeout(() => setDuressSuccess(null), 5000);366    } else {367      setDuressError(res.error ?? "failed to set duress password");368    }369  }370371  async function handleClearDeclinedCards() {372    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });373    setDeclinedCardCount(0);374  }375376  async function handleCountDuplicates() {377    setDedupRunning(true);378    setDedupDone(null);379    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };380    setDedupRunning(false);381    if (res.ok) setDedupCount(res.data ?? 0);382  }383384  async function handleDeduplicate() {385    setDedupRunning(true);386    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };387    setDedupRunning(false);388    if (res.ok) {389      setDedupDone(res.data ?? 0);390      setDedupCount(0);391    }392  }393394  async function handleDisableDuress() {395    const mainPw = duressMainPassword().trim();396    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }397398    setDuressSaving(true);399    setDuressError(null);400401    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };402    if (!verifyRes.ok || !verifyRes.data) {403      setDuressError("incorrect main password");404      setDuressSaving(false);405      return;406    }407408    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };409    setDuressSaving(false);410    if (res.ok) {411      setDuressEnabled(false);412      setShowDuressSetup(false);413      setDuressMainPassword("");414      setDuressSuccess("Duress password disabled.");415      setTimeout(() => setDuressSuccess(null), 4000);416    } else {417      setDuressError(res.error ?? "failed to disable duress password");418    }419  }420421  async function handleSubmitFeedback() {422    if (!fbMessage().trim() || fbSubmitting()) return;423    setFbSubmitting(true);424    setFbResult(null);425    const res = await sendMessage({426      type: "SUBMIT_FEEDBACK",427      payload: {428        category: fbCategory(),429        message: fbMessage().trim(),430        version,431        browser: navigator.userAgent,432      },433    }) as { ok: boolean; error?: string };434    setFbSubmitting(false);435    if (res.ok) {436      setFbResult("success");437      setFbMessage("");438      setTimeout(() => setFbResult(null), 4000);439    } else {440      setFbResult("error");441    }442  }443444  function handleHeaderBack() {445    if (activeSection() !== null) {446      setActiveSection(null);447    } else {448      props.onBack();449    }450  }451452  function currentTitle(): string {453    const s = activeSection();454    if (s === null) return "settings";455    if (s === "license") return "account";456    return s;457  }458459  function renderCatRow(opts: {460    section: SectionName;461    name: string;462    desc: string;463    icon: () => any;464    pro?: boolean;465  }) {466    return (467      <button468        class="peach-settings-cat-row"469        onClick={() => setActiveSection(opts.section)}470      >471        <div class="peach-settings-cat-icon">{opts.icon()}</div>472        <div class="peach-settings-cat-content">473          <span class="peach-settings-cat-name">474            {opts.name}475            <Show when={opts.pro && !isPro()}>476              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>477            </Show>478          </span>479          <span class="peach-settings-cat-desc">{opts.desc}</span>480        </div>481        <svg482          class="peach-settings-cat-chevron"483          width="6" height="10" viewBox="0 0 6 10" fill="none"484          stroke="currentColor" stroke-width="1.1"485          stroke-linecap="round" stroke-linejoin="round"486        >487          <path d="M1 1l4 4-4 4" />488        </svg>489      </button>490    );491  }492493  const iconShield = () => (494    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">495      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>496    </svg>497  );498  const iconKey = () => (499    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">500      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>501    </svg>502  );503  const iconLock = () => (504    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">505      <rect x="3" y="7" width="10" height="7" rx="1.5"/>506      <path d="M6 7V5a3 3 0 016 0v2"/>507    </svg>508  );509  const iconSync = () => (510    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">511      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>512      <path d="M14 6l-2 2-2-2"/>513      <path d="M2 10l2-2 2 2"/>514    </svg>515  );516  const iconSun = () => (517    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">518      <circle cx="8" cy="8" r="3"/>519      <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"/>520    </svg>521  );522  const iconKeys = () => (523    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">524      <rect x="2" y="5" width="5" height="4" rx="1"/>525      <rect x="9" y="7" width="5" height="4" rx="1"/>526      <path d="M7 7h2m-2 2h2"/>527    </svg>528  );529  const iconBubble = () => (530    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">531      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>532    </svg>533  );534535  function renderHome() {536    return (537      <div class="peach-settings-body">538        <p class="peach-settings-nav-label">PROTECTION</p>539        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}540        {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}541542        <p class="peach-settings-nav-label">VAULT</p>543        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}544        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}545546        <p class="peach-settings-nav-label">PREFERENCES</p>547        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}548        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}549550        <p class="peach-settings-nav-label">SUPPORT</p>551        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}552553        <div class="peach-settings-footer">554          <span>Peach {version}</span>555          <span class="peach-divider"></span>556          <span>Browser extension</span>557        </div>558      </div>559    );560  }561562  function renderSecurity() {563    return (564      <div class="peach-settings-body">565        <div class="peach-settings-group">566          <p class="peach-settings-sub-label">LOCKING</p>567          <div class="peach-settings-row">568            <span>Lock on close</span>569            <button570              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}571              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}572              aria-label="Toggle lock on close"573            >574              <span class="peach-toggle-knob"></span>575            </button>576          </div>577          <div class="peach-settings-row">578            <span>Auto-lock after</span>579            <select580              class="peach-settings-select"581              value={String(props.autoLockMinutes)}582              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}583            >584              <option value="1">1 minute</option>585              <option value="5">5 minutes</option>586              <option value="15">15 minutes</option>587              <option value="30">30 minutes</option>588              <option value="0">Never</option>589            </select>590          </div>591592          <p class="peach-settings-sub-label">BREACH PROTECTION</p>593          <div class="peach-settings-row">594            <span>Breach monitoring</span>595            <button596              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}597              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}598              aria-label="Toggle breach monitoring"599            >600              <span class="peach-toggle-knob"></span>601            </button>602          </div>603          <div class="peach-settings-row peach-settings-row--meta">604            <span>Check all passwords</span>605            <button606              class="peach-btn peach-btn--subtle peach-btn--sm"607              onClick={() => void handleCheckAllBreaches()}608              disabled={checkingBreaches()}609            >610              {checkingBreaches() ? "checking..." : "run"}611            </button>612          </div>613          <Show when={breachResult()}>614            <div class="peach-settings-row peach-settings-row--meta">615              <span>Last check</span>616              <span class="peach-settings-value">{breachResult()}</span>617            </div>618          </Show>619620          <p class="peach-settings-sub-label">MASTER PASSWORD</p>621          <div class="peach-settings-row peach-settings-row--meta">622            <span>Master password</span>623            <button624              class="peach-btn peach-btn--subtle peach-btn--sm"625              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}626            >627              {showChangePw() ? "cancel" : "change"}628            </button>629          </div>630          <Show when={showChangePw()}>631            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">632              <input633                class="peach-settings-input"634                type="password"635                placeholder="current password"636                value={changePwCurrent()}637                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}638                autocomplete="current-password"639              />640              <input641                class="peach-settings-input"642                type="password"643                placeholder="new password (min 12 chars)"644                value={changePwNew()}645                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}646                autocomplete="new-password"647              />648              <input649                class="peach-settings-input"650                type="password"651                placeholder="confirm new password"652                value={changePwConfirm()}653                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}654                autocomplete="new-password"655                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}656              />657              <Show when={changePwError()}>658                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>659              </Show>660              <Show when={changePwSuccess()}>661                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>662              </Show>663              <button664                class="peach-btn peach-btn--accent peach-btn--sm"665                style="align-self:flex-start"666                onClick={() => void handleChangePassword()}667                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}668              >669                {changePwSaving() ? "saving…" : "save new password"}670              </button>671            </div>672          </Show>673674          <p class="peach-settings-sub-label">DURESS PASSWORD</p>675          <div class="peach-settings-row peach-settings-row--top">676            <div class="peach-settings-row-main">677              <span>Duress password</span>678              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">679                If entered under coercion, shows a decoy empty vault instead of your real one.680              </span>681            </div>682            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>683              {duressEnabled() ? "on" : "off"}684            </div>685          </div>686687          <Show when={duressEnabled() && !showDuressSetup()}>688            <div class="peach-settings-row peach-settings-row--meta">689              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">690                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.691              </span>692            </div>693          </Show>694695          <Show when={duressSuccess()}>696            <div class="peach-settings-row peach-settings-row--meta">697              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>698            </div>699          </Show>700701          <Show when={!showDuressSetup()}>702            <div class="peach-settings-row peach-settings-row--meta">703              <button704                class="peach-btn peach-btn--subtle peach-btn--sm"705                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}706              >707                {duressEnabled() ? "Change / disable" : "Set up"}708              </button>709            </div>710          </Show>711712          <Show when={showDuressSetup()}>713            <div class="peach-duress-form">714              <label class="peach-label">main password (to confirm)</label>715              <input716                class="peach-settings-input"717                type="password"718                placeholder="your current master password"719                autocomplete="current-password"720                value={duressMainPassword()}721                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}722              />723              <Show when={!duressEnabled()}>724                <label class="peach-label">new duress password</label>725                <input726                  class="peach-settings-input"727                  type="password"728                  placeholder="duress password (min 8 chars)"729                  autocomplete="new-password"730                  value={duressPassword()}731                  onInput={(e) => setDuressPassword(e.currentTarget.value)}732                />733                <label class="peach-label">confirm duress password</label>734                <input735                  class="peach-settings-input"736                  type="password"737                  placeholder="confirm duress password"738                  autocomplete="new-password"739                  value={duressConfirm()}740                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}741                />742              </Show>743744              <Show when={duressError()}>745                <p class="peach-hint peach-hint-error">{duressError()}</p>746              </Show>747748              <div class="peach-duress-actions">749                <button750                  class="peach-btn peach-btn--subtle peach-btn--sm"751                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}752                >753                  cancel754                </button>755                <Show when={duressEnabled()}>756                  <button757                    class="peach-btn peach-btn--ghost peach-btn--sm"758                    style="color:var(--peach-breach)"759                    onClick={() => void handleDisableDuress()}760                    disabled={duressSaving()}761                  >762                    {duressSaving() ? "disabling..." : "disable duress"}763                  </button>764                </Show>765                <Show when={!duressEnabled()}>766                  <button767                    class="peach-btn peach-btn--accent peach-btn--sm"768                    onClick={() => void handleSetupDuress()}769                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}770                  >771                    {duressSaving() ? "saving..." : "save"}772                  </button>773                </Show>774              </div>775            </div>776          </Show>777778          <p class="peach-settings-sub-label">PASSKEYS</p>779          <div class="peach-settings-row peach-settings-row--top">780            <div class="peach-settings-row-main">781              <span>Intercept passkey requests</span>782              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">783                Peach can handle passkey sign-in and creation on supported Chromium browsers.784              </span>785            </div>786            <button787              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}788              onClick={() => void handleTogglePasskeys()}789              aria-label="Toggle passkey interception"790              disabled={!passkeyState()?.supported || passkeySaving()}791            >792              <span class="peach-toggle-knob"></span>793            </button>794          </div>795          <div class="peach-settings-row peach-settings-row--meta">796            <span>Status</span>797            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>798          </div>799          <div class="peach-settings-row peach-settings-row--meta">800            <span>Browser</span>801            <span class="peach-settings-value">802              {passkeyState()?.browserLabel ?? "Unknown"}803              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}804            </span>805          </div>806          <div class="peach-info-notice" style="margin-top:8px">807            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.808          </div>809          <Show when={passkeyState()?.conflict}>810            <div class="peach-settings-row peach-settings-row--meta">811              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>812            </div>813          </Show>814        </div>815      </div>816    );817  }818819  function renderVault() {820    return (821      <div class="peach-settings-body">822        <div class="peach-settings-group">823          <p class="peach-settings-sub-label">ENTRIES</p>824          <div class="peach-settings-row peach-settings-row--meta">825            <span>Entries</span>826            <span class="peach-settings-value">{props.entriesCount}</span>827          </div>828          <div class="peach-settings-row peach-settings-row--meta">829            <span>Duplicates</span>830            <Show when={dedupDone() !== null} fallback={831              <button832                class="peach-btn peach-btn--subtle peach-btn--sm"833                onClick={() => void handleCountDuplicates()}834                disabled={dedupRunning()}835              >836                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}837              </button>838            }>839              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">840                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}841              </span>842            </Show>843          </div>844          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>845            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">846              <div class="peach-restore-warning">847                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.848              </div>849              <div style="display:flex;gap:6px">850                <button851                  class="peach-btn peach-btn--subtle peach-btn--sm"852                  onClick={() => setDedupCount(null)}853                >854                  cancel855                </button>856                <button857                  class="peach-btn peach-btn--accent peach-btn--sm"858                  onClick={() => void handleDeduplicate()}859                  disabled={dedupRunning()}860                >861                  {dedupRunning() ? "removing..." : "remove duplicates"}862                </button>863              </div>864            </div>865          </Show>866867          <p class="peach-settings-sub-label">MAINTENANCE</p>868          <div class="peach-settings-row peach-settings-row--meta">869            <span>Trash auto-delete</span>870            <select871              class="peach-settings-select"872              value={String(props.trashRetentionDays)}873              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}874            >875              <option value="7">7 days</option>876              <option value="30">30 days</option>877              <option value="90">90 days</option>878              <option value="0">Never</option>879            </select>880          </div>881          <div class="peach-settings-row peach-settings-row--meta">882            <span>Vault Health</span>883            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>884              open885            </button>886          </div>887          <Show when={declinedCardCount() > 0}>888            <div class="peach-settings-row peach-settings-row--meta">889              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>890              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>891                Clear892              </button>893            </div>894          </Show>895896          <p class="peach-settings-sub-label">DATA</p>897          <div class="peach-settings-row peach-settings-row--meta">898            <span>Import passwords</span>899            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>900              open901            </button>902          </div>903          <div class="peach-settings-row peach-settings-row--meta">904            <span>Export backup</span>905            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>906              open907            </button>908          </div>909          <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">910            <span>Replace vault from backup</span>911            <div class="peach-restore-warning" style="margin-top:2px">912              This will permanently replace your current vault with the backup.913            </div>914            <div style="display:flex;gap:6px">915              <button916                class="peach-btn peach-btn--subtle peach-btn--sm"917                onClick={() => props.onOpenFileImport?.()}918              >919                backup file920              </button>921            </div>922          </div>923        </div>924      </div>925    );926  }927928  function renderSync() {929    if (!isPro()) {930      return (931        <div class="peach-settings-body">932          <div class="peach-pro-upgrade">933            <div class="peach-pro-upgrade-hero">934              <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">935                <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>936                <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>937                <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"/>938                <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"/>939                <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>940                <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>941                <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>942              </svg>943              <h2 class="peach-pro-upgrade-title">Peach Sync</h2>944              <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>945            </div>946947            <div class="peach-pro-features">948              <div class="peach-pro-feature">949                <span class="peach-pro-feature-mark">✦</span>950                <div class="peach-pro-feature-text">951                  <span class="peach-pro-feature-name">Always in sync</span>952                  <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>953                </div>954              </div>955              <div class="peach-pro-feature">956                <span class="peach-pro-feature-mark">✦</span>957                <div class="peach-pro-feature-text">958                  <span class="peach-pro-feature-name">No cloud. No relay.</span>959                  <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>960                </div>961              </div>962              <div class="peach-pro-feature">963                <span class="peach-pro-feature-mark">✦</span>964                <div class="peach-pro-feature-text">965                  <span class="peach-pro-feature-name">Tailscale supported</span>966                  <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>967                </div>968              </div>969              <div class="peach-pro-feature">970                <span class="peach-pro-feature-mark">✦</span>971                <div class="peach-pro-feature-text">972                  <span class="peach-pro-feature-name">Zero friction after setup</span>973                  <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>974                </div>975              </div>976            </div>977978            <button979              class="peach-btn peach-btn--accent peach-pro-upgrade-cta"980              onClick={() => setActiveSection("license")}981            >982              Upgrade to Pro983            </button>984            <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>985              Already have a key? Enter it →986            </button>987          </div>988        </div>989      );990    }991992    return (993      <div class="peach-settings-body">994        <div class="peach-settings-group">995          <div class="peach-settings-row">996            <span>Status</span>997            <span class="peach-settings-value peach-settings-value--status">998              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>999              {getStatusLabel(syncStatus())}1000            </span>1001          </div>1002          <div class="peach-settings-row peach-settings-row--meta">1003            <span>Last sync</span>1004            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1005          </div>1006          <div class="peach-settings-row peach-settings-row--meta">1007            <button1008              class="peach-btn peach-btn--subtle peach-btn--sm"1009              onClick={() => void handleSyncNow()}1010              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1011            >1012              {syncingNow() ? "Syncing..." : "Sync now"}1013            </button>1014          </div>10151016          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1017            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1018            {pairedDevices().map((device) => (1019              <div class="peach-sync-device-row">1020                <div class="peach-sync-device-info">1021                  <span class="peach-sync-device-name">{device.displayName}</span>1022                  <span class="peach-sync-device-meta">1023                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1024                  </span>1025                </div>1026                <button1027                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1028                  onClick={() => void handleRemoveDevice(device.deviceId)}1029                >1030                  Remove1031                </button>1032              </div>1033            ))}1034            <div class="peach-settings-actions--row">1035              <button1036                class="peach-btn peach-btn--subtle peach-btn--sm"1037                onClick={() => props.onOpenSyncPairing?.("initiate")}1038              >1039                Pair a new device1040              </button>1041              <button1042                class="peach-btn peach-btn--subtle peach-btn--sm"1043                onClick={() => props.onOpenSyncPairing?.("accept")}1044              >1045                Pair with another device1046              </button>1047            </div>1048          </div>10491050          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1051            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1052            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1053              <div class="peach-sync-device-row">1054                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1055                <button1056                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1057                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1058                >1059                  Remove1060                </button>1061              </div>1062            ))}1063            <div class="peach-settings-actions--row">1064              <Show when={!isCurrentNetworkTrusted()}>1065                <button1066                  class="peach-btn peach-btn--subtle peach-btn--sm"1067                  onClick={() => void handleMarkNetworkTrusted()}1068                >1069                  Mark current network as trusted1070                </button>1071              </Show>1072            </div>1073            <span class="peach-sync-device-meta">1074              Tailscale is always trusted1075            </span>1076          </div>1077        </div>1078      </div>1079    );1080  }10811082  function renderAppearance() {1083    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1084      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1085      { id: "cormorant", label: "Cormorant", preview: "Aa" },1086      { id: "inter", label: "Inter", preview: "Aa" },1087      { id: "system", label: "System", preview: "Aa" },1088    ];1089    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1090      { id: "small", label: "Small" },1091      { id: "medium", label: "Default" },1092      { id: "large", label: "Large" },1093    ];1094    return (1095      <div class="peach-settings-body">1096        <div class="peach-settings-group">1097          <p class="peach-settings-sub-label">THEME</p>1098          <div class="peach-settings-row peach-settings-row--meta">1099            <span>Theme</span>1100            <button1101              class="peach-btn peach-btn--subtle peach-btn--sm"1102              onClick={() =>1103                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1104              }1105            >1106              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1107            </button>1108          </div>11091110          <p class="peach-settings-sub-label">ACCENT</p>1111          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1112            <span>Accent</span>1113            <div class="peach-accent-picker">1114              <For each={ACCENT_COLORS}>1115                {(color) => (1116                  <button1117                    class="peach-accent-swatch"1118                    classList={{ "is-active": props.accentColor === color.name }}1119                    style={{ background: color.hex }}1120                    onClick={() => void props.onAccentChange(color.name)}1121                    aria-label={color.name}1122                    title={color.name}1123                  />1124                )}1125              </For>1126            </div>1127          </div>11281129          <p class="peach-settings-sub-label">DISPLAY FONT</p>1130          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1131            <span>Font</span>1132            <div class="peach-font-picker">1133              <For each={fontOptions}>1134                {(opt) => (1135                  <button1136                    class="peach-font-swatch"1137                    classList={{ "is-active": props.displayFont === opt.id }}1138                    data-font-preview={opt.id}1139                    onClick={() => void props.onDisplayFontChange(opt.id)}1140                    aria-label={opt.label}1141                    title={opt.label}1142                  >1143                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1144                    <span class="peach-font-swatch-name">{opt.label}</span>1145                  </button>1146                )}1147              </For>1148            </div>1149          </div>11501151          <p class="peach-settings-sub-label">TEXT SIZE</p>1152          <div class="peach-settings-row peach-settings-row--meta">1153            <span>Size</span>1154            <div class="peach-pill-group">1155              <For each={sizeOptions}>1156                {(opt) => (1157                  <button1158                    class="peach-btn peach-btn--ghost peach-btn--sm"1159                    classList={{ "is-active": props.textSize === opt.id }}1160                    onClick={() => void props.onTextSizeChange(opt.id)}1161                  >1162                    {opt.label}1163                  </button>1164                )}1165              </For>1166            </div>1167          </div>11681169          <p class="peach-settings-sub-label">MOTION</p>1170          <div class="peach-settings-row peach-settings-row--meta">1171            <span>Reduce motion</span>1172            <button1173              class="peach-btn peach-btn--subtle peach-btn--sm"1174              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1175            >1176              {props.reduceMotion ? "On" : "Off"}1177            </button>1178          </div>1179        </div>1180      </div>1181    );1182  }11831184  function renderShortcuts() {1185    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1186    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);11871188    function startCapture(action: ShortcutAction) {1189      setConflictMsg(null);1190      setCapturingAction(action);1191    }1192    function cancelCapture() {1193      setCapturingAction(null);1194      setConflictMsg(null);1195    }1196    function commitBinding(action: ShortcutAction, binding: Keybinding) {1197      const conflict = findConflict(props.shortcuts, binding, action);1198      if (conflict) {1199        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1200        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1201        return;1202      }1203      const next = { ...props.shortcuts, [action]: binding };1204      setCapturingAction(null);1205      setConflictMsg(null);1206      void props.onShortcutsChange(next);1207    }1208    function resetAction(action: ShortcutAction) {1209      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1210      void props.onShortcutsChange(next);1211    }1212    function resetAll() {1213      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1214    }12151216    // Capture-mode key handler — wired via ref onto the chip element so it1217    // catches the next non-modifier keypress and ignores other bindings.1218    function handleCaptureKey(e: KeyboardEvent) {1219      e.preventDefault();1220      e.stopPropagation();1221      if (e.key === "Escape") { cancelCapture(); return; }1222      const binding = keybindingFromEvent(e);1223      if (!binding) return; // bare modifier — keep waiting1224      const action = capturingAction();1225      if (action) commitBinding(action, binding);1226    }12271228    return (1229      <div class="peach-settings-body">1230        <div class="peach-settings-group peach-shortcuts-group">1231          <p class="peach-settings-sub-label">ENTRY VIEW</p>1232          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1233            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1234          </For>1235        </div>12361237        <div class="peach-settings-group peach-shortcuts-group">1238          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1239          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1240            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1241          </For>1242        </div>12431244        <Show when={conflictMsg()}>1245          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1246        </Show>12471248        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1249          <div class="peach-settings-row peach-settings-row--meta">1250            <span>Inline autofill on page</span>1251            <a1252              class="peach-settings-link"1253              href="chrome://extensions/shortcuts"1254              onClick={(e) => {1255                e.preventDefault();1256                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1257              }}1258            >1259              ⌘⇧P — customize ↗1260            </a>1261          </div>1262          <button1263            class="peach-btn peach-btn--subtle peach-btn--sm"1264            style="align-self:flex-start"1265            onClick={resetAll}1266          >1267            Reset all to defaults1268          </button>1269        </div>1270      </div>1271    );12721273    function renderRow(1274      desc: typeof SHORTCUT_DESCRIPTORS[number],1275      capturing: () => ShortcutAction | null,1276      onStart: (a: ShortcutAction) => void,1277      onCancel: () => void,1278      onReset: (a: ShortcutAction) => void,1279      onKey: (e: KeyboardEvent) => void,1280    ) {1281      const isActive = () => capturing() === desc.action;1282      const binding = () => props.shortcuts[desc.action];1283      const isCustom = () => {1284        const b = binding();1285        const d = DEFAULT_BINDINGS[desc.action];1286        return b.key !== d.key1287          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1288          || Boolean(b.meta)  !== Boolean(d.meta)1289          || Boolean(b.shift) !== Boolean(d.shift)1290          || Boolean(b.alt)   !== Boolean(d.alt);1291      };1292      return (1293        <div class="peach-shortcut-row">1294          <span class="peach-shortcut-row-label">{desc.label}</span>1295          <button1296            class="peach-shortcut-chip"1297            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1298            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1299            onKeyDown={(e) => { if (isActive()) onKey(e); }}1300            ref={(el) => {1301              // Auto-focus when entering capture mode so the keydown listener catches the next press.1302              if (el) {1303                queueMicrotask(() => { if (isActive()) el.focus(); });1304              }1305            }}1306          >1307            {isActive() ? "Press a key…" : formatKeybinding(binding())}1308          </button>1309          <Show when={isCustom() && !isActive()}>1310            <button1311              class="peach-shortcut-reset"1312              title="Reset to default"1313              onClick={() => onReset(desc.action)}1314            >1315              ↺1316            </button>1317          </Show>1318        </div>1319      );1320    }1321  }13221323  const PRO_FEATURES: Array<{1324    feature: ProFeature;1325    name: string;1326    desc: string;1327  }> = [1328    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1329    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1330    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1331    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1332    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1333  ];13341335  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1336  const [deactivating, setDeactivating] = createSignal(false);13371338  async function handleDeactivateLicense() {1339    setDeactivating(true);1340    await sendMessage({ type: "DEACTIVATE_LICENSE" });1341    setLicenseInfo(null);1342    await props.onLicenseChange?.();1343    setDeactivating(false);1344  }13451346  function renderLicense() {1347    return (1348      <div class="peach-settings-body">1349        <Show1350          when={isPro()}1351          fallback={1352            <div class="peach-pro-upgrade">1353              <div class="peach-pro-upgrade-hero">1354                <PeachMark class="peach-pro-upgrade-mark" />1355                <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1356                <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1357              </div>13581359              <div class="peach-pro-features">1360                <For each={PRO_FEATURES}>1361                  {(item) => (1362                    <div1363                      class="peach-pro-feature"1364                      classList={{1365                        "is-highlighted": props.upgradeHighlight === item.feature,1366                      }}1367                    >1368                      <span class="peach-pro-feature-mark">✦</span>1369                      <div class="peach-pro-feature-text">1370                        <span class="peach-pro-feature-name">{item.name}</span>1371                        <span class="peach-pro-feature-desc">{item.desc}</span>1372                      </div>1373                    </div>1374                  )}1375                </For>1376              </div>13771378              <p class="peach-pro-pricing">$39 · once · yours forever</p>13791380              <a1381                href="https://peachpasswords.com/#pricing"1382                target="_blank"1383                rel="noopener noreferrer"1384                class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1385              >1386                Get Peach Pro1387              </a>13881389              <Show1390                when={showKeyEntry()}1391                fallback={1392                  <button1393                    class="peach-pro-key-link"1394                    onClick={() => setShowKeyEntry(true)}1395                  >1396                    Already have a key? Enter it →1397                  </button>1398                }1399              >1400                <div class="peach-pro-key-entry">1401                  <input1402                    class="peach-underline-input peach-pro-key-input"1403                    type="text"1404                    placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1405                    value={licenseKeyInput()}1406                    onInput={(e) =>1407                      setLicenseKeyInput(e.currentTarget.value.toUpperCase())1408                    }1409                    onKeyDown={(e) => {1410                      if (e.key === "Enter") void onActivate();1411                    }}1412                    autocomplete="off"1413                    spellcheck={false}1414                  />1415                  <button1416                    class="peach-btn peach-btn--accent peach-btn--sm"1417                    onClick={() => void onActivate()}1418                    disabled={activatingLicense() || !licenseKeyInput().trim()}1419                  >1420                    {activatingLicense() ? "activating…" : "Activate"}1421                  </button>1422                  <Show when={licenseError()}>1423                    <p class="peach-pro-key-error">That key doesn't look right.</p>1424                  </Show>1425                </div>1426              </Show>1427            </div>1428          }1429        >1430          <div class="peach-pro-active">1431            <div class="peach-pro-active-head">1432              <span class="peach-pro-active-mark" aria-hidden="true">🍑</span>1433              <h3 class="peach-pro-active-title">Peach Pro</h3>1434            </div>1435            <p class="peach-pro-active-since">1436              {formatActivatedSince(licenseInfo()?.issued)}1437            </p>1438            <div class="peach-pro-active-divider"></div>1439            <p class="peach-pro-active-thanks">Thank you.</p>1440            <button1441              class="peach-pro-deactivate"1442              onClick={() => void handleDeactivateLicense()}1443              disabled={deactivating()}1444            >1445              {deactivating() ? "Deactivating…" : "Deactivate →"}1446            </button>1447          </div>1448        </Show>1449      </div>1450    );14511452    async function onActivate() {1453      await handleActivateLicense();1454      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1455        await props.onLicenseChange?.();1456      }1457    }1458  }14591460  function renderFeedback() {1461    return (1462      <div class="peach-settings-body">1463        <div class="peach-settings-group peach-feedback-group">1464          <p class="peach-feedback-intro">1465            Help us improve Peach. Every report goes directly to our inbox.1466          </p>14671468          <p class="peach-settings-sub-label">TYPE</p>1469          <div class="peach-feedback-chips">1470            <For each={FEEDBACK_CATEGORIES}>1471              {(cat) => (1472                <button1473                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1474                  onClick={() => setFbCategory(cat)}1475                >1476                  {cat}1477                </button>1478              )}1479            </For>1480          </div>14811482          <p class="peach-settings-sub-label">MESSAGE</p>1483          <textarea1484            class="peach-feedback-textarea"1485            placeholder="Describe the issue or idea..."1486            value={fbMessage()}1487            onInput={(e) => setFbMessage(e.currentTarget.value)}1488          />14891490          <p class="peach-feedback-meta">1491            Version {version} · will be included automatically1492          </p>14931494          <Show when={fbResult() === "success"}>1495            <p class="peach-feedback-result peach-feedback-result--ok">1496              Sent — thank you1497            </p>1498          </Show>1499          <Show when={fbResult() === "error"}>1500            <p class="peach-feedback-result peach-feedback-result--err">1501              Failed to send — try again1502            </p>1503          </Show>15041505          <button1506            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1507            onClick={() => void handleSubmitFeedback()}1508            disabled={fbSubmitting() || !fbMessage().trim()}1509          >1510            {fbSubmitting() ? "Sending..." : "Send feedback"}1511          </button>15121513          <p class="peach-feedback-anon">1514            Sent anonymously · no account required1515          </p>1516        </div>1517      </div>1518    );1519  }15201521  return (1522    <div class="peach-overlay-view ov-enter-right" style="display:flex">1523      <div class="peach-overlay-header">1524        <button1525          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1526          onClick={handleHeaderBack}1527          aria-label="Back"1528        >1529          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1530            <path d="M6 1L1 6L6 11" />1531          </svg>1532        </button>1533        <span class="peach-overlay-title">{currentTitle()}</span>1534        <div class="peach-overlay-spacer"></div>1535      </div>15361537      <Switch fallback={renderHome()}>1538        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1539        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1540        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1541        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1542        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1543        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1544        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1545      </Switch>1546    </div>1547  );1548}