Settings.tsx

66.3 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { LockRuneCanvas } from "../components/LockRuneCanvas";5import type { Canary } from "../../shared/types";6import { ACCENT_COLORS } from "../../shared/types";7import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";8import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";9import {10  DEFAULT_BINDINGS,11  findConflict,12  formatKeybinding,13  keybindingFromEvent,14  SHORTCUT_DESCRIPTORS,15  type Keybinding,16  type ShortcutAction,17} from "../../shared/shortcuts";1819interface LicenseInfo {20  valid: boolean;21  plan: "free" | "pro";22  issued?: string;23}2425interface SettingsProps {26  entriesCount: number;27  breachMonitoring: boolean;28  lockOnClose: boolean;29  autoLockMinutes: number;30  trashRetentionDays: number;31  theme: "dark" | "light";32  accentColor: AccentColorName;33  displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";34  textSize: "small" | "medium" | "large";35  reduceMotion: boolean;36  isPro?: boolean;37  initialSection?: string | null;38  upgradeHighlight?: ProFeature | null;39  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;40  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;41  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;42  onTrashRetentionChange: (days: number) => void | Promise<void>;43  onThemeChange: (theme: "dark" | "light") => void;44  onAccentChange: (name: AccentColorName) => void | Promise<void>;45  onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;46  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;47  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;48  shortcuts: Record<ShortcutAction, Keybinding>;49  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;50  onLicenseChange?: () => void | Promise<void>;51  onBack: () => void;52  onImport: () => void;53  onExport: () => void;54  onBreachCheckDone: () => void;55  onOpenHealth?: () => void;56  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;57  onOpenFileImport?: () => void;58  onOpenPanic?: () => void;59}6061type SectionName =62  | "security"63  | "vault"64  | "sync"65  | "appearance"66  | "shortcuts"67  | "license"68  | "feedback";6970const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7172function formatActivatedSince(issued: string | undefined): string {73  if (!issued) return "Active";74  try {75    const d = new Date(issued);76    if (Number.isNaN(d.getTime())) return "Active";77    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;78  } catch {79    return "Active";80  }81}8283function isSectionName(value: string): value is SectionName {84  return (85    value === "security" ||86    value === "vault" ||87    value === "sync" ||88    value === "appearance" ||89    value === "shortcuts" ||90    value === "license" ||91    value === "feedback"92  );93}9495export function SettingsView(props: SettingsProps) {96  const version = chrome.runtime.getManifest().version;97  const initial = props.initialSection;98  const [activeSection, setActiveSection] = createSignal<SectionName | null>(99    initial && isSectionName(initial) ? (initial as SectionName) : null100  );101102  const [checkingBreaches, setCheckingBreaches] = createSignal(false);103  const [breachResult, setBreachResult] = createSignal<string | null>(null);104  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);105  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");106  const [licenseError, setLicenseError] = createSignal<string | null>(null);107  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);108  const [activatingLicense, setActivatingLicense] = createSignal(false);109110  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);111  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);112  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");113  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);114  // Prefer the prop (cached at App boot), fall back to the sync-state probe.115  // The prop is authoritative once present so license activation reflects116  // immediately without waiting for a sync round-trip.117  const [syncProStatus, setSyncProStatus] = createSignal(false);118  const isPro = () => props.isPro ?? syncProStatus();119  const [syncingNow, setSyncingNow] = createSignal(false);120  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);121  const [passkeySaving, setPasskeySaving] = createSignal(false);122123  const [dedupCount, setDedupCount] = createSignal<number | null>(null);124  const [dedupRunning, setDedupRunning] = createSignal(false);125  const [dedupDone, setDedupDone] = createSignal<number | null>(null);126127  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);128129  const [showChangePw, setShowChangePw] = createSignal(false);130  const [changePwCurrent, setChangePwCurrent] = createSignal("");131  const [changePwNew, setChangePwNew] = createSignal("");132  const [changePwConfirm, setChangePwConfirm] = createSignal("");133  const [changePwSaving, setChangePwSaving] = createSignal(false);134  const [changePwError, setChangePwError] = createSignal<string | null>(null);135  const [changePwSuccess, setChangePwSuccess] = createSignal(false);136137  const [duressEnabled, setDuressEnabled] = createSignal(false);138  const [showDuressSetup, setShowDuressSetup] = createSignal(false);139  const [duressPassword, setDuressPassword] = createSignal("");140  const [duressConfirm, setDuressConfirm] = createSignal("");141  const [duressMainPassword, setDuressMainPassword] = createSignal("");142  const [duressSaving, setDuressSaving] = createSignal(false);143  const [duressError, setDuressError] = createSignal<string | null>(null);144  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);145146  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);147  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);148  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);149150  const [fbCategory, setFbCategory] = createSignal("Bug report");151  const [fbMessage, setFbMessage] = createSignal("");152  const [fbSubmitting, setFbSubmitting] = createSignal(false);153  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);154155  onMount(async () => {156    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([157      sendMessage({ type: "GET_LICENSE" }),158      sendMessage({ type: "GET_SYNC_STATE" }),159      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),160      sendMessage({ type: "IS_DURESS_ENABLED" }),161      sendMessage({ type: "GET_SETTINGS" }),162    ]);163164    if ((licenseRes as { ok: boolean }).ok) {165      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);166    }167168    if ((syncRes as { ok: boolean }).ok) {169      const data = syncRes as { ok: boolean; data?: {170        pairedDevices?: PairedDevice[];171        trustedNetworks?: TrustedNetwork[];172        status?: SyncStatus;173        lastSyncAt?: number;174        isPro?: boolean;175      }};176      if (data.data) {177        setPairedDevices(data.data.pairedDevices ?? []);178        setTrustedNetworks(data.data.trustedNetworks ?? []);179        setSyncStatus(data.data.status ?? "offline");180        setLastSyncAt(data.data.lastSyncAt);181        setSyncProStatus(data.data.isPro ?? false);182      }183    }184185    if ((passkeyRes as { ok: boolean }).ok) {186      setPasskeyState(187        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null188      );189    }190191    if ((duressRes as { ok: boolean }).ok) {192      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);193    }194195    if ((settingsRes as { ok: boolean }).ok) {196      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;197      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);198    }199200    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });201    if ((entriesRes as { ok: boolean }).ok) {202      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];203      setCanaryEntries(204        entries205          .filter((e) => e.canary?.webhookUrl)206          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))207      );208    }209210    chrome.runtime.onMessage.addListener((msg) => {211      if (msg.type === "SYNC_STATE_UPDATE") {212        setPairedDevices(msg.pairedDevices ?? []);213        setTrustedNetworks(msg.trustedNetworks ?? []);214        setSyncStatus(msg.status ?? "offline");215        setLastSyncAt(msg.lastSyncAt);216        setSyncProStatus(msg.isPro ?? false);217      }218    });219  });220221  async function handleChangePassword() {222    setChangePwError(null);223    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }224    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }225    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }226    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }227    setChangePwSaving(true);228    const res = await sendMessage({229      type: "CHANGE_PASSWORD",230      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },231    }) as { ok: boolean; error?: string };232    if (res.ok) {233      setChangePwSuccess(true);234      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");235      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);236    } else {237      setChangePwError(res.error ?? "failed to change password");238    }239    setChangePwSaving(false);240  }241242  async function handleActivateLicense() {243    const key = licenseKeyInput().trim();244    if (!key) return;245    setActivatingLicense(true);246    setLicenseError(null);247    setLicenseSuccess(null);248    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });249    setActivatingLicense(false);250    if ((res as { ok: boolean }).ok) {251      const data = (res as { ok: boolean; data?: LicenseInfo }).data;252      if (data?.valid) {253        setLicenseInfo(data);254        setLicenseSuccess("License activated!");255        setLicenseKeyInput("");256      } else {257        setLicenseError("Invalid license key");258      }259    } else {260      setLicenseError("Activation failed");261    }262  }263264  async function handleTogglePasskeys() {265    const current = passkeyState();266    if (!current || !current.supported) return;267    setPasskeySaving(true);268    const res = await sendMessage({269      type: "SET_PASSKEY_PROVIDER_ENABLED",270      payload: { enabled: !current.enabled },271    });272    setPasskeySaving(false);273    if ((res as { ok: boolean }).ok) {274      setPasskeyState(275        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null276      );277    }278  }279280  async function handleCheckAllBreaches() {281    setCheckingBreaches(true);282    setBreachResult(null);283    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });284    setCheckingBreaches(false);285    if ((res as { ok: boolean }).ok) {286      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};287      const breached = Object.values(data).filter((count) => count > 0).length;288      setBreachResult(289        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"290      );291      props.onBreachCheckDone();292    }293  }294295  async function handleSyncNow() {296    if (syncStatus() === "not_trusted") return;297    setSyncingNow(true);298    await sendMessage({ type: "TRIGGER_SYNC" });299    setTimeout(() => setSyncingNow(false), 3000);300  }301302  async function handleRemoveDevice(deviceId: string) {303    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });304  }305306  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {307    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });308  }309310  async function handleMarkNetworkTrusted() {311    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });312  }313314  function formatRelativeTime(timestamp?: number): string {315    if (!timestamp) return "Never";316    const diff = Date.now() - timestamp;317    const minutes = Math.floor(diff / 60000);318    const hours = Math.floor(diff / 3600000);319    const days = Math.floor(diff / 86400000);320    if (minutes < 1) return "Just now";321    if (minutes < 60) return `${minutes}m ago`;322    if (hours < 24) return `${hours}h ago`;323    return `${days}d ago`;324  }325326  function getStatusColor(status: SyncStatus): string {327    switch (status) {328      case "synced": return "var(--peach-sync-ok)";329      case "syncing": return "var(--peach-sync-busy)";330      case "no_peers": return "var(--peach-sync-off)";331      case "not_trusted": return "var(--peach-sync-err)";332      case "offline": return "var(--peach-sync-off)";333      default: return "var(--peach-sync-off)";334    }335  }336337  function getStatusLabel(status: SyncStatus): string {338    switch (status) {339      case "synced": return "Synced";340      case "syncing": return "Syncing";341      case "no_peers": return "No peers visible";342      case "not_trusted": return "Not on a trusted network";343      case "offline": return "Offline";344      default: return "Unknown";345    }346  }347348  function isCurrentNetworkTrusted(): boolean {349    return trustedNetworks().length > 0;350  }351352  async function handleSetupDuress() {353    const mainPw = duressMainPassword().trim();354    const duressPw = duressPassword().trim();355    const confirmPw = duressConfirm().trim();356357    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }358    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }359    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }360    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }361362    setDuressSaving(true);363    setDuressError(null);364365    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };366    if (!verifyRes.ok || !verifyRes.data) {367      setDuressError("incorrect main password");368      setDuressSaving(false);369      return;370    }371372    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };373    setDuressSaving(false);374    if (res.ok) {375      setDuressEnabled(true);376      setShowDuressSetup(false);377      setDuressPassword("");378      setDuressConfirm("");379      setDuressMainPassword("");380      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");381      setTimeout(() => setDuressSuccess(null), 5000);382    } else {383      setDuressError(res.error ?? "failed to set duress password");384    }385  }386387  async function handleClearDeclinedCards() {388    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });389    setDeclinedCardCount(0);390  }391392  async function handleCountDuplicates() {393    setDedupRunning(true);394    setDedupDone(null);395    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };396    setDedupRunning(false);397    if (res.ok) setDedupCount(res.data ?? 0);398  }399400  async function handleDeduplicate() {401    setDedupRunning(true);402    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };403    setDedupRunning(false);404    if (res.ok) {405      setDedupDone(res.data ?? 0);406      setDedupCount(0);407    }408  }409410  async function handleDisableDuress() {411    const mainPw = duressMainPassword().trim();412    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }413414    setDuressSaving(true);415    setDuressError(null);416417    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };418    if (!verifyRes.ok || !verifyRes.data) {419      setDuressError("incorrect main password");420      setDuressSaving(false);421      return;422    }423424    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };425    setDuressSaving(false);426    if (res.ok) {427      setDuressEnabled(false);428      setShowDuressSetup(false);429      setDuressMainPassword("");430      setDuressSuccess("Duress password disabled.");431      setTimeout(() => setDuressSuccess(null), 4000);432    } else {433      setDuressError(res.error ?? "failed to disable duress password");434    }435  }436437  async function handleSubmitFeedback() {438    if (!fbMessage().trim() || fbSubmitting()) return;439    setFbSubmitting(true);440    setFbResult(null);441    const res = await sendMessage({442      type: "SUBMIT_FEEDBACK",443      payload: {444        category: fbCategory(),445        message: fbMessage().trim(),446        version,447        browser: navigator.userAgent,448      },449    }) as { ok: boolean; error?: string };450    setFbSubmitting(false);451    if (res.ok) {452      setFbResult("success");453      setFbMessage("");454      setTimeout(() => setFbResult(null), 4000);455    } else {456      setFbResult("error");457    }458  }459460  function handleHeaderBack() {461    if (activeSection() !== null) {462      setActiveSection(null);463    } else {464      props.onBack();465    }466  }467468  function currentTitle(): string {469    const s = activeSection();470    if (s === null) return "settings";471    if (s === "license") return "account";472    return s;473  }474475  function renderCatRow(opts: {476    section: SectionName;477    name: string;478    desc: string;479    icon: () => any;480    pro?: boolean;481  }) {482    return (483      <button484        class="peach-settings-cat-row"485        onClick={() => setActiveSection(opts.section)}486      >487        <div class="peach-settings-cat-icon">{opts.icon()}</div>488        <div class="peach-settings-cat-content">489          <span class="peach-settings-cat-name">490            {opts.name}491            <Show when={opts.pro && !isPro()}>492              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>493            </Show>494          </span>495          <span class="peach-settings-cat-desc">{opts.desc}</span>496        </div>497        <svg498          class="peach-settings-cat-chevron"499          width="6" height="10" viewBox="0 0 6 10" fill="none"500          stroke="currentColor" stroke-width="1.1"501          stroke-linecap="round" stroke-linejoin="round"502        >503          <path d="M1 1l4 4-4 4" />504        </svg>505      </button>506    );507  }508509  const iconShield = () => (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="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>512    </svg>513  );514  const iconKey = () => (515    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">516      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>517    </svg>518  );519  const iconLock = () => (520    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">521      <rect x="3" y="7" width="10" height="7" rx="1.5"/>522      <path d="M6 7V5a3 3 0 016 0v2"/>523    </svg>524  );525  const iconSync = () => (526    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">527      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>528      <path d="M14 6l-2 2-2-2"/>529      <path d="M2 10l2-2 2 2"/>530    </svg>531  );532  const iconSun = () => (533    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">534      <circle cx="8" cy="8" r="3"/>535      <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"/>536    </svg>537  );538  const iconKeys = () => (539    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">540      <rect x="2" y="5" width="5" height="4" rx="1"/>541      <rect x="9" y="7" width="5" height="4" rx="1"/>542      <path d="M7 7h2m-2 2h2"/>543    </svg>544  );545  const iconBubble = () => (546    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">547      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>548    </svg>549  );550551  function renderHome() {552    return (553      <div class="peach-settings-body">554        <p class="peach-settings-nav-label">PROTECTION</p>555        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}556        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}557558        <p class="peach-settings-nav-label">VAULT</p>559        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}560        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}561562        <p class="peach-settings-nav-label">PREFERENCES</p>563        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}564        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}565566        <p class="peach-settings-nav-label">SUPPORT</p>567        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}568569        <div class="peach-settings-footer">570          <span>Peach {version}</span>571          <span class="peach-divider"></span>572          <span>Browser extension</span>573        </div>574      </div>575    );576  }577578  function renderSecurity() {579    return (580      <div class="peach-settings-body">581        <div class="peach-settings-group">582          <p class="peach-settings-sub-label">LOCKING</p>583          <div class="peach-settings-row">584            <span>Lock on close</span>585            <button586              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}587              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}588              aria-label="Toggle lock on close"589            >590              <span class="peach-toggle-knob"></span>591            </button>592          </div>593          <div class="peach-settings-row">594            <span>Auto-lock after</span>595            <select596              class="peach-settings-select"597              value={String(props.autoLockMinutes)}598              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}599            >600              <option value="1">1 minute</option>601              <option value="5">5 minutes</option>602              <option value="15">15 minutes</option>603              <option value="30">30 minutes</option>604              <option value="0">Never</option>605            </select>606          </div>607608          <p class="peach-settings-sub-label">BREACH PROTECTION</p>609          <div class="peach-settings-row">610            <span>Breach monitoring</span>611            <button612              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}613              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}614              aria-label="Toggle breach monitoring"615            >616              <span class="peach-toggle-knob"></span>617            </button>618          </div>619          <div class="peach-settings-row peach-settings-row--meta">620            <span>Check all passwords</span>621            <button622              class="peach-btn peach-btn--subtle peach-btn--sm"623              onClick={() => void handleCheckAllBreaches()}624              disabled={checkingBreaches()}625            >626              {checkingBreaches() ? "checking..." : "run"}627            </button>628          </div>629          <Show when={breachResult()}>630            <div class="peach-settings-row peach-settings-row--meta">631              <span>Last check</span>632              <span class="peach-settings-value">{breachResult()}</span>633            </div>634          </Show>635636          <p class="peach-settings-sub-label">MASTER PASSWORD</p>637          <div class="peach-settings-row peach-settings-row--meta">638            <span>Master password</span>639            <button640              class="peach-btn peach-btn--subtle peach-btn--sm"641              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}642            >643              {showChangePw() ? "cancel" : "change"}644            </button>645          </div>646          <Show when={showChangePw()}>647            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">648              <input649                class="peach-settings-input"650                type="password"651                placeholder="current password"652                value={changePwCurrent()}653                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}654                autocomplete="current-password"655              />656              <input657                class="peach-settings-input"658                type="password"659                placeholder="new password (min 12 chars)"660                value={changePwNew()}661                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}662                autocomplete="new-password"663              />664              <input665                class="peach-settings-input"666                type="password"667                placeholder="confirm new password"668                value={changePwConfirm()}669                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}670                autocomplete="new-password"671                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}672              />673              <Show when={changePwError()}>674                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>675              </Show>676              <Show when={changePwSuccess()}>677                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>678              </Show>679              <button680                class="peach-btn peach-btn--accent peach-btn--sm"681                style="align-self:flex-start"682                onClick={() => void handleChangePassword()}683                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}684              >685                {changePwSaving() ? "saving…" : "save new password"}686              </button>687            </div>688          </Show>689690          <p class="peach-settings-sub-label">DURESS PASSWORD</p>691          <div class="peach-settings-row peach-settings-row--top">692            <div class="peach-settings-row-main">693              <span>Duress password</span>694              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">695                If entered under coercion, shows a decoy empty vault instead of your real one.696              </span>697            </div>698            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>699              {duressEnabled() ? "on" : "off"}700            </div>701          </div>702703          <Show when={duressEnabled() && !showDuressSetup()}>704            <div class="peach-settings-row peach-settings-row--meta">705              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">706                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.707              </span>708            </div>709          </Show>710711          <Show when={duressSuccess()}>712            <div class="peach-settings-row peach-settings-row--meta">713              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>714            </div>715          </Show>716717          <Show when={!showDuressSetup()}>718            <div class="peach-settings-row peach-settings-row--meta">719              <button720                class="peach-btn peach-btn--subtle peach-btn--sm"721                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}722              >723                {duressEnabled() ? "Change / disable" : "Set up"}724              </button>725            </div>726          </Show>727728          <Show when={showDuressSetup()}>729            <div class="peach-duress-form">730              <label class="peach-label">main password (to confirm)</label>731              <input732                class="peach-settings-input"733                type="password"734                placeholder="your current master password"735                autocomplete="current-password"736                value={duressMainPassword()}737                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}738              />739              <Show when={!duressEnabled()}>740                <label class="peach-label">new duress password</label>741                <input742                  class="peach-settings-input"743                  type="password"744                  placeholder="duress password (min 8 chars)"745                  autocomplete="new-password"746                  value={duressPassword()}747                  onInput={(e) => setDuressPassword(e.currentTarget.value)}748                />749                <label class="peach-label">confirm duress password</label>750                <input751                  class="peach-settings-input"752                  type="password"753                  placeholder="confirm duress password"754                  autocomplete="new-password"755                  value={duressConfirm()}756                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}757                />758              </Show>759760              <Show when={duressError()}>761                <p class="peach-hint peach-hint-error">{duressError()}</p>762              </Show>763764              <div class="peach-duress-actions">765                <button766                  class="peach-btn peach-btn--subtle peach-btn--sm"767                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}768                >769                  cancel770                </button>771                <Show when={duressEnabled()}>772                  <button773                    class="peach-btn peach-btn--ghost peach-btn--sm"774                    style="color:var(--peach-breach)"775                    onClick={() => void handleDisableDuress()}776                    disabled={duressSaving()}777                  >778                    {duressSaving() ? "disabling..." : "disable duress"}779                  </button>780                </Show>781                <Show when={!duressEnabled()}>782                  <button783                    class="peach-btn peach-btn--accent peach-btn--sm"784                    onClick={() => void handleSetupDuress()}785                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}786                  >787                    {duressSaving() ? "saving..." : "save"}788                  </button>789                </Show>790              </div>791            </div>792          </Show>793794          <p class="peach-settings-sub-label">CANARY ENTRIES</p>795          <div class="peach-settings-row peach-settings-row--top">796            <div class="peach-settings-row-main">797              <span>Canary entries</span>798              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">799                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.800              </span>801            </div>802            <span class="peach-settings-value" style="flex-shrink:0">803              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}804            </span>805          </div>806          <Show when={canaryEntries().length > 0}>807            <For each={canaryEntries()}>808              {(entry) => (809                <div class="peach-settings-row peach-settings-row--meta">810                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>811                  <button812                    class="peach-btn peach-btn--subtle peach-btn--sm"813                    onClick={() => void handleTestCanary(entry)}814                    disabled={canaryTestingId() === entry.id}815                    style="flex-shrink:0"816                  >817                    {canaryTestingId() === entry.id818                      ? "sending…"819                      : canaryTestResult()?.id === entry.id820                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")821                        : "test"}822                  </button>823                </div>824              )}825            </For>826          </Show>827828          <p class="peach-settings-sub-label">PASSKEYS</p>829          <div class="peach-settings-row peach-settings-row--top">830            <div class="peach-settings-row-main">831              <span>Intercept passkey requests</span>832              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">833                Peach can handle passkey sign-in and creation on supported Chromium browsers.834              </span>835            </div>836            <button837              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}838              onClick={() => void handleTogglePasskeys()}839              aria-label="Toggle passkey interception"840              disabled={!passkeyState()?.supported || passkeySaving()}841            >842              <span class="peach-toggle-knob"></span>843            </button>844          </div>845          <div class="peach-settings-row peach-settings-row--meta">846            <span>Status</span>847            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>848          </div>849          <div class="peach-settings-row peach-settings-row--meta">850            <span>Browser</span>851            <span class="peach-settings-value">852              {passkeyState()?.browserLabel ?? "Unknown"}853              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}854            </span>855          </div>856          <div class="peach-info-notice" style="margin-top:8px">857            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.858          </div>859          <Show when={passkeyState()?.conflict}>860            <div class="peach-settings-row peach-settings-row--meta">861              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>862            </div>863          </Show>864        </div>865      </div>866    );867  }868869  function renderVault() {870    return (871      <div class="peach-settings-body">872        <div class="peach-settings-group">873          <p class="peach-settings-sub-label">ENTRIES</p>874          <div class="peach-settings-row peach-settings-row--meta">875            <span>Entries</span>876            <span class="peach-settings-value">{props.entriesCount}</span>877          </div>878          <div class="peach-settings-row peach-settings-row--meta">879            <span>Duplicates</span>880            <Show when={dedupDone() !== null} fallback={881              <button882                class="peach-btn peach-btn--subtle peach-btn--sm"883                onClick={() => void handleCountDuplicates()}884                disabled={dedupRunning()}885              >886                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}887              </button>888            }>889              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">890                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}891              </span>892            </Show>893          </div>894          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>895            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">896              <div class="peach-restore-warning">897                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.898              </div>899              <div style="display:flex;gap:6px">900                <button901                  class="peach-btn peach-btn--subtle peach-btn--sm"902                  onClick={() => setDedupCount(null)}903                >904                  cancel905                </button>906                <button907                  class="peach-btn peach-btn--accent peach-btn--sm"908                  onClick={() => void handleDeduplicate()}909                  disabled={dedupRunning()}910                >911                  {dedupRunning() ? "removing..." : "remove duplicates"}912                </button>913              </div>914            </div>915          </Show>916917          <p class="peach-settings-sub-label">MAINTENANCE</p>918          <div class="peach-settings-row peach-settings-row--meta">919            <span>Trash auto-delete</span>920            <select921              class="peach-settings-select"922              value={String(props.trashRetentionDays)}923              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}924            >925              <option value="7">7 days</option>926              <option value="30">30 days</option>927              <option value="90">90 days</option>928              <option value="0">Never</option>929            </select>930          </div>931          <div class="peach-settings-row peach-settings-row--meta">932            <span>Vault Health</span>933            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>934              open935            </button>936          </div>937          <Show when={declinedCardCount() > 0}>938            <div class="peach-settings-row peach-settings-row--meta">939              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>940              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>941                Clear942              </button>943            </div>944          </Show>945946          <p class="peach-settings-sub-label">DATA</p>947          <div class="peach-settings-row peach-settings-row--meta">948            <span>Import passwords</span>949            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>950              open951            </button>952          </div>953          <div class="peach-settings-row peach-settings-row--meta">954            <span>Export backup</span>955            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>956              open957            </button>958          </div>959          <div class="peach-danger-card">960            <div class="peach-danger-card-head">961              <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>962              <h3 class="peach-danger-card-title">Replace vault from backup</h3>963            </div>964            <p class="peach-danger-card-copy">965              Wipes the current vault and restores from a backup file.966              Recoverable only if your backup is good.967            </p>968            <button969              class="peach-danger-card-button"970              onClick={() => props.onOpenFileImport?.()}971            >972              Choose backup file973            </button>974          </div>975976          <Show when={props.onOpenPanic}>977            <div class="peach-danger-card peach-danger-card--hazard">978              <div class="peach-danger-card-head">979                <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>980                <h3 class="peach-danger-card-title">Destroy vault</h3>981              </div>982              <p class="peach-danger-card-copy">983                Permanently wipes every entry, setting, and license on this984                device. There is no recovery.985              </p>986              <button987                class="peach-danger-card-button"988                onClick={() => props.onOpenPanic?.()}989              >990                Begin destruction991              </button>992            </div>993          </Show>994        </div>995      </div>996    );997  }998999  function renderSync() {1000    if (!isPro()) {1001      return (1002        <div class="peach-settings-body">1003          <div class="peach-pro-upgrade">1004            <div class="peach-pro-upgrade-hero">1005              <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">1006                <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1007                <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1008                <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"/>1009                <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"/>1010                <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>1011                <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1012                <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1013              </svg>1014              <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1015              <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1016            </div>10171018            <div class="peach-pro-features">1019              <div class="peach-pro-feature">1020                <span class="peach-pro-feature-mark">✦</span>1021                <div class="peach-pro-feature-text">1022                  <span class="peach-pro-feature-name">Always in sync</span>1023                  <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1024                </div>1025              </div>1026              <div class="peach-pro-feature">1027                <span class="peach-pro-feature-mark">✦</span>1028                <div class="peach-pro-feature-text">1029                  <span class="peach-pro-feature-name">No cloud. No relay.</span>1030                  <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1031                </div>1032              </div>1033              <div class="peach-pro-feature">1034                <span class="peach-pro-feature-mark">✦</span>1035                <div class="peach-pro-feature-text">1036                  <span class="peach-pro-feature-name">Tailscale supported</span>1037                  <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1038                </div>1039              </div>1040              <div class="peach-pro-feature">1041                <span class="peach-pro-feature-mark">✦</span>1042                <div class="peach-pro-feature-text">1043                  <span class="peach-pro-feature-name">Zero friction after setup</span>1044                  <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1045                </div>1046              </div>1047            </div>10481049            <button1050              class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1051              onClick={() => setActiveSection("license")}1052            >1053              Upgrade to Pro1054            </button>1055            <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1056              Already have a key? Enter it →1057            </button>1058          </div>1059        </div>1060      );1061    }10621063    return (1064      <div class="peach-settings-body">1065        <div class="peach-settings-group">1066          <div class="peach-settings-row">1067            <span>Status</span>1068            <span class="peach-settings-value peach-settings-value--status">1069              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1070              {getStatusLabel(syncStatus())}1071            </span>1072          </div>1073          <div class="peach-settings-row peach-settings-row--meta">1074            <span>Last sync</span>1075            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1076          </div>1077          <div class="peach-settings-row peach-settings-row--meta">1078            <button1079              class="peach-btn peach-btn--subtle peach-btn--sm"1080              onClick={() => void handleSyncNow()}1081              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1082            >1083              {syncingNow() ? "Syncing..." : "Sync now"}1084            </button>1085          </div>10861087          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1088            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1089            {pairedDevices().map((device) => (1090              <div class="peach-sync-device-row">1091                <div class="peach-sync-device-info">1092                  <span class="peach-sync-device-name">{device.displayName}</span>1093                  <span class="peach-sync-device-meta">1094                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1095                  </span>1096                </div>1097                <button1098                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1099                  onClick={() => void handleRemoveDevice(device.deviceId)}1100                >1101                  Remove1102                </button>1103              </div>1104            ))}1105            <div class="peach-settings-actions--row">1106              <button1107                class="peach-btn peach-btn--subtle peach-btn--sm"1108                onClick={() => props.onOpenSyncPairing?.("initiate")}1109              >1110                Pair a new device1111              </button>1112              <button1113                class="peach-btn peach-btn--subtle peach-btn--sm"1114                onClick={() => props.onOpenSyncPairing?.("accept")}1115              >1116                Pair with another device1117              </button>1118            </div>1119          </div>11201121          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1122            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1123            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1124              <div class="peach-sync-device-row">1125                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1126                <button1127                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1128                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1129                >1130                  Remove1131                </button>1132              </div>1133            ))}1134            <div class="peach-settings-actions--row">1135              <Show when={!isCurrentNetworkTrusted()}>1136                <button1137                  class="peach-btn peach-btn--subtle peach-btn--sm"1138                  onClick={() => void handleMarkNetworkTrusted()}1139                >1140                  Mark current network as trusted1141                </button>1142              </Show>1143            </div>1144            <span class="peach-sync-device-meta">1145              Tailscale is always trusted1146            </span>1147          </div>1148        </div>1149      </div>1150    );1151  }11521153  function renderAppearance() {1154    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1155      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1156      { id: "cormorant", label: "Cormorant", preview: "Aa" },1157      { id: "inter", label: "Inter", preview: "Aa" },1158      { id: "system", label: "System", preview: "Aa" },1159    ];1160    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1161      { id: "small", label: "Small" },1162      { id: "medium", label: "Default" },1163      { id: "large", label: "Large" },1164    ];1165    return (1166      <div class="peach-settings-body">1167        <div class="peach-settings-group">1168          <p class="peach-settings-sub-label">THEME</p>1169          <div class="peach-settings-row peach-settings-row--meta">1170            <span>Theme</span>1171            <button1172              class="peach-btn peach-btn--subtle peach-btn--sm"1173              onClick={() =>1174                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1175              }1176            >1177              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1178            </button>1179          </div>11801181          <p class="peach-settings-sub-label">ACCENT</p>1182          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1183            <span>Accent</span>1184            <div class="peach-accent-picker">1185              <For each={ACCENT_COLORS}>1186                {(color) => (1187                  <button1188                    class="peach-accent-swatch"1189                    classList={{ "is-active": props.accentColor === color.name }}1190                    style={{ background: color.hex }}1191                    onClick={() => void props.onAccentChange(color.name)}1192                    aria-label={color.name}1193                    title={color.name}1194                  />1195                )}1196              </For>1197            </div>1198          </div>11991200          <p class="peach-settings-sub-label">DISPLAY FONT</p>1201          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1202            <span>Font</span>1203            <div class="peach-font-picker">1204              <For each={fontOptions}>1205                {(opt) => (1206                  <button1207                    class="peach-font-swatch"1208                    classList={{ "is-active": props.displayFont === opt.id }}1209                    data-font-preview={opt.id}1210                    onClick={() => void props.onDisplayFontChange(opt.id)}1211                    aria-label={opt.label}1212                    title={opt.label}1213                  >1214                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1215                    <span class="peach-font-swatch-name">{opt.label}</span>1216                  </button>1217                )}1218              </For>1219            </div>1220          </div>12211222          <p class="peach-settings-sub-label">TEXT SIZE</p>1223          <div class="peach-settings-row peach-settings-row--meta">1224            <span>Size</span>1225            <div class="peach-pill-group">1226              <For each={sizeOptions}>1227                {(opt) => (1228                  <button1229                    class="peach-btn peach-btn--ghost peach-btn--sm"1230                    classList={{ "is-active": props.textSize === opt.id }}1231                    onClick={() => void props.onTextSizeChange(opt.id)}1232                  >1233                    {opt.label}1234                  </button>1235                )}1236              </For>1237            </div>1238          </div>12391240          <p class="peach-settings-sub-label">MOTION</p>1241          <div class="peach-settings-row peach-settings-row--meta">1242            <span>Reduce motion</span>1243            <button1244              class="peach-btn peach-btn--subtle peach-btn--sm"1245              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1246            >1247              {props.reduceMotion ? "On" : "Off"}1248            </button>1249          </div>1250        </div>1251      </div>1252    );1253  }12541255  function renderShortcuts() {1256    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1257    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12581259    function startCapture(action: ShortcutAction) {1260      setConflictMsg(null);1261      setCapturingAction(action);1262    }1263    function cancelCapture() {1264      setCapturingAction(null);1265      setConflictMsg(null);1266    }1267    function commitBinding(action: ShortcutAction, binding: Keybinding) {1268      const conflict = findConflict(props.shortcuts, binding, action);1269      if (conflict) {1270        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1271        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1272        return;1273      }1274      const next = { ...props.shortcuts, [action]: binding };1275      setCapturingAction(null);1276      setConflictMsg(null);1277      void props.onShortcutsChange(next);1278    }1279    function resetAction(action: ShortcutAction) {1280      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1281      void props.onShortcutsChange(next);1282    }1283    function resetAll() {1284      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1285    }12861287    // Capture-mode key handler — wired via ref onto the chip element so it1288    // catches the next non-modifier keypress and ignores other bindings.1289    function handleCaptureKey(e: KeyboardEvent) {1290      e.preventDefault();1291      e.stopPropagation();1292      if (e.key === "Escape") { cancelCapture(); return; }1293      const binding = keybindingFromEvent(e);1294      if (!binding) return; // bare modifier — keep waiting1295      const action = capturingAction();1296      if (action) commitBinding(action, binding);1297    }12981299    return (1300      <div class="peach-settings-body">1301        <div class="peach-settings-group peach-shortcuts-group">1302          <p class="peach-settings-sub-label">ENTRY VIEW</p>1303          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1304            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1305          </For>1306        </div>13071308        <div class="peach-settings-group peach-shortcuts-group">1309          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1310          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1311            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1312          </For>1313        </div>13141315        <Show when={conflictMsg()}>1316          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1317        </Show>13181319        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1320          <div class="peach-settings-row peach-settings-row--meta">1321            <span>Inline autofill on page</span>1322            <a1323              class="peach-settings-link"1324              href="chrome://extensions/shortcuts"1325              onClick={(e) => {1326                e.preventDefault();1327                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1328              }}1329            >1330              ⌘⇧P — customize ↗1331            </a>1332          </div>1333          <button1334            class="peach-btn peach-btn--subtle peach-btn--sm"1335            style="align-self:flex-start"1336            onClick={resetAll}1337          >1338            Reset all to defaults1339          </button>1340        </div>1341      </div>1342    );13431344    function renderRow(1345      desc: typeof SHORTCUT_DESCRIPTORS[number],1346      capturing: () => ShortcutAction | null,1347      onStart: (a: ShortcutAction) => void,1348      onCancel: () => void,1349      onReset: (a: ShortcutAction) => void,1350      onKey: (e: KeyboardEvent) => void,1351    ) {1352      const isActive = () => capturing() === desc.action;1353      const binding = () => props.shortcuts[desc.action];1354      const isCustom = () => {1355        const b = binding();1356        const d = DEFAULT_BINDINGS[desc.action];1357        return b.key !== d.key1358          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1359          || Boolean(b.meta)  !== Boolean(d.meta)1360          || Boolean(b.shift) !== Boolean(d.shift)1361          || Boolean(b.alt)   !== Boolean(d.alt);1362      };1363      return (1364        <div class="peach-shortcut-row">1365          <span class="peach-shortcut-row-label">{desc.label}</span>1366          <button1367            class="peach-shortcut-chip"1368            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1369            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1370            onKeyDown={(e) => { if (isActive()) onKey(e); }}1371            ref={(el) => {1372              // Auto-focus when entering capture mode so the keydown listener catches the next press.1373              if (el) {1374                queueMicrotask(() => { if (isActive()) el.focus(); });1375              }1376            }}1377          >1378            {isActive() ? "Press a key…" : formatKeybinding(binding())}1379          </button>1380          <Show when={isCustom() && !isActive()}>1381            <button1382              class="peach-shortcut-reset"1383              title="Reset to default"1384              onClick={() => onReset(desc.action)}1385            >1386              ↺1387            </button>1388          </Show>1389        </div>1390      );1391    }1392  }13931394  const PRO_FEATURES: Array<{1395    feature: ProFeature;1396    name: string;1397    desc: string;1398  }> = [1399    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1400    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1401    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1402    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1403    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1404  ];14051406  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1407  const [deactivating, setDeactivating] = createSignal(false);14081409  async function handleDeactivateLicense() {1410    setDeactivating(true);1411    await sendMessage({ type: "DEACTIVATE_LICENSE" });1412    setLicenseInfo(null);1413    await props.onLicenseChange?.();1414    setDeactivating(false);1415  }14161417  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1418    setCanaryTestingId(entry.id);1419    setCanaryTestResult(null);1420    const res = await sendMessage({1421      type: "FIRE_CANARY_PING",1422      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1423    }) as { ok: boolean };1424    setCanaryTestResult({ id: entry.id, ok: res.ok });1425    setCanaryTestingId(null);1426    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1427  }14281429  function renderLicense() {1430    async function onActivate() {1431      await handleActivateLicense();1432      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1433        await props.onLicenseChange?.();1434      }1435    }14361437    return (1438      <div class="peach-settings-body">1439        <Show1440          when={isPro()}1441          fallback={1442            <Show1443              when={showKeyEntry()}1444              fallback={1445                <div class="peach-pro-upgrade">1446                  <div class="peach-pro-upgrade-hero">1447                    <div class="peach-pro-upgrade-mark-wrap">1448                      <PeachMark class="peach-pro-upgrade-mark" />1449                    </div>1450                    <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1451                    <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1452                  </div>14531454                  <div class="peach-pro-features">1455                    <For each={PRO_FEATURES}>1456                      {(item) => (1457                        <div1458                          class="peach-pro-feature"1459                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1460                        >1461                          <span class="peach-pro-feature-mark">✦</span>1462                          <div class="peach-pro-feature-text">1463                            <span class="peach-pro-feature-name">{item.name}</span>1464                            <span class="peach-pro-feature-desc">{item.desc}</span>1465                          </div>1466                        </div>1467                      )}1468                    </For>1469                  </div>14701471                  <div class="peach-pro-upgrade-actions">1472                    <p class="peach-pro-pricing">$39 · once · yours forever</p>1473                    <a1474                      href="https://peachpasswords.com/#pricing"1475                      target="_blank"1476                      rel="noopener noreferrer"1477                      class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1478                    >1479                      Get Peach Pro1480                    </a>1481                    <div class="peach-pro-key-divider"><span>or</span></div>1482                    <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1483                      Activate a license key1484                    </button>1485                  </div>1486                </div>1487              }1488            >1489              <div class="peach-pro-key-ceremony">1490                <LockRuneCanvas />1491                <p class="peach-pro-key-ceremony-label">Enter your license key</p>1492                <input1493                  class="peach-underline-input peach-pro-key-input"1494                  type="text"1495                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1496                  value={licenseKeyInput()}1497                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1498                  onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1499                  autocomplete="off"1500                  spellcheck={false}1501                />1502                <Show when={licenseError()}>1503                  <p class="peach-pro-key-error">That key doesn't look right.</p>1504                </Show>1505                <button1506                  class="peach-btn peach-btn--accent peach-pro-key-activate"1507                  onClick={() => void onActivate()}1508                  disabled={activatingLicense() || !licenseKeyInput().trim()}1509                >1510                  {activatingLicense() ? "activating…" : "Activate"}1511                </button>1512                <button1513                  class="peach-pro-key-cancel"1514                  onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1515                >1516                  cancel1517                </button>1518              </div>1519            </Show>1520          }1521        >1522          <div class="peach-pro-active">1523            <div class="peach-pro-active-hero">1524              <div class="peach-pro-active-mark-wrap">1525                <PeachMark class="peach-pro-active-mark" />1526              </div>1527              <h2 class="peach-pro-active-title">Peach Pro</h2>1528              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1529            </div>15301531            <div class="peach-pro-active-features">1532              <For each={PRO_FEATURES}>1533                {(item) => (1534                  <div class="peach-pro-active-feature">1535                    <span class="peach-pro-active-feature-mark">✦</span>1536                    <span class="peach-pro-active-feature-name">{item.name}</span>1537                  </div>1538                )}1539              </For>1540            </div>15411542            <button1543              class="peach-pro-deactivate"1544              onClick={() => void handleDeactivateLicense()}1545              disabled={deactivating()}1546            >1547              {deactivating() ? "Deactivating…" : "Deactivate"}1548            </button>1549          </div>1550        </Show>1551      </div>1552    );1553  }15541555  function renderFeedback() {1556    return (1557      <div class="peach-settings-body">1558        <div class="peach-settings-group peach-feedback-group">1559          <p class="peach-feedback-intro">1560            Help us improve Peach. Every report goes directly to our inbox.1561          </p>15621563          <p class="peach-settings-sub-label">TYPE</p>1564          <div class="peach-feedback-chips">1565            <For each={FEEDBACK_CATEGORIES}>1566              {(cat) => (1567                <button1568                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1569                  onClick={() => setFbCategory(cat)}1570                >1571                  {cat}1572                </button>1573              )}1574            </For>1575          </div>15761577          <p class="peach-settings-sub-label">MESSAGE</p>1578          <textarea1579            class="peach-feedback-textarea"1580            placeholder="Describe the issue or idea..."1581            value={fbMessage()}1582            onInput={(e) => setFbMessage(e.currentTarget.value)}1583          />15841585          <p class="peach-feedback-meta">1586            Version {version} · will be included automatically1587          </p>15881589          <Show when={fbResult() === "success"}>1590            <p class="peach-feedback-result peach-feedback-result--ok">1591              Sent — thank you1592            </p>1593          </Show>1594          <Show when={fbResult() === "error"}>1595            <p class="peach-feedback-result peach-feedback-result--err">1596              Failed to send — try again1597            </p>1598          </Show>15991600          <button1601            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1602            onClick={() => void handleSubmitFeedback()}1603            disabled={fbSubmitting() || !fbMessage().trim()}1604          >1605            {fbSubmitting() ? "Sending..." : "Send feedback"}1606          </button>16071608          <p class="peach-feedback-anon">1609            Sent anonymously · no account required1610          </p>1611        </div>1612      </div>1613    );1614  }16151616  return (1617    <div class="peach-overlay-view ov-enter-right" style="display:flex">1618      <div class="peach-overlay-header">1619        <button1620          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1621          onClick={handleHeaderBack}1622          aria-label="Back"1623        >1624          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1625            <path d="M6 1L1 6L6 11" />1626          </svg>1627        </button>1628        <span class="peach-overlay-title">{currentTitle()}</span>1629        <div class="peach-overlay-spacer"></div>1630      </div>16311632      <Switch fallback={renderHome()}>1633        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1634        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1635        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1636        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1637        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1638        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1639        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1640      </Switch>1641    </div>1642  );1643}