Settings.tsx

68.9 KB · raw

1import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10  AccentColorName,11  PasskeyProviderState,12  ProFeature,13  VaultSnapshot,14} from "../../shared/types";15import {16  DEFAULT_BINDINGS,17  findConflict,18  formatKeybinding,19  keybindingFromEvent,20  SHORTCUT_DESCRIPTORS,21  type Keybinding,22  type ShortcutAction,23} from "../../shared/shortcuts";24import { isNativePlatform, nativePlugin } from "../../shared/native";2526interface LicenseInfo {27  valid: boolean;28  plan: "free" | "pro";29  issued?: string;30}3132interface SettingsProps {33  entriesCount: number;34  breachMonitoring: boolean;35  lockOnClose: boolean;36  autoLockMinutes: number;37  trashRetentionDays: number;38  theme: "dark" | "light";39  accentColor: AccentColorName;40  textSize: "small" | "medium" | "large";41  reduceMotion: boolean;42  isPro?: boolean;43  initialSection?: string | null;44  upgradeHighlight?: ProFeature | null;45  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;46  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;47  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;48  onTrashRetentionChange: (days: number) => void | Promise<void>;49  onThemeChange: (theme: "dark" | "light") => void;50  onAccentChange: (name: AccentColorName) => void | Promise<void>;51  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;52  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;53  shortcuts: Record<ShortcutAction, Keybinding>;54  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;55  onLicenseChange?: () => void | Promise<void>;56  isExiting?: boolean;57  onBack: () => void;58  onImport: () => void;59  onExport: () => void;60  onBreachCheckDone: () => void;61  onOpenHealth?: () => void;62  onOpenSyncPairing?: () => void;63  onOpenFileImport?: () => void;64  onOpenPanic?: () => void;65  onOpenCodexGenerate?: () => void;66  onOpenCodexRestore?: () => void;67  /** Invoked from within Settings when a Pro-gated control is tapped on68   *  free tier. App owns the upgrade navigation + highlight state, so this69   *  callback is the single way for Settings to request that navigation. */70  onProGate?: (feature: ProFeature) => void;71}7273const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7475function formatActivatedSince(issued: string | undefined): string {76  if (!issued) return "Active";77  try {78    const d = new Date(issued);79    if (Number.isNaN(d.getTime())) return "Active";80    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;81  } catch {82    return "Active";83  }84}8586function initialSectionToCard(value: string | null | undefined): CardId | null {87  if (!value) return null;88  if (value === "security") return "protection";89  if (value === "license") return "pro";90  if (91    value === "protection" ||92    value === "vault" ||93    value === "sync" ||94    value === "appearance" ||95    value === "shortcuts" ||96    value === "pro" ||97    value === "feedback"98  ) {99    return value;100  }101  return null;102}103104export function SettingsView(props: SettingsProps) {105  const manifest = chrome.runtime.getManifest();106  const version = manifest.version;107  const initial = props.initialSection;108  const [activeCard, setActiveCard] = createSignal<CardId>(109    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"110  );111112  const [checkingBreaches, setCheckingBreaches] = createSignal(false);113  const [breachResult, setBreachResult] = createSignal<string | null>(null);114  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);115  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");116  const [licenseError, setLicenseError] = createSignal<string | null>(null);117  const [, setLicenseSuccess] = createSignal<string | null>(null);118  const [activatingLicense, setActivatingLicense] = createSignal(false);119120  const isPro = () => props.isPro ?? false;121  const cards = createMemo<StackCardSpec[]>(() => [122    {123      id: "protection",124      num: "I · PROTECT",125      title: "Protection",126      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,127      bgVar: "--stack-protect-bg",128      fgVar: "--stack-protect-fg",129      icon: "shield",130    },131    {132      id: "vault",133      num: "II · KEEP",134      title: "Vault",135      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,136      bgVar: "--stack-vault-bg",137      fgVar: "--stack-vault-fg",138      icon: "lock",139    },140    {141      id: "sync",142      num: "III · ECHO",143      title: "Sync",144      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",145      bgVar: "--stack-sync-bg",146      fgVar: "--stack-sync-fg",147      icon: "sync",148    },149    {150      id: "appearance",151      num: "IV · DRESS",152      title: "Appearance",153      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,154      bgVar: "--stack-appear-bg",155      fgVar: "--stack-appear-fg",156      icon: "sun",157    },158    {159      id: "shortcuts",160      num: "V · KEYS",161      title: "Shortcuts",162      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,163      bgVar: "--stack-shortcut-bg",164      fgVar: "--stack-shortcut-fg",165      icon: "keys",166    },167    {168      id: "pro",169      num: "VI · FOREVER",170      title: "Peach Pro",171      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",172      bgVar: "--stack-pro-bg",173      fgVar: "--stack-pro-fg",174      icon: "spark",175    },176    {177      id: "feedback",178      num: "VII · ECHO",179      title: "Feedback",180      lede: `version ${version} · tell us what would make it better`,181      bgVar: "--stack-feedback-bg",182      fgVar: "--stack-feedback-fg",183      icon: "bubble",184    },185  ]);186  const [biometricSupported, setBiometricSupported] = createSignal(false);187  const [biometricStored, setBiometricStored] = createSignal(false);188  const [biometricBusy, setBiometricBusy] = createSignal(false);189  const [biometricError, setBiometricError] = createSignal<string | null>(null);190  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);191  const [passkeySaving, setPasskeySaving] = createSignal(false);192193  const [dedupCount, setDedupCount] = createSignal<number | null>(null);194  const [dedupRunning, setDedupRunning] = createSignal(false);195  const [dedupDone, setDedupDone] = createSignal<number | null>(null);196197  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);198199  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted200  // BEFORE every real-mode vault write so the user can roll back if a sync201  // or import overwrites the wrong state.202  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);203  const [historyLoading, setHistoryLoading] = createSignal(false);204  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);205  const [historyOpen, setHistoryOpen] = createSignal(false);206207  async function loadVaultHistory() {208    setHistoryLoading(true);209    try {210      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });211      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];212      setHistorySnapshots(list);213    } finally {214      setHistoryLoading(false);215    }216  }217218  async function handleRestoreVaultSnapshot(id: string) {219    if (historyRestoring()) return;220    setHistoryRestoring(id);221    const res = await sendMessage({222      type: "RESTORE_VAULT_SNAPSHOT",223      payload: { snapshotId: id },224    });225    setHistoryRestoring(null);226    if ((res as { ok: boolean }).ok) {227      // Vault is now locked — let the parent close Settings; App.tsx's228      // VAULT_LOCKED listener will route back to the lock screen.229      props.onBack();230    }231  }232233  function formatSnapshotTime(savedAt: number): string {234    const diff = Date.now() - savedAt;235    if (diff < 60_000) return "Just now";236    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;237    const d = new Date(savedAt);238    const isToday = new Date().toDateString() === d.toDateString();239    const isYest =240      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();241    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });242    if (isToday) return `Today ${t}`;243    if (isYest) return `Yesterday ${t}`;244    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;245  }246247  function reasonLabel(reason: VaultSnapshot["reason"]): string {248    return (249      {250        local_save: "saved",251        sync_received: "synced",252        import: "imported",253        restore: "restored",254      } as const255    )[reason] ?? reason;256  }257258  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);259260  const [showChangePw, setShowChangePw] = createSignal(false);261  const [changePwCurrent, setChangePwCurrent] = createSignal("");262  const [changePwNew, setChangePwNew] = createSignal("");263  const [changePwConfirm, setChangePwConfirm] = createSignal("");264  const [changePwSaving, setChangePwSaving] = createSignal(false);265  const [changePwError, setChangePwError] = createSignal<string | null>(null);266  const [changePwSuccess] = createSignal(false);267  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);268269  const [duressEnabled, setDuressEnabled] = createSignal(false);270  const [showDuressSetup, setShowDuressSetup] = createSignal(false);271  const [duressPassword, setDuressPassword] = createSignal("");272  const [duressConfirm, setDuressConfirm] = createSignal("");273  const [duressMainPassword, setDuressMainPassword] = createSignal("");274  const [duressSaving, setDuressSaving] = createSignal(false);275  const [duressError, setDuressError] = createSignal<string | null>(null);276  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);277278  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);279280  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);281  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);282283  const [fbCategory, setFbCategory] = createSignal("Bug report");284  const [fbMessage, setFbMessage] = createSignal("");285  const [fbSubmitting, setFbSubmitting] = createSignal(false);286  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);287288  createEffect(() => {289    if (props.upgradeHighlight) {290      setActiveCard("pro");291    }292  });293294  onMount(async () => {295    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([296      sendMessage({ type: "GET_LICENSE" }),297      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),298      sendMessage({ type: "IS_DURESS_ENABLED" }),299      sendMessage({ type: "GET_SETTINGS" }),300      sendMessage({ type: "GET_LICENSE_KEY" }),301    ]);302303    // Vault history loads in the background — the Vault sub-view picks it up304    // when the user navigates in. Loading it lazily keeps Settings boot fast.305    void loadVaultHistory();306307    if ((licenseRes as { ok: boolean }).ok) {308      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);309    }310311    if ((passkeyRes as { ok: boolean }).ok) {312      setPasskeyState(313        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null314      );315    }316317    if ((duressRes as { ok: boolean }).ok) {318      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);319    }320321    if ((settingsRes as { ok: boolean }).ok) {322      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;323      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);324    }325326    if ((keyRes as { ok: boolean }).ok) {327      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);328    }329330    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });331    if ((entriesRes as { ok: boolean }).ok) {332      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];333      setCanaryEntries(334        entries335          .filter((e) => e.canary?.webhookUrl)336          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))337      );338    }339340341    if (isNativePlatform()) {342      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");343      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");344      try {345        if (biometric) {346          const a = await biometric.isAvailable();347          setBiometricSupported(a.available);348        }349        if (keystore) {350          const k = await keystore.hasMasterKey();351          setBiometricStored(k.exists);352        }353      } catch { /* missing plugins — leave defaults */ }354    }355  });356357  async function handleEnableBiometrics() {358    setBiometricBusy(true);359    setBiometricError(null);360    try {361      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {362        ok: boolean;363        data?: { masterKeyHex: string };364        error?: string;365      };366      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {367        setBiometricError(keyRes.error === "vault_locked"368          ? "unlock the vault first"369          : "couldn't read derived key");370        return;371      }372      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");373      if (!keystore) {374        setBiometricError("biometric plugin unavailable");375        return;376      }377      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });378      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });379      setBiometricStored(true);380    } catch (e) {381      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");382    } finally {383      setBiometricBusy(false);384    }385  }386387  async function handleDisableBiometrics() {388    setBiometricBusy(true);389    setBiometricError(null);390    try {391      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");392      if (keystore) await keystore.clearMasterKey();393      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });394      setBiometricStored(false);395    } catch (e) {396      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");397    } finally {398      setBiometricBusy(false);399    }400  }401402  async function handleChangePassword() {403    setChangePwError(null);404    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }405    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }406    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }407    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }408    setChangePwSaving(true);409    const res = await sendMessage({410      type: "CHANGE_PASSWORD",411      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },412    }) as { ok: boolean; error?: string };413    if (res.ok) {414      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");415      setShowChangePw(false);416      setShowNewRecoveryWords(true);417    } else {418      setChangePwError(res.error ?? "failed to change password");419    }420    setChangePwSaving(false);421  }422423  const [copied, setCopied] = createSignal(false);424425  async function copyLicenseKey(key: string) {426    await navigator.clipboard.writeText(key);427    setCopied(true);428    setTimeout(() => setCopied(false), 2000);429  }430431  async function handleActivateLicense() {432    const key = licenseKeyInput().trim();433    if (!key) return;434    setActivatingLicense(true);435    setLicenseError(null);436    setLicenseSuccess(null);437    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });438    setActivatingLicense(false);439    if ((res as { ok: boolean }).ok) {440      const data = (res as { ok: boolean; data?: LicenseInfo }).data;441      if (data?.valid) {442        setLicenseInfo(data);443        setLicenseKey(key);444        setLicenseSuccess("License activated!");445        setLicenseKeyInput("");446      } else {447        setLicenseError("Invalid license key");448      }449    } else {450      setLicenseError("Activation failed");451    }452  }453454  async function handleTogglePasskeys() {455    const current = passkeyState();456    if (!current || !current.supported) return;457    setPasskeySaving(true);458    const res = await sendMessage({459      type: "SET_PASSKEY_PROVIDER_ENABLED",460      payload: { enabled: !current.enabled },461    });462    setPasskeySaving(false);463    if ((res as { ok: boolean }).ok) {464      setPasskeyState(465        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null466      );467    }468  }469470  async function handleCheckAllBreaches() {471    setCheckingBreaches(true);472    setBreachResult(null);473    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });474    setCheckingBreaches(false);475    if ((res as { ok: boolean }).ok) {476      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};477      const breached = Object.values(data).filter((count) => count > 0).length;478      setBreachResult(479        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"480      );481      props.onBreachCheckDone();482    }483  }484485486  async function handleSetupDuress() {487    const mainPw = duressMainPassword().trim();488    const duressPw = duressPassword().trim();489    const confirmPw = duressConfirm().trim();490491    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }492    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }493    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }494    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }495496    setDuressSaving(true);497    setDuressError(null);498499    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };500    if (!verifyRes.ok || !verifyRes.data) {501      setDuressError("incorrect main password");502      setDuressSaving(false);503      return;504    }505506    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };507    setDuressSaving(false);508    if (res.ok) {509      setDuressEnabled(true);510      setShowDuressSetup(false);511      setDuressPassword("");512      setDuressConfirm("");513      setDuressMainPassword("");514      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");515      setTimeout(() => setDuressSuccess(null), 5000);516    } else {517      setDuressError(res.error ?? "failed to set duress password");518    }519  }520521  async function handleClearDeclinedCards() {522    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });523    setDeclinedCardCount(0);524  }525526  async function handleCountDuplicates() {527    setDedupRunning(true);528    setDedupDone(null);529    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };530    setDedupRunning(false);531    if (res.ok) setDedupCount(res.data ?? 0);532  }533534  async function handleDeduplicate() {535    setDedupRunning(true);536    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };537    setDedupRunning(false);538    if (res.ok) {539      setDedupDone(res.data ?? 0);540      setDedupCount(0);541    }542  }543544  async function handleDisableDuress() {545    const mainPw = duressMainPassword().trim();546    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }547548    setDuressSaving(true);549    setDuressError(null);550551    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };552    if (!verifyRes.ok || !verifyRes.data) {553      setDuressError("incorrect main password");554      setDuressSaving(false);555      return;556    }557558    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };559    setDuressSaving(false);560    if (res.ok) {561      setDuressEnabled(false);562      setShowDuressSetup(false);563      setDuressMainPassword("");564      setDuressSuccess("Duress password disabled.");565      setTimeout(() => setDuressSuccess(null), 4000);566    } else {567      setDuressError(res.error ?? "failed to disable duress password");568    }569  }570571  async function handleSubmitFeedback() {572    if (!fbMessage().trim() || fbSubmitting()) return;573    setFbSubmitting(true);574    setFbResult(null);575    const res = await sendMessage({576      type: "SUBMIT_FEEDBACK",577      payload: {578        category: fbCategory(),579        message: fbMessage().trim(),580        version,581        browser: navigator.userAgent,582      },583    }) as { ok: boolean; error?: string };584    setFbSubmitting(false);585    if (res.ok) {586      setFbResult("success");587      setFbMessage("");588      setTimeout(() => setFbResult(null), 4000);589    } else {590      setFbResult("error");591    }592  }593594  function renderSecurity() {595    return (596      <div class="peach-settings-body">597        <div class="peach-settings-group">598          <p class="peach-settings-sub-label">LOCKING</p>599          <div class="peach-settings-row">600            <span>Lock on close</span>601            <button602              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}603              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}604              aria-label="Toggle lock on close"605            >606              <span class="peach-toggle-knob"></span>607            </button>608          </div>609          <div class="peach-settings-row">610            <span>Auto-lock after</span>611            <select612              class="peach-settings-select"613              value={String(props.autoLockMinutes)}614              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}615            >616              <option value="1">1 minute</option>617              <option value="5">5 minutes</option>618              <option value="15">15 minutes</option>619              <option value="30">30 minutes</option>620              <option value="0">Never</option>621            </select>622          </div>623624          <Show when={isNativePlatform() && biometricSupported()}>625            <p class="peach-settings-sub-label">BIOMETRICS</p>626            <div class="peach-settings-row">627              <span>Unlock with biometrics</span>628              <button629                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}630                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}631                disabled={biometricBusy()}632                aria-label="Toggle biometric unlock"633              >634                <span class="peach-toggle-knob"></span>635              </button>636            </div>637            <Show when={biometricError()}>638              <div class="peach-settings-row peach-settings-row--meta">639                <span class="peach-hint peach-hint-error">{biometricError()}</span>640              </div>641            </Show>642          </Show>643644          <p class="peach-settings-sub-label">BREACH PROTECTION</p>645          <div class="peach-settings-row">646            <span>Breach monitoring</span>647            <button648              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}649              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}650              aria-label="Toggle breach monitoring"651            >652              <span class="peach-toggle-knob"></span>653            </button>654          </div>655          <div class="peach-settings-row peach-settings-row--meta">656            <span>Check all passwords</span>657            <button658              class="peach-btn peach-btn--subtle peach-btn--sm"659              onClick={() => void handleCheckAllBreaches()}660              disabled={checkingBreaches()}661            >662              {checkingBreaches() ? "checking..." : "run"}663            </button>664          </div>665          <Show when={breachResult()}>666            <div class="peach-settings-row peach-settings-row--meta">667              <span>Last check</span>668              <span class="peach-settings-value">{breachResult()}</span>669            </div>670          </Show>671672          <p class="peach-settings-sub-label">MASTER PASSWORD</p>673          <div class="peach-settings-row peach-settings-row--meta">674            <span>Master password</span>675            <button676              class="peach-btn peach-btn--subtle peach-btn--sm"677              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}678            >679              {showChangePw() ? "cancel" : "change"}680            </button>681          </div>682          <Show when={showChangePw()}>683            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">684              <input685                class="peach-settings-input"686                type="password"687                placeholder="current password"688                value={changePwCurrent()}689                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}690                autocomplete="current-password"691              />692              <input693                class="peach-settings-input"694                type="password"695                placeholder="new password (min 12 chars)"696                value={changePwNew()}697                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}698                autocomplete="new-password"699              />700              <input701                class="peach-settings-input"702                type="password"703                placeholder="confirm new password"704                value={changePwConfirm()}705                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}706                autocomplete="new-password"707                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}708              />709              <Show when={changePwError()}>710                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>711              </Show>712              <Show when={changePwSuccess()}>713                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>714              </Show>715              <button716                class="peach-btn peach-btn--accent peach-btn--sm"717                style="align-self:flex-start"718                onClick={() => void handleChangePassword()}719                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}720              >721                {changePwSaving() ? "saving…" : "save new password"}722              </button>723            </div>724          </Show>725726          <p class="peach-settings-sub-label">727            DURESS PASSWORD728            <Show when={!isPro()}>729              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>730            </Show>731          </p>732          <div class="peach-settings-row peach-settings-row--top">733            <div class="peach-settings-row-main">734              <span>735                Duress password736                <Show when={!isPro()}>737                  <span class="peach-pro-badge">Pro</span>738                </Show>739              </span>740              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">741                If entered under coercion, shows a decoy empty vault instead of your real one.742              </span>743            </div>744            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>745              {duressEnabled() ? "on" : "off"}746            </div>747          </div>748749          <Show when={duressEnabled() && !showDuressSetup()}>750            <div class="peach-settings-row peach-settings-row--meta">751              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">752                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden. The encrypted storage is identical in size whether a hidden vault exists or not, so the hidden vault cannot be proven or disproven without both passwords. This protects against a coercer who demands your password — not against device malware or someone who already has your device unlocked.753              </span>754            </div>755          </Show>756757          <Show when={duressSuccess()}>758            <div class="peach-settings-row peach-settings-row--meta">759              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>760            </div>761          </Show>762763          <Show when={!showDuressSetup()}>764            <div class="peach-settings-row peach-settings-row--meta">765              <button766                class="peach-btn peach-btn--subtle peach-btn--sm"767                onClick={() => {768                  // Pro gate entry-point: free users are redirected to the769                  // upgrade screen before the setup form opens. The server-770                  // side SETUP_DURESS handler is the single enforcement771                  // point; this is UI redirection so the form never appears772                  // for a user who can't complete it.773                  if (!isPro() && !duressEnabled()) {774                    props.onProGate?.("duress_vault");775                    setActiveCard("pro");776                    return;777                  }778                  setShowDuressSetup(true);779                  setDuressError(null);780                  setDuressSuccess(null);781                  setDuressPassword("");782                  setDuressConfirm("");783                  setDuressMainPassword("");784                }}785              >786                {duressEnabled() ? "Change / disable" : "Set up"}787              </button>788            </div>789          </Show>790791          <Show when={showDuressSetup()}>792            <div class="peach-duress-form">793              <label class="peach-label">main password (to confirm)</label>794              <input795                class="peach-settings-input"796                type="password"797                placeholder="your current master password"798                autocomplete="current-password"799                value={duressMainPassword()}800                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}801              />802              <Show when={!duressEnabled()}>803                <label class="peach-label">new duress password</label>804                <input805                  class="peach-settings-input"806                  type="password"807                  placeholder="duress password (min 8 chars)"808                  autocomplete="new-password"809                  value={duressPassword()}810                  onInput={(e) => setDuressPassword(e.currentTarget.value)}811                />812                <label class="peach-label">confirm duress password</label>813                <input814                  class="peach-settings-input"815                  type="password"816                  placeholder="confirm duress password"817                  autocomplete="new-password"818                  value={duressConfirm()}819                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}820                />821              </Show>822823              <Show when={duressError()}>824                <p class="peach-hint peach-hint-error">{duressError()}</p>825              </Show>826827              <div class="peach-duress-actions">828                <button829                  class="peach-btn peach-btn--subtle peach-btn--sm"830                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}831                >832                  cancel833                </button>834                <Show when={duressEnabled()}>835                  <button836                    class="peach-btn peach-btn--ghost peach-btn--sm"837                    style="color:var(--peach-breach)"838                    onClick={() => void handleDisableDuress()}839                    disabled={duressSaving()}840                  >841                    {duressSaving() ? "disabling..." : "disable duress"}842                  </button>843                </Show>844                <Show when={!duressEnabled()}>845                  <button846                    class="peach-btn peach-btn--accent peach-btn--sm"847                    onClick={() => void handleSetupDuress()}848                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}849                  >850                    {duressSaving() ? "saving..." : "save"}851                  </button>852                </Show>853              </div>854            </div>855          </Show>856857          <p class="peach-settings-sub-label">858            CANARY ENTRIES859            <Show when={!isPro()}>860              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>861            </Show>862          </p>863          <div class="peach-settings-row peach-settings-row--top">864            <div class="peach-settings-row-main">865              <span>866                Canary entries867                <Show when={!isPro()}>868                  <span class="peach-pro-badge">Pro</span>869                </Show>870              </span>871              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">872                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.873              </span>874            </div>875            <span class="peach-settings-value" style="flex-shrink:0">876              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}877            </span>878          </div>879          <Show when={canaryEntries().length > 0}>880            <For each={canaryEntries()}>881              {(entry) => (882                <div class="peach-settings-row peach-settings-row--meta">883                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>884                  <button885                    class="peach-btn peach-btn--subtle peach-btn--sm"886                    onClick={() => void handleTestCanary(entry)}887                    disabled={canaryTestingId() === entry.id}888                    style="flex-shrink:0"889                  >890                    {canaryTestingId() === entry.id891                      ? "sending…"892                      : canaryTestResult()?.id === entry.id893                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")894                        : "test"}895                  </button>896                </div>897              )}898            </For>899          </Show>900901          <p class="peach-settings-sub-label">PASSKEYS</p>902          <div class="peach-settings-row peach-settings-row--top">903            <div class="peach-settings-row-main">904              <span>Intercept passkey requests</span>905              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">906                Peach can handle passkey sign-in and creation on supported Chromium browsers.907              </span>908            </div>909            <button910              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}911              onClick={() => void handleTogglePasskeys()}912              aria-label="Toggle passkey interception"913              disabled={!passkeyState()?.supported || passkeySaving()}914            >915              <span class="peach-toggle-knob"></span>916            </button>917          </div>918          <div class="peach-settings-row peach-settings-row--meta">919            <span>Status</span>920            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>921          </div>922          <div class="peach-settings-row peach-settings-row--meta">923            <span>Browser</span>924            <span class="peach-settings-value">925              {passkeyState()?.browserLabel ?? "Unknown"}926              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}927            </span>928          </div>929          <div class="peach-info-notice" style="margin-top:8px">930            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.931          </div>932          <Show when={passkeyState()?.conflict}>933            <div class="peach-settings-row peach-settings-row--meta">934              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>935            </div>936          </Show>937        </div>938      </div>939    );940  }941942  function renderVault() {943    return (944      <div class="peach-settings-body">945        <div class="peach-settings-group">946          <p class="peach-settings-sub-label">ENTRIES</p>947          <div class="peach-settings-row peach-settings-row--meta">948            <span>Entries</span>949            <span class="peach-settings-value">{props.entriesCount}</span>950          </div>951          <div class="peach-settings-row peach-settings-row--meta">952            <span>Duplicates</span>953            <Show when={dedupDone() !== null} fallback={954              <button955                class="peach-btn peach-btn--subtle peach-btn--sm"956                onClick={() => void handleCountDuplicates()}957                disabled={dedupRunning()}958              >959                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}960              </button>961            }>962              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">963                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}964              </span>965            </Show>966          </div>967          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>968            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">969              <div class="peach-restore-warning">970                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.971              </div>972              <div style="display:flex;gap:6px">973                <button974                  class="peach-btn peach-btn--subtle peach-btn--sm"975                  onClick={() => setDedupCount(null)}976                >977                  cancel978                </button>979                <button980                  class="peach-btn peach-btn--accent peach-btn--sm"981                  onClick={() => void handleDeduplicate()}982                  disabled={dedupRunning()}983                >984                  {dedupRunning() ? "removing..." : "remove duplicates"}985                </button>986              </div>987            </div>988          </Show>989990          <p class="peach-settings-sub-label">MAINTENANCE</p>991          <div class="peach-settings-row peach-settings-row--meta">992            <span>Trash auto-delete</span>993            <select994              class="peach-settings-select"995              value={String(props.trashRetentionDays)}996              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}997            >998              <option value="7">7 days</option>999              <option value="30">30 days</option>1000              <option value="90">90 days</option>1001              <option value="0">Never</option>1002            </select>1003          </div>1004          <div class="peach-settings-row peach-settings-row--meta">1005            <span>Vault Health</span>1006            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1007              open1008            </button>1009          </div>1010          <Show when={declinedCardCount() > 0}>1011            <div class="peach-settings-row peach-settings-row--meta">1012              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1013              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1014                Clear1015              </button>1016            </div>1017          </Show>10181019          <p class="peach-settings-sub-label">DATA</p>1020          <div class="peach-settings-row peach-settings-row--meta">1021            <span>Import passwords</span>1022            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1023              open1024            </button>1025          </div>1026          <div class="peach-settings-row peach-settings-row--meta">1027            <span>Export backup</span>1028            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1029              open1030            </button>1031          </div>1032          <div class="peach-settings-row peach-settings-row--meta">1033            <span>1034              Peach Codex1035              <Show when={!isPro()}>1036                <span class="peach-pro-badge">Pro</span>1037              </Show>1038            </span>1039            <button1040              class="peach-btn peach-btn--subtle peach-btn--sm"1041              onClick={() => props.onOpenCodexGenerate?.()}1042            >1043              open1044            </button>1045          </div>1046          {/* Restore from Codex: intentionally never gated — a printed1047              Codex must remain restorable regardless of license state. */}1048          <div class="peach-settings-row peach-settings-row--meta">1049            <span>Restore from Codex</span>1050            <button1051              class="peach-btn peach-btn--subtle peach-btn--sm"1052              onClick={() => props.onOpenCodexRestore?.()}1053            >1054              open1055            </button>1056          </div>10571058          <button1059            class="peach-history-toggle"1060            onClick={() => setHistoryOpen((v) => !v)}1061            aria-expanded={historyOpen()}1062          >1063            <span>VAULT HISTORY</span>1064            <svg1065              class={`peach-history-chevron${historyOpen() ? " is-open" : ""}`}1066              width="10" height="6" viewBox="0 0 10 6" fill="none"1067              stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"1068            >1069              <path d="M1 1L5 5L9 1" />1070            </svg>1071          </button>1072          <Show when={historyOpen()}>1073            <Show when={historyLoading()}>1074              <p class="peach-settings-hint">Loading…</p>1075            </Show>1076            <Show when={!historyLoading() && historySnapshots().length === 0}>1077              <p class="peach-settings-hint">1078                No snapshots yet. Peach saves automatically before every change —1079                saves, syncs, and imports.1080              </p>1081            </Show>1082            <Show when={!historyLoading() && historySnapshots().length > 0}>1083              <p class="peach-settings-hint">1084                Restoring locks your vault. Unlock with the password that1085                encrypted that snapshot to confirm.1086              </p>1087              <div class="peach-history-list">1088                <For each={historySnapshots()}>1089                  {(snap) => (1090                    <div class="peach-history-row">1091                      <div class="peach-history-meta">1092                        <span class="peach-history-time">1093                          {formatSnapshotTime(snap.savedAt)}1094                        </span>1095                        <span class="peach-history-sub">1096                          {[1097                            snap.entryCount !== null1098                              ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1099                              : null,1100                            reasonLabel(snap.reason),1101                          ]1102                            .filter(Boolean)1103                            .join(" · ")}1104                        </span>1105                      </div>1106                      <button1107                        class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1108                          historyRestoring() === snap.id ? " is-busy" : ""1109                        }`}1110                        onClick={() => void handleRestoreVaultSnapshot(snap.id)}1111                        disabled={historyRestoring() !== null}1112                      >1113                        <Show when={historyRestoring() === snap.id} fallback="Restore">1114                          …1115                        </Show>1116                      </button>1117                    </div>1118                  )}1119                </For>1120              </div>1121              <p class="peach-history-footer">1122                {historySnapshots().length} of 10 snapshots1123              </p>1124            </Show>1125          </Show>11261127          <button1128            class="peach-settings-advanced-toggle"1129            onClick={() => setShowVaultAdvanced((v) => !v)}1130            aria-expanded={showVaultAdvanced()}1131          >1132            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1133            <span class="peach-settings-advanced-chevron">1134              {showVaultAdvanced() ? "▲" : "▼"}1135            </span>1136          </button>11371138          <Show when={showVaultAdvanced()}>1139            <div class="peach-danger-card">1140              <div class="peach-danger-card-head">1141                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1142                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1143              </div>1144              <p class="peach-danger-card-copy">1145                Wipes the current vault and restores from a backup file.1146                Recoverable only if your backup is good.1147              </p>1148              <button1149                class="peach-danger-card-button"1150                onClick={() => props.onOpenFileImport?.()}1151              >1152                Choose backup file1153              </button>1154            </div>11551156            <Show when={props.onOpenPanic}>1157              <div class="peach-danger-card peach-danger-card--hazard">1158                <div class="peach-danger-card-head">1159                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1160                  <h3 class="peach-danger-card-title">Destroy vault</h3>1161                </div>1162                <p class="peach-danger-card-copy">1163                  Permanently wipes every entry, setting, and license on this1164                  device. There is no recovery.1165                </p>1166                <button1167                  class="peach-danger-card-button"1168                  onClick={() => props.onOpenPanic?.()}1169                >1170                  Begin destruction1171                </button>1172              </div>1173            </Show>1174          </Show>1175        </div>1176      </div>1177    );1178  }11791180  function renderSync() {1181    return (1182      <div class="peach-settings-body">1183        <div class="peach-settings-group">1184          <p class="peach-settings-sub-label">PAIRING</p>1185          <div class="peach-settings-row peach-settings-row--top">1186            <div class="peach-settings-row-main">1187              <span>1188                Peach Sync1189                <Show when={!isPro()}>1190                  <span class="peach-pro-badge">Pro</span>1191                </Show>1192              </span>1193              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1194                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1195              </span>1196            </div>1197            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1198          </div>1199          <div class="peach-settings-row peach-settings-row--meta">1200            <span>Status</span>1201            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1202          </div>1203          <div class="peach-settings-row peach-settings-row--meta">1204            <span>Sync hub</span>1205            <button1206              class="peach-btn peach-btn--subtle peach-btn--sm"1207              onClick={() => props.onOpenSyncPairing?.()}1208            >1209              open1210            </button>1211          </div>12121213          <p class="peach-settings-sub-label">DEVICE FLOW</p>1214          <div class="peach-settings-row peach-settings-row--top">1215            <div class="peach-settings-row-main">1216              <span>Pair new device</span>1217              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1218                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1219              </span>1220            </div>1221            <button1222              class="peach-btn peach-btn--subtle peach-btn--sm"1223              onClick={() => props.onOpenSyncPairing?.()}1224            >1225              pair1226            </button>1227          </div>1228          <div class="peach-info-notice" style="margin-top:8px">1229            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1230          </div>1231        </div>1232      </div>1233    );1234  }12351236  function renderAppearance() {1237    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1238      { id: "small", label: "Small" },1239      { id: "medium", label: "Default" },1240      { id: "large", label: "Large" },1241    ];1242    return (1243      <div class="peach-settings-body">1244        <div class="peach-settings-group">1245          <p class="peach-settings-sub-label">THEME</p>1246          <div class="peach-settings-row peach-settings-row--meta">1247            <span>Theme</span>1248            <button1249              class="peach-btn peach-btn--subtle peach-btn--sm"1250              onClick={() =>1251                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1252              }1253            >1254              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1255            </button>1256          </div>12571258          <p class="peach-settings-sub-label">ACCENT</p>1259          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1260            <span>Accent</span>1261            <div class="peach-accent-picker">1262              <For each={ACCENT_COLORS}>1263                {(color) => (1264                  <button1265                    class="peach-accent-swatch peach-swatch"1266                    classList={{ "is-active": props.accentColor === color.name }}1267                    style={{ background: color.hex }}1268                    onClick={() => void props.onAccentChange(color.name)}1269                    aria-label={color.name}1270                    title={color.name}1271                  />1272                )}1273              </For>1274            </div>1275          </div>12761277          <p class="peach-settings-sub-label">TEXT SIZE</p>1278          <div class="peach-settings-row peach-settings-row--meta">1279            <span>Size</span>1280            <div class="peach-pill-group">1281              <For each={sizeOptions}>1282                {(opt) => (1283                  <button1284                    class="peach-btn peach-btn--ghost peach-btn--sm"1285                    classList={{ "is-active": props.textSize === opt.id }}1286                    onClick={() => void props.onTextSizeChange(opt.id)}1287                  >1288                    {opt.label}1289                  </button>1290                )}1291              </For>1292            </div>1293          </div>12941295          <p class="peach-settings-sub-label">MOTION</p>1296          <div class="peach-settings-row peach-settings-row--meta">1297            <span>Reduce motion</span>1298            <button1299              class="peach-btn peach-btn--subtle peach-btn--sm"1300              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1301            >1302              {props.reduceMotion ? "On" : "Off"}1303            </button>1304          </div>1305        </div>1306      </div>1307    );1308  }13091310  function renderShortcuts() {1311    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1312    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13131314    function startCapture(action: ShortcutAction) {1315      setConflictMsg(null);1316      setCapturingAction(action);1317    }1318    function cancelCapture() {1319      setCapturingAction(null);1320      setConflictMsg(null);1321    }1322    function commitBinding(action: ShortcutAction, binding: Keybinding) {1323      const conflict = findConflict(props.shortcuts, binding, action);1324      if (conflict) {1325        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1326        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1327        return;1328      }1329      const next = { ...props.shortcuts, [action]: binding };1330      setCapturingAction(null);1331      setConflictMsg(null);1332      void props.onShortcutsChange(next);1333    }1334    function resetAction(action: ShortcutAction) {1335      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1336      void props.onShortcutsChange(next);1337    }1338    function resetAll() {1339      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1340    }13411342    // Capture-mode key handler — wired via ref onto the chip element so it1343    // catches the next non-modifier keypress and ignores other bindings.1344    function handleCaptureKey(e: KeyboardEvent) {1345      e.preventDefault();1346      e.stopPropagation();1347      if (e.key === "Escape") { cancelCapture(); return; }1348      const binding = keybindingFromEvent(e);1349      if (!binding) return; // bare modifier — keep waiting1350      const action = capturingAction();1351      if (action) commitBinding(action, binding);1352    }13531354    return (1355      <div class="peach-settings-body">1356        <div class="peach-settings-group peach-shortcuts-group">1357          <p class="peach-settings-sub-label">ENTRY VIEW</p>1358          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1359            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1360          </For>1361        </div>13621363        <div class="peach-settings-group peach-shortcuts-group">1364          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1365          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1366            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1367          </For>1368        </div>13691370        <Show when={conflictMsg()}>1371          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1372        </Show>13731374        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1375          <div class="peach-settings-row peach-settings-row--meta">1376            <span>Inline autofill on page</span>1377            <a1378              class="peach-settings-link"1379              href="chrome://extensions/shortcuts"1380              onClick={(e) => {1381                e.preventDefault();1382                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1383              }}1384            >1385              ⌘⇧P — customize ↗1386            </a>1387          </div>1388          <button1389            class="peach-btn peach-btn--subtle peach-btn--sm"1390            style="align-self:flex-start"1391            onClick={resetAll}1392          >1393            Reset all to defaults1394          </button>1395        </div>1396      </div>1397    );13981399    function renderRow(1400      desc: typeof SHORTCUT_DESCRIPTORS[number],1401      capturing: () => ShortcutAction | null,1402      onStart: (a: ShortcutAction) => void,1403      onCancel: () => void,1404      onReset: (a: ShortcutAction) => void,1405      onKey: (e: KeyboardEvent) => void,1406    ) {1407      const isActive = () => capturing() === desc.action;1408      const binding = () => props.shortcuts[desc.action];1409      const isCustom = () => {1410        const b = binding();1411        const d = DEFAULT_BINDINGS[desc.action];1412        return b.key !== d.key1413          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1414          || Boolean(b.meta)  !== Boolean(d.meta)1415          || Boolean(b.shift) !== Boolean(d.shift)1416          || Boolean(b.alt)   !== Boolean(d.alt);1417      };1418      return (1419        <div class="peach-shortcut-row">1420          <span class="peach-shortcut-row-label">{desc.label}</span>1421          <button1422            class="peach-shortcut-chip"1423            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1424            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1425            onKeyDown={(e) => { if (isActive()) onKey(e); }}1426            ref={(el) => {1427              // Auto-focus when entering capture mode so the keydown listener catches the next press.1428              if (el) {1429                queueMicrotask(() => { if (isActive()) el.focus(); });1430              }1431            }}1432          >1433            {isActive() ? "Press a key…" : formatKeybinding(binding())}1434          </button>1435          <Show when={isCustom() && !isActive()}>1436            <button1437              class="peach-shortcut-reset"1438              title="Reset to default"1439              onClick={() => onReset(desc.action)}1440            >1441              ↺1442            </button>1443          </Show>1444        </div>1445      );1446    }1447  }14481449  const PRO_FEATURES: Array<{1450    feature: ProFeature;1451    name: string;1452    desc: string;1453  }> = [1454    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1455    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1456    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1457    { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1458    { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1459    { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },1460  ];14611462  const PRO_SECURITY_FEATURES: Array<{1463    feature: ProFeature;1464    name: string;1465    desc: string;1466  }> = [1467    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1468    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1469  ];14701471  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1472  const [showKey, setShowKey] = createSignal(false);14731474  const [deactivating, setDeactivating] = createSignal(false);14751476  async function handleDeactivateLicense() {1477    setDeactivating(true);1478    await sendMessage({ type: "DEACTIVATE_LICENSE" });1479    setLicenseInfo(null);1480    await props.onLicenseChange?.();1481    setDeactivating(false);1482  }14831484  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1485    setCanaryTestingId(entry.id);1486    setCanaryTestResult(null);1487    const res = await sendMessage({1488      type: "FIRE_CANARY_PING",1489      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1490    }) as { ok: boolean };1491    setCanaryTestResult({ id: entry.id, ok: res.ok });1492    setCanaryTestingId(null);1493    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1494  }14951496  function renderLicense() {1497    async function onActivate() {1498      await handleActivateLicense();1499      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1500        await props.onLicenseChange?.();1501      }1502    }15031504    return (1505      <div class="peach-settings-body">1506        <Show1507          when={isPro()}1508          fallback={1509                <div class="peach-pro-upgrade">1510                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15111512                  <h2 class="peach-pro-headline">1513                    Every<em>thing,</em><br />every<em>where.</em>1514                  </h2>15151516                  <p class="peach-pro-subtitle">1517                    One key unlocks every Pro feature — for the lifetime of the app.1518                  </p>15191520                  <div class="peach-pro-grid">1521                    <For each={PRO_FEATURES}>1522                      {(item, i) => (1523                        <div1524                          class="peach-pro-grid-item"1525                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1526                        >1527                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1528                          <span class="peach-pro-grid-name">{item.name}</span>1529                          <span class="peach-pro-grid-desc">{item.desc}</span>1530                        </div>1531                      )}1532                    </For>1533                  </div>15341535                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1536                  <div class="peach-pro-grid">1537                    <For each={PRO_SECURITY_FEATURES}>1538                      {(item, i) => (1539                        <div1540                          class="peach-pro-grid-item"1541                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1542                        >1543                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1544                          <span class="peach-pro-grid-name">{item.name}</span>1545                          <span class="peach-pro-grid-desc">{item.desc}</span>1546                        </div>1547                      )}1548                    </For>1549                  </div>15501551                  <div class="peach-pro-upgrade-actions">1552                    <p class="peach-pro-pricing">1553                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1554                    </p>1555                    <a1556                      href="https://peachpasswords.com/#pricing"1557                      target="_blank"1558                      rel="noopener noreferrer"1559                      class="peach-pro-upgrade-cta"1560                    >1561                      Get Peach Pro1562                    </a>1563                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1564                    <div class="peach-pro-key-inline">1565                      <input1566                        class="peach-pro-key-inline-input"1567                        type="text"1568                        placeholder="PEACH-XXXX-XXXX-XXXX"1569                        value={licenseKeyInput()}1570                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1571                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1572                        autocomplete="off"1573                        spellcheck={false}1574                      />1575                      <button1576                        class="peach-pro-key-apply"1577                        onClick={() => void onActivate()}1578                        disabled={activatingLicense() || !licenseKeyInput().trim()}1579                      >1580                        {activatingLicense() ? "…" : "APPLY"}1581                      </button>1582                    </div>1583                    <Show when={licenseError()}>1584                      <p class="peach-pro-key-error">{licenseError()}</p>1585                    </Show>1586                  </div>1587                </div>1588          }1589        >1590          <div class="peach-pro-active">1591            <div class="peach-pro-active-hero">1592              <div class="peach-pro-active-mark-wrap">1593                <PeachMark class="peach-pro-active-mark" />1594              </div>1595              <h2 class="peach-pro-active-title">Peach Pro</h2>1596              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1597            </div>15981599            <Show when={licenseKey()}>1600              <div class="peach-settings-license">1601                <span class="peach-settings-label">LICENSE KEY</span>1602                <div class="peach-settings-license-row">1603                  <Show1604                    when={showKey()}1605                    fallback={1606                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1607                        ••••••••••••••••••••••••••••1608                      </span>1609                    }1610                  >1611                    <span class="peach-settings-license-key">{licenseKey()!}</span>1612                  </Show>1613                  <div class="peach-settings-license-actions">1614                    <button1615                      class="peach-settings-license-copy"1616                      title="Copy license key"1617                      onClick={() => copyLicenseKey(licenseKey()!)}1618                    >1619                      {copied() ? "✓" : "copy"}1620                    </button>1621                    <button1622                      class="peach-settings-license-toggle"1623                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1624                      title={showKey() ? "Hide" : "Reveal"}1625                      onClick={() => setShowKey(v => !v)}1626                    >1627                      <Show1628                        when={showKey()}1629                        fallback={1630                          <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1631                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1632                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1633                          </svg>1634                        }1635                      >1636                        <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1637                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1638                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1639                          <line x1="2" y1="2" x2="12" y2="12" />1640                        </svg>1641                      </Show>1642                    </button>1643                  </div>1644                </div>1645              </div>1646            </Show>16471648            <div class="peach-pro-active-features">1649              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1650                {(item) => (1651                  <div class="peach-pro-active-feature">1652                    <span class="peach-pro-active-feature-mark">✦</span>1653                    <span class="peach-pro-active-feature-name">{item.name}</span>1654                  </div>1655                )}1656              </For>1657            </div>16581659            <button1660              class="peach-pro-deactivate"1661              onClick={() => void handleDeactivateLicense()}1662              disabled={deactivating()}1663            >1664              {deactivating() ? "Deactivating…" : "Deactivate"}1665            </button>1666          </div>1667        </Show>1668      </div>1669    );1670  }16711672  function renderFeedback() {1673    return (1674      <div class="peach-settings-body">1675        <div class="peach-settings-group peach-feedback-group">1676          <p class="peach-feedback-intro">1677            Help us improve Peach. Every report goes directly to our inbox.1678          </p>16791680          <p class="peach-settings-sub-label">TYPE</p>1681          <div class="peach-feedback-chips">1682            <For each={FEEDBACK_CATEGORIES}>1683              {(cat) => (1684                <button1685                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1686                  onClick={() => setFbCategory(cat)}1687                >1688                  {cat}1689                </button>1690              )}1691            </For>1692          </div>16931694          <p class="peach-settings-sub-label">MESSAGE</p>1695          <textarea1696            class="peach-feedback-textarea"1697            placeholder="Describe the issue or idea..."1698            value={fbMessage()}1699            onInput={(e) => setFbMessage(e.currentTarget.value)}1700          />17011702          <p class="peach-feedback-meta">1703            Version {version} · will be included automatically1704          </p>17051706          <Show when={fbResult() === "success"}>1707            <p class="peach-feedback-result peach-feedback-result--ok">1708              Sent — thank you1709            </p>1710          </Show>1711          <Show when={fbResult() === "error"}>1712            <p class="peach-feedback-result peach-feedback-result--err">1713              Failed to send — try again1714            </p>1715          </Show>17161717          <button1718            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1719            onClick={() => void handleSubmitFeedback()}1720            disabled={fbSubmitting() || !fbMessage().trim()}1721          >1722            {fbSubmitting() ? "Sending..." : "Send feedback"}1723          </button>17241725          <p class="peach-feedback-anon">1726            Sent anonymously · no account required1727          </p>1728        </div>1729      </div>1730    );1731  }17321733  const bodies: StackBodies = {1734    protection: renderSecurity(),1735    vault: renderVault(),1736    sync: renderSync(),1737    appearance: renderAppearance(),1738    shortcuts: renderShortcuts(),1739    pro: renderLicense(),1740    feedback: renderFeedback(),1741  };17421743  return (1744    <Show1745      when={showNewRecoveryWords()}1746      fallback={1747        <div1748          class="peach-overlay-view"1749          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1750          style="display:flex;flex-direction:column"1751        >1752          <StackDeck1753            active={activeCard()}1754            onActiveChange={setActiveCard}1755            onBack={props.onBack}1756            cards={cards()}1757            bodies={bodies}1758          />1759        </div>1760      }1761    >1762      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1763    </Show>1764  );1765}