Settings.tsx

98.5 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { SyncModeSelector } from "../components/SyncModeSelector";5import { StackDeck } from "../components/settings/StackDeck";6import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";7import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";8import { WhatsNewView } from "./WhatsNew";9import type { Canary } from "../../shared/types";10import { ACCENT_COLORS } from "../../shared/types";11import { PEACH_VERSION, hasReleaseNotes } from "../../shared/release-notes";12import type {13  AccentColorName,14  PasskeyProviderState,15  ProFeature,16  VaultSnapshot,17} from "../../shared/types";18import type { PairedDevice } from "../../shared/sync-types";19import {20  describeSyncOutcome,21  syncIndicator,22  syncIndicatorLabel,23  type SyncOutcomeSummary,24} from "../../shared/sync-outcome";25import {26  DEFAULT_BINDINGS,27  findConflict,28  formatKeybinding,29  keybindingFromEvent,30  SHORTCUT_DESCRIPTORS,31  type Keybinding,32  type ShortcutAction,33} from "../../shared/shortcuts";34import { WEB3FORMS_KEY } from "../../shared/config";3536interface LicenseInfo {37  valid: boolean;38  plan: "free" | "pro";39  issued?: string;40}4142interface SettingsProps {43  entriesCount: number;44  breachMonitoring: boolean;45  lockOnClose: boolean;46  autoLockMinutes: number;47  trashRetentionDays: number;48  theme: "dark" | "light";49  accentColor: AccentColorName;50  textSize: "small" | "medium" | "large";51  reduceMotion: boolean;52  isPro?: boolean;53  initialSection?: string | null;54  upgradeHighlight?: ProFeature | null;55  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;56  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;57  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;58  onTrashRetentionChange: (days: number) => void | Promise<void>;59  onThemeChange: (theme: "dark" | "light") => void;60  onAccentChange: (name: AccentColorName) => void | Promise<void>;61  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;62  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;63  shortcuts: Record<ShortcutAction, Keybinding>;64  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;65  onLicenseChange?: () => void | Promise<void>;66  isExiting?: boolean;67  onBack: () => void;68  onImport: () => void;69  onExport: () => void;70  onBreachCheckDone: () => void;71  onOpenHealth?: () => void;72  onOpenSyncPairing?: (method?: "code") => void;73  onOpenFileImport?: () => void;74  onOpenPanic?: () => void;75  onOpenCodexGenerate?: () => void;76  onOpenCodexRestore?: () => void;77  /** Invoked from within Settings when a Pro-gated control is tapped on78   *  free tier. App owns the upgrade navigation + highlight state, so this79   *  callback is the single way for Settings to request that navigation. */80  onProGate?: (feature: ProFeature) => void;81  onCardChange?: (id: CardId) => void;82}8384const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];85const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;86const PRO_CELEBRATION_CARD_STEP_MS = 150;87const PRO_CELEBRATION_THANK_YOU_MS = 3200;8889function sanitizeFeedbackMessage(value: string): string {90  return value91    .trim()92    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)93    // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strips control characters from feedback text94    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")95    .replace(/&/g, "&amp;")96    .replace(/</g, "&lt;")97    .replace(/>/g, "&gt;")98    .replace(/"/g, "&quot;")99    .replace(/'/g, "&#39;");100}101102function formatActivatedSince(issued: string | undefined): string {103  if (!issued) return "Active";104  try {105    const d = new Date(issued);106    if (Number.isNaN(d.getTime())) return "Active";107    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;108  } catch {109    return "Active";110  }111}112113function initialSectionToCard(value: string | null | undefined): CardId | null {114  if (!value) return null;115  if (value === "security") return "protection";116  if (value === "license") return "pro";117  if (118    value === "protection" ||119    value === "vault" ||120    value === "sync" ||121    value === "appearance" ||122    value === "shortcuts" ||123    value === "pro" ||124    value === "feedback"125  ) {126    return value;127  }128  return null;129}130131export function SettingsView(props: SettingsProps) {132  const manifest = chrome.runtime.getManifest();133  const version = manifest.version;134  const initial = props.initialSection;135  const [activeCard, setActiveCard] = createSignal<CardId>(136    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"137  );138139  createEffect(() => { props.onCardChange?.(activeCard()); });140141  const [justActivated, setJustActivated] = createSignal(false);142  const [isFanning, setIsFanning] = createSignal(false);143144  function triggerCelebration() {145    if (isFanning()) return;146    setJustActivated(false);147    setIsFanning(true);148    setActiveCard("pro");149    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];150    fanOrder.forEach((id, i) => {151      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);152    });153    window.setTimeout(() => {154      setActiveCard("pro");155      setIsFanning(false);156      setJustActivated(true);157      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);158    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);159  }160161  function handleProCelebrationClick(e: MouseEvent) {162    e.stopPropagation();163    triggerCelebration();164  }165166  const [checkingBreaches, setCheckingBreaches] = createSignal(false);167  const [breachResult, setBreachResult] = createSignal<string | null>(null);168  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);169  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");170  const [licenseError, setLicenseError] = createSignal<string | null>(null);171  const [, setLicenseSuccess] = createSignal<string | null>(null);172  const [activatingLicense, setActivatingLicense] = createSignal(false);173174  const isPro = () => props.isPro ?? false;175  const cards = createMemo<StackCardSpec[]>(() => [176    {177      id: "pro",178      num: "I · FOREVER",179      title: "Peach Pro",180      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",181      bgVar: "--stack-pro-bg",182      fgVar: "--stack-pro-fg",183      icon: "spark",184      onIconClick: isPro() ? triggerCelebration : undefined,185    },186    {187      id: "protection",188      num: "II · PROTECT",189      title: "Protection",190      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,191      bgVar: "--stack-protect-bg",192      fgVar: "--stack-protect-fg",193      icon: "shield",194    },195    {196      id: "vault",197      num: "III · KEEP",198      title: "Vault",199      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,200      bgVar: "--stack-vault-bg",201      fgVar: "--stack-vault-fg",202      icon: "lock",203    },204    {205      id: "sync",206      num: "IV · ECHO",207      title: "Sync",208      lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",209      bgVar: "--stack-sync-bg",210      fgVar: "--stack-sync-fg",211      icon: "sync",212    },213    {214      id: "appearance",215      num: "V · DRESS",216      title: "Appearance",217      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,218      bgVar: "--stack-appear-bg",219      fgVar: "--stack-appear-fg",220      icon: "sun",221    },222    {223      id: "shortcuts",224      num: "VI · KEYS",225      title: "Shortcuts",226      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,227      bgVar: "--stack-shortcut-bg",228      fgVar: "--stack-shortcut-fg",229      icon: "keys",230    },231    {232      id: "feedback",233      num: "VII · ECHO",234      title: "Feedback",235      lede: `version ${version} · tell us what would make it better`,236      bgVar: "--stack-feedback-bg",237      fgVar: "--stack-feedback-fg",238      icon: "bubble",239    },240  ]);241  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);242  const [passkeySaving, setPasskeySaving] = createSignal(false);243244  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting245  // explicit confirmation before they become sync targets.246  interface PendingVouched { deviceId: string; deviceName: string }247  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);248  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);249  // Paired-device management lives here — Settings is the sync hub.250  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);251  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);252  const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);253  const [renameDraft, setRenameDraft] = createSignal("");254  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");255  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);256  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —257  // a transient toast is no use for diagnosing a phone that isn't syncing.258  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});259  const syncLight = () =>260    syncStatus() === "syncing"261      ? "busy"262      : syncIndicator(syncSummary(), pairedDevices().length);263  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());264  const lastSyncLine = () => {265    const s = syncSummary();266    if (!s.outcome) return "No sync attempted yet";267    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;268  };269  async function loadSyncState() {270    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {271      ok: boolean;272      data?: SyncOutcomeSummary;273    };274    if (r.ok && r.data) {275      setSyncSummary(r.data);276      setSyncStatus(r.data.status ?? "idle");277    }278  }279280  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;281  function syncRelativeTime(when: number | undefined): string {282    if (!when) return "Not synced yet";283    const elapsed = Date.now() - when;284    if (elapsed < 60_000) return "Just now";285    const minutes = Math.floor(elapsed / 60_000);286    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;287    const hours = Math.floor(minutes / 60);288    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;289    const days = Math.floor(hours / 24);290    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";291  }292  function isSyncStale(d: PairedDevice): boolean {293    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;294  }295  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {296    switch (t) {297      case "android": return "Android";298      case "ios": return "iPhone";299      case "extension": return "Browser";300      default: return "Device";301    }302  }303304  async function loadPairedDevices() {305    if (!isPro()) return;306    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {307      ok: boolean;308      data?: { devices: PairedDevice[] };309    };310    if (r.ok) setPairedDevices(r.data?.devices ?? []);311  }312  async function unpairDevice(deviceId: string) {313    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });314    setConfirmUnpair(null);315    await loadPairedDevices();316  }317  async function renameDevice(deviceId: string) {318    const name = renameDraft().trim();319    if (!name) return;320    const result = (await sendMessage({321      type: "RENAME_PAIRED_DEVICE",322      payload: { deviceId, name },323    })) as { ok: boolean };324    if (!result.ok) return;325    setRenameDeviceId(null);326    await loadPairedDevices();327  }328  async function syncNow() {329    setSyncStatus("syncing");330    setSyncFlash(null);331    const res = (await sendMessage({332      type: "TRIGGER_SYNC",333      payload: { reason: "manual", force: true },334    })) as { ok: boolean; error?: string };335    if (!res.ok) {336      setSyncStatus("error");337      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });338      return;339    }340    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the341    // outcome. A single delayed read used to land while the run was still342    // "syncing" and leave the button disabled forever.343    let s: SyncOutcomeSummary = { status: "syncing" };344    for (let i = 0; i < 40 && s.status === "syncing"; i++) {345      await new Promise((r) => setTimeout(r, 250));346      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {347        ok: boolean;348        data?: SyncOutcomeSummary;349      };350      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };351    }352    setSyncSummary(s);353    setSyncStatus(s.status ?? "idle");354    // The persistent line below carries the outcome; the flash is only the355    // "that click did something" acknowledgement.356    setSyncFlash(describeSyncOutcome(s));357    setTimeout(() => setSyncFlash(null), 4000);358    await loadPairedDevices();359  }360  // UI-4 / MV-8: rotate sync identity confirmation dialog.361  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);362  const [rotating, setRotating] = createSignal(false);363  const [rotateError, setRotateError] = createSignal<string | null>(null);364  const [showSyncConnection, setShowSyncConnection] = createSignal(false);365  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);366367  async function loadPendingVouched() {368    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {369      ok: boolean;370      data?: PendingVouched[];371    };372    if (r.ok) setPendingVouched(r.data ?? []);373  }374  async function confirmVouched(deviceId: string) {375    setVouchedBusy(deviceId);376    try {377      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });378      await loadPendingVouched();379    } finally {380      setVouchedBusy(null);381    }382  }383  async function rejectVouched(deviceId: string) {384    setVouchedBusy(deviceId);385    try {386      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });387      await loadPendingVouched();388    } finally {389      setVouchedBusy(null);390    }391  }392  async function doRotateSyncIdentity() {393    setRotateError(null);394    setRotating(true);395    try {396      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {397        ok: boolean;398        error?: string;399      };400      if (!r.ok) {401        setRotateError(r.error ?? "Rotation failed.");402        return;403      }404      setRotateConfirmOpen(false);405      // Backend set peach_recovery_pending; reuse the existing recovery-words406      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).407      setShowNewRecoveryWords(true);408    } finally {409      setRotating(false);410    }411  }412413  const [dedupCount, setDedupCount] = createSignal<number | null>(null);414  const [dedupRunning, setDedupRunning] = createSignal(false);415  const [dedupDone, setDedupDone] = createSignal<number | null>(null);416417  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);418419  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted420  // BEFORE every real-mode vault write so the user can roll back if a sync421  // or import overwrites the wrong state.422  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);423  const [historyLoading, setHistoryLoading] = createSignal(false);424  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);425  const [historyShowAll, setHistoryShowAll] = createSignal(false);426427  async function loadVaultHistory() {428    setHistoryLoading(true);429    try {430      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });431      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];432      setHistorySnapshots(list);433    } finally {434      setHistoryLoading(false);435    }436  }437438  async function handleRestoreVaultSnapshot(id: string) {439    if (historyRestoring()) return;440    setHistoryRestoring(id);441    const res = await sendMessage({442      type: "RESTORE_VAULT_SNAPSHOT",443      payload: { snapshotId: id },444    });445    setHistoryRestoring(null);446    if ((res as { ok: boolean }).ok) {447      // Vault is now locked — let the parent close Settings; App.tsx's448      // VAULT_LOCKED listener will route back to the lock screen.449      props.onBack();450    }451  }452453  function formatSnapshotTime(savedAt: number): string {454    const diff = Date.now() - savedAt;455    if (diff < 60_000) return "Just now";456    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;457    const d = new Date(savedAt);458    const isToday = new Date().toDateString() === d.toDateString();459    const isYest =460      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();461    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });462    if (isToday) return `Today ${t}`;463    if (isYest) return `Yesterday ${t}`;464    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;465  }466467  function reasonLabel(reason: VaultSnapshot["reason"]): string {468    return (469      {470        local_save: "saved",471        sync_received: "synced",472        import: "imported",473        restore: "restored",474      } as const475    )[reason] ?? reason;476  }477478  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);479  const [showWhatsNew, setShowWhatsNew] = createSignal(false);480481  const [showChangePw, setShowChangePw] = createSignal(false);482  const [changePwCurrent, setChangePwCurrent] = createSignal("");483  const [changePwNew, setChangePwNew] = createSignal("");484  const [changePwConfirm, setChangePwConfirm] = createSignal("");485  const [changePwSaving, setChangePwSaving] = createSignal(false);486  const [changePwError, setChangePwError] = createSignal<string | null>(null);487  const [changePwSuccess] = createSignal(false);488  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);489490  const [pinEnabled, setPinEnabled] = createSignal(false);491  const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);492  const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");493  const [pinValue, setPinValue] = createSignal("");494  const [pinConfirm, setPinConfirm] = createSignal("");495  const [pinSaving, setPinSaving] = createSignal(false);496  const [pinError, setPinError] = createSignal<string | null>(null);497498  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.499  const [showViewWords, setShowViewWords] = createSignal(false);500  const [viewWordsPassword, setViewWordsPassword] = createSignal("");501  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);502  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);503  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);504505  async function handleViewRecoveryWords() {506    setViewWordsError(null);507    if (!viewWordsPassword()) {508      setViewWordsError("Enter your master password.");509      return;510    }511    setViewWordsBusy(true);512    try {513      const r = (await sendMessage({514        type: "GET_RECOVERY_WORDS",515        payload: { password: viewWordsPassword() },516      })) as { ok: boolean; data?: string[]; error?: string };517      if (r.ok) {518        setViewedWords(r.data ?? []);519        setViewWordsPassword("");520        return;521      }522      switch (r.error) {523        case "wrong_password":524          setViewWordsError("Incorrect password");525          break;526        case "password_required":527          setViewWordsError("Enter your master password.");528          break;529        case "recovery_not_setup":530          setViewWordsError("Recovery words aren't set up for this vault yet.");531          break;532        default:533          setViewWordsError("Couldn't retrieve recovery words.");534      }535    } finally {536      setViewWordsBusy(false);537    }538  }539  function closeViewWords() {540    setShowViewWords(false);541    setViewWordsPassword("");542    setViewWordsError(null);543    setViewedWords(null);544  }545546  const [duressEnabled, setDuressEnabled] = createSignal(false);547  const [showDuressSetup, setShowDuressSetup] = createSignal(false);548  const [duressPassword, setDuressPassword] = createSignal("");549  const [duressConfirm, setDuressConfirm] = createSignal("");550  const [duressMainPassword, setDuressMainPassword] = createSignal("");551  const [duressSaving, setDuressSaving] = createSignal(false);552  const [duressError, setDuressError] = createSignal<string | null>(null);553  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);554555  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);556557  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);558  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);559560  const [fbCategory, setFbCategory] = createSignal("Bug report");561  const [fbMessage, setFbMessage] = createSignal("");562  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';563  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');564565  createEffect(() => {566    if (props.upgradeHighlight) {567      setActiveCard("pro");568    }569  });570571  onMount(async () => {572    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([573      sendMessage({ type: "GET_LICENSE" }),574      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),575      sendMessage({ type: "IS_DURESS_ENABLED" }),576      sendMessage({ type: "GET_SETTINGS" }),577      sendMessage({ type: "GET_LICENSE_KEY" }),578    ]);579580    // Vault history loads in the background — the Vault sub-view picks it up581    // when the user navigates in. Loading it lazily keeps Settings boot fast.582    void loadVaultHistory();583584    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the585    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.586    void loadPendingVouched();587    void loadPairedDevices();588    void loadSyncState();589    const onBroadcast = (msg: unknown) => {590      const m = msg as { type?: string; payload?: unknown } | null;591      if (m?.type === "VOUCHED_DEVICE_PENDING") {592        void loadPendingVouched();593      }594      if (m?.type === "SYNC_STATE_CHANGED") {595        const payload = (m.payload ?? {}) as SyncOutcomeSummary;596        setSyncSummary(payload);597        setSyncStatus(payload.status ?? "idle");598      }599    };600    chrome.runtime?.onMessage?.addListener(onBroadcast);601    onCleanup(() => {602      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }603    });604605    if ((licenseRes as { ok: boolean }).ok) {606      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);607    }608609    if ((passkeyRes as { ok: boolean }).ok) {610      setPasskeyState(611        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null612      );613    }614615    if ((duressRes as { ok: boolean }).ok) {616      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);617    }618619    if ((settingsRes as { ok: boolean }).ok) {620      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;621      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);622      setPinEnabled(sData?.pinEnabled === true);623    }624625    if ((keyRes as { ok: boolean }).ok) {626      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);627    }628629    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });630    if ((entriesRes as { ok: boolean }).ok) {631      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];632      setCanaryEntries(633        entries634          .filter((e) => e.canary?.webhookUrl)635          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))636      );637    }638639  });640641  function resetPinFlow() {642    setPinFlow(null);643    setPinCurrentPassword("");644    setPinValue("");645    setPinConfirm("");646    setPinError(null);647  }648649  async function handleSetupPin() {650    setPinError(null);651    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }652    if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }653    if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }654    setPinSaving(true);655    const res = await sendMessage({656      type: "SETUP_PIN",657      payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },658    }) as { ok: boolean; error?: string };659    if (res.ok) {660      setPinEnabled(true);661      resetPinFlow();662    } else {663      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");664    }665    setPinSaving(false);666  }667668  async function handleDisablePin() {669    setPinError(null);670    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }671    setPinSaving(true);672    const res = await sendMessage({673      type: "DISABLE_PIN",674      payload: { currentPassword: pinCurrentPassword() },675    }) as { ok: boolean; error?: string };676    if (res.ok) {677      setPinEnabled(false);678      resetPinFlow();679    } else {680      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");681    }682    setPinSaving(false);683  }684685  async function handleChangePassword() {686    setChangePwError(null);687    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }688    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }689    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }690    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }691    setChangePwSaving(true);692    const res = await sendMessage({693      type: "CHANGE_PASSWORD",694      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },695    }) as { ok: boolean; error?: string };696    if (res.ok) {697      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");698      setShowChangePw(false);699      // LV-4: the user just authenticated (current password), so the recovery700      // words reveal that follows is exempt from the re-auth gate. The701      // background owns this session-scoped exemption; the display clears it.702      await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });703      setShowNewRecoveryWords(true);704    } else {705      setChangePwError(res.error ?? "failed to change password");706    }707    setChangePwSaving(false);708  }709710  const [copied, setCopied] = createSignal(false);711712  async function copyLicenseKey(key: string) {713    await navigator.clipboard.writeText(key);714    setCopied(true);715    setTimeout(() => setCopied(false), 2000);716  }717718  async function handleActivateLicense() {719    const key = licenseKeyInput().trim();720    if (!key) return;721    setActivatingLicense(true);722    setLicenseError(null);723    setLicenseSuccess(null);724    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });725    setActivatingLicense(false);726    if ((res as { ok: boolean }).ok) {727      const data = (res as { ok: boolean; data?: LicenseInfo }).data;728      if (data?.valid) {729        setLicenseInfo(data);730        setLicenseKey(key);731        setLicenseSuccess("License activated!");732        setLicenseKeyInput("");733      } else {734        setLicenseError("Invalid license key");735      }736    } else {737      setLicenseError("Activation failed");738    }739  }740741  async function handleTogglePasskeys() {742    const current = passkeyState();743    if (!current || !current.supported) return;744    setPasskeySaving(true);745    const res = await sendMessage({746      type: "SET_PASSKEY_PROVIDER_ENABLED",747      payload: { enabled: !current.enabled },748    });749    setPasskeySaving(false);750    if ((res as { ok: boolean }).ok) {751      setPasskeyState(752        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null753      );754    }755  }756757  async function handleCheckAllBreaches() {758    setCheckingBreaches(true);759    setBreachResult(null);760    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });761    setCheckingBreaches(false);762    if ((res as { ok: boolean }).ok) {763      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};764      const breached = Object.values(data).filter((count) => count > 0).length;765      setBreachResult(766        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"767      );768      props.onBreachCheckDone();769    }770  }771772773  async function handleSetupDuress() {774    const mainPw = duressMainPassword().trim();775    const duressPw = duressPassword().trim();776    const confirmPw = duressConfirm().trim();777778    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }779    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }780    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }781    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }782783    setDuressSaving(true);784    setDuressError(null);785786    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };787    if (!verifyRes.ok || !verifyRes.data) {788      setDuressError("incorrect main password");789      setDuressSaving(false);790      return;791    }792793    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };794    setDuressSaving(false);795    if (res.ok) {796      setDuressEnabled(true);797      setShowDuressSetup(false);798      setDuressPassword("");799      setDuressConfirm("");800      setDuressMainPassword("");801      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");802      setTimeout(() => setDuressSuccess(null), 5000);803    } else {804      setDuressError(805        res.error === "session_expired"806          ? "Vault session expired — lock and unlock Peach, then try again"807          : res.error ?? "failed to set duress password"808      );809    }810  }811812  async function handleClearDeclinedCards() {813    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });814    setDeclinedCardCount(0);815  }816817  async function handleCountDuplicates() {818    setDedupRunning(true);819    setDedupDone(null);820    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };821    setDedupRunning(false);822    if (res.ok) setDedupCount(res.data ?? 0);823  }824825  async function handleDeduplicate() {826    setDedupRunning(true);827    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };828    setDedupRunning(false);829    if (res.ok) {830      setDedupDone(res.data ?? 0);831      setDedupCount(0);832    }833  }834835  async function handleDisableDuress() {836    const mainPw = duressMainPassword().trim();837    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }838839    setDuressSaving(true);840    setDuressError(null);841842    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };843    if (!verifyRes.ok || !verifyRes.data) {844      setDuressError("incorrect main password");845      setDuressSaving(false);846      return;847    }848849    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };850    setDuressSaving(false);851    if (res.ok) {852      setDuressEnabled(false);853      setShowDuressSetup(false);854      setDuressMainPassword("");855      setDuressSuccess("Duress password disabled.");856      setTimeout(() => setDuressSuccess(null), 4000);857    } else {858      setDuressError(res.error ?? "failed to disable duress password");859    }860  }861862  async function handleSubmitFeedback() {863    const message = sanitizeFeedbackMessage(fbMessage());864    if (!message) return;865866    setFeedbackState('sending');867868    try {869      const category = FEEDBACK_CATEGORIES.includes(fbCategory())870        ? fbCategory()871        : FEEDBACK_CATEGORIES[0];872      const res = await fetch('https://api.web3forms.com/submit', {873        method: 'POST',874        headers: { 'Content-Type': 'application/json' },875        body: JSON.stringify({876          access_key: WEB3FORMS_KEY,877          subject: `Peach Feedback - ${category}`,878          message,879          from_name: 'Peach User',880          // Honeypot — leave blank881          botcheck: '',882        }),883      });884885      if (!res.ok) throw new Error(`HTTP ${res.status}`);886      const data = await res.json() as { success: boolean; message?: string };887      if (!data.success) throw new Error(data.message ?? 'unknown error');888889      setFeedbackState('sent');890      setFbMessage('');891      setFbCategory(FEEDBACK_CATEGORIES[0]);892    } catch (err) {893      console.error('[feedback]', err);894      setFeedbackState('error');895      setTimeout(() => setFeedbackState('idle'), 4000);896    }897  }898899  function renderSecurity() {900    return (901      <div class="peach-settings-body">902        <div class="peach-settings-group">903          <p class="peach-settings-sub-label">LOCKING</p>904          <div class="peach-settings-row">905            <span>Lock on close</span>906            <button907              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}908              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}909              aria-label="Toggle lock on close"910            >911              <span class="peach-toggle-knob"></span>912            </button>913          </div>914          <div class="peach-settings-row">915            <span>Auto-lock after</span>916            <select917              class="peach-settings-select"918              value={String(props.autoLockMinutes)}919              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}920            >921              <option value="1">1 minute</option>922              <option value="5">5 minutes</option>923              <option value="15">15 minutes</option>924              <option value="30">30 minutes</option>925              <option value="0">Never</option>926            </select>927          </div>928929          <p class="peach-settings-sub-label">QUICK UNLOCK</p>930          <div class="peach-settings-row peach-settings-row--meta">931            <span>PIN unlock</span>932            <button933              class="peach-btn peach-btn--subtle peach-btn--sm"934              onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}935            >936              {pinEnabled() ? "remove" : "set up"}937            </button>938          </div>939          <Show when={pinFlow() === "setup"}>940            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">941              <input942                class="peach-settings-input"943                type="password"944                placeholder="current password"945                value={pinCurrentPassword()}946                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}947                autocomplete="current-password"948              />949              <input950                class="peach-settings-input"951                type="password"952                inputmode="numeric"953                maxlength="12"954                placeholder="new 4–12 digit PIN"955                value={pinValue()}956                onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}957                autocomplete="off"958              />959              <input960                class="peach-settings-input"961                type="password"962                inputmode="numeric"963                maxlength="12"964                placeholder="confirm PIN"965                value={pinConfirm()}966                onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}967                autocomplete="off"968                onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}969              />970              <Show when={pinError()}>971                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>972              </Show>973              <button974                class="peach-btn peach-btn--accent peach-btn--sm"975                style="align-self:flex-start"976                onClick={() => void handleSetupPin()}977                disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}978              >979                {pinSaving() ? "saving…" : "save PIN"}980              </button>981            </div>982          </Show>983          <Show when={pinFlow() === "disable"}>984            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">985              <input986                class="peach-settings-input"987                type="password"988                placeholder="current password"989                value={pinCurrentPassword()}990                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}991                autocomplete="current-password"992                onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}993              />994              <Show when={pinError()}>995                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>996              </Show>997              <button998                class="peach-btn peach-btn--subtle peach-btn--sm"999                style="align-self:flex-start"1000                onClick={() => void handleDisablePin()}1001                disabled={pinSaving() || !pinCurrentPassword()}1002              >1003                {pinSaving() ? "removing…" : "remove PIN"}1004              </button>1005            </div>1006          </Show>10071008          <p class="peach-settings-sub-label">BREACH PROTECTION</p>1009          <div class="peach-settings-row">1010            <span>Breach monitoring</span>1011            <button1012              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1013              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1014              aria-label="Toggle breach monitoring"1015            >1016              <span class="peach-toggle-knob"></span>1017            </button>1018          </div>1019          <div class="peach-settings-row peach-settings-row--meta">1020            <span>Check all passwords</span>1021            <button1022              class="peach-btn peach-btn--subtle peach-btn--sm"1023              onClick={() => void handleCheckAllBreaches()}1024              disabled={checkingBreaches()}1025            >1026              {checkingBreaches() ? "checking..." : "run"}1027            </button>1028          </div>1029          <Show when={breachResult()}>1030            <div class="peach-settings-row peach-settings-row--meta">1031              <span>Last check</span>1032              <span class="peach-settings-value">{breachResult()}</span>1033            </div>1034          </Show>10351036          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1037          <div class="peach-settings-row peach-settings-row--meta">1038            <span>Master password</span>1039            <button1040              class="peach-btn peach-btn--subtle peach-btn--sm"1041              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1042            >1043              {showChangePw() ? "cancel" : "change"}1044            </button>1045          </div>1046          <Show when={showChangePw()}>1047            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1048              <input1049                class="peach-settings-input"1050                type="password"1051                placeholder="current password"1052                value={changePwCurrent()}1053                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1054                autocomplete="current-password"1055              />1056              <input1057                class="peach-settings-input"1058                type="password"1059                placeholder="new password (min 12 chars)"1060                value={changePwNew()}1061                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1062                autocomplete="new-password"1063              />1064              <input1065                class="peach-settings-input"1066                type="password"1067                placeholder="confirm new password"1068                value={changePwConfirm()}1069                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1070                autocomplete="new-password"1071                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1072              />1073              <Show when={changePwError()}>1074                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1075              </Show>1076              <Show when={changePwSuccess()}>1077                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1078              </Show>1079              <button1080                class="peach-btn peach-btn--accent peach-btn--sm"1081                style="align-self:flex-start"1082                onClick={() => void handleChangePassword()}1083                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1084              >1085                {changePwSaving() ? "saving…" : "save new password"}1086              </button>1087            </div>1088          </Show>10891090          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1091          <div class="peach-settings-row peach-settings-row--meta">1092            <span>Recovery words</span>1093            <button1094              class="peach-btn peach-btn--subtle peach-btn--sm"1095              onClick={() => {1096                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1097              }}1098            >1099              {showViewWords() ? "cancel" : "view"}1100            </button>1101          </div>1102          <Show when={showViewWords()}>1103            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1104              <Show1105                when={viewedWords()}1106                fallback={1107                  <>1108                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1109                      Enter your master password to reveal your recovery words.1110                    </p>1111                    <input1112                      class="peach-settings-input"1113                      type="password"1114                      placeholder="master password"1115                      value={viewWordsPassword()}1116                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1117                      autocomplete="current-password"1118                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1119                    />1120                    <Show when={viewWordsError()}>1121                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1122                    </Show>1123                    <button1124                      class="peach-btn peach-btn--accent peach-btn--sm"1125                      style="align-self:flex-start"1126                      onClick={() => void handleViewRecoveryWords()}1127                      disabled={viewWordsBusy() || !viewWordsPassword()}1128                    >1129                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1130                    </button>1131                  </>1132                }1133              >1134                <div class="peach-recovery-words-grid">1135                  <For each={viewedWords()!}>1136                    {(word, i) => (1137                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1138                    )}1139                  </For>1140                </div>1141                <button1142                  class="peach-btn peach-btn--subtle peach-btn--sm"1143                  style="align-self:flex-start"1144                  onClick={closeViewWords}1145                >done</button>1146              </Show>1147            </div>1148          </Show>11491150          <p class="peach-settings-sub-label">1151            DURESS PASSWORD1152            <Show when={!isPro()}>1153              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1154            </Show>1155          </p>1156          <div class="peach-settings-row peach-settings-row--top">1157            <div class="peach-settings-row-main">1158              <span>1159                Duress password1160                <Show when={!isPro()}>1161                  <span class="peach-pro-badge">Pro</span>1162                </Show>1163              </span>1164              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1165                If entered under coercion, shows a decoy empty vault instead of your real one.1166              </span>1167            </div>1168            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1169              {duressEnabled() ? "on" : "off"}1170            </div>1171          </div>11721173          <Show when={duressEnabled() && !showDuressSetup()}>1174            <div class="peach-settings-row peach-settings-row--meta">1175              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1176                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.1177              </span>1178            </div>1179          </Show>11801181          <Show when={duressSuccess()}>1182            <div class="peach-settings-row peach-settings-row--meta">1183              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1184            </div>1185          </Show>11861187          <Show when={!showDuressSetup()}>1188            <div class="peach-settings-row peach-settings-row--meta">1189              <button1190                class="peach-btn peach-btn--subtle peach-btn--sm"1191                onClick={() => {1192                  // Pro gate entry-point: free users are redirected to the1193                  // upgrade screen before the setup form opens. The server-1194                  // side SETUP_DURESS handler is the single enforcement1195                  // point; this is UI redirection so the form never appears1196                  // for a user who can't complete it.1197                  if (!isPro() && !duressEnabled()) {1198                    props.onProGate?.("duress_vault");1199                    setActiveCard("pro");1200                    return;1201                  }1202                  setShowDuressSetup(true);1203                  setDuressError(null);1204                  setDuressSuccess(null);1205                  setDuressPassword("");1206                  setDuressConfirm("");1207                  setDuressMainPassword("");1208                }}1209              >1210                {duressEnabled() ? "Change / disable" : "Set up"}1211              </button>1212            </div>1213          </Show>12141215          <Show when={showDuressSetup()}>1216            <div class="peach-duress-form">1217              <label class="peach-label">main password (to confirm)</label>1218              <input1219                class="peach-settings-input"1220                type="password"1221                placeholder="your current master password"1222                autocomplete="current-password"1223                value={duressMainPassword()}1224                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1225              />1226              <Show when={!duressEnabled()}>1227                <label class="peach-label">new duress password</label>1228                <input1229                  class="peach-settings-input"1230                  type="password"1231                  placeholder="duress password (min 8 chars)"1232                  autocomplete="new-password"1233                  value={duressPassword()}1234                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1235                />1236                <label class="peach-label">confirm duress password</label>1237                <input1238                  class="peach-settings-input"1239                  type="password"1240                  placeholder="confirm duress password"1241                  autocomplete="new-password"1242                  value={duressConfirm()}1243                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1244                />1245              </Show>12461247              <Show when={duressError()}>1248                <p class="peach-hint peach-hint-error">{duressError()}</p>1249              </Show>12501251              <div class="peach-duress-actions">1252                <button1253                  class="peach-btn peach-btn--subtle peach-btn--sm"1254                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1255                >1256                  cancel1257                </button>1258                <Show when={duressEnabled()}>1259                  <button1260                    class="peach-btn peach-btn--ghost peach-btn--sm"1261                    style="color:var(--peach-breach)"1262                    onClick={() => void handleDisableDuress()}1263                    disabled={duressSaving()}1264                  >1265                    {duressSaving() ? "disabling..." : "disable duress"}1266                  </button>1267                </Show>1268                <Show when={!duressEnabled()}>1269                  <button1270                    class="peach-btn peach-btn--accent peach-btn--sm"1271                    onClick={() => void handleSetupDuress()}1272                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1273                  >1274                    {duressSaving() ? "saving..." : "save"}1275                  </button>1276                </Show>1277              </div>1278            </div>1279          </Show>12801281          <p class="peach-settings-sub-label">1282            CANARY ENTRIES1283            <Show when={!isPro()}>1284              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1285            </Show>1286          </p>1287          <div class="peach-settings-row peach-settings-row--top">1288            <div class="peach-settings-row-main">1289              <span>1290                Canary entries1291                <Show when={!isPro()}>1292                  <span class="peach-pro-badge">Pro</span>1293                </Show>1294              </span>1295              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1296                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1297              </span>1298            </div>1299            <span class="peach-settings-value" style="flex-shrink:0">1300              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1301            </span>1302          </div>1303          <Show when={canaryEntries().length > 0}>1304            <For each={canaryEntries()}>1305              {(entry) => (1306                <div class="peach-settings-row peach-settings-row--meta">1307                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1308                  <button1309                    class="peach-btn peach-btn--subtle peach-btn--sm"1310                    onClick={() => void handleTestCanary(entry)}1311                    disabled={canaryTestingId() === entry.id}1312                    style="flex-shrink:0"1313                  >1314                    {canaryTestingId() === entry.id1315                      ? "sending…"1316                      : canaryTestResult()?.id === entry.id1317                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1318                        : "test"}1319                  </button>1320                </div>1321              )}1322            </For>1323          </Show>13241325          <p class="peach-settings-sub-label">PASSKEYS</p>1326          <div class="peach-settings-row peach-settings-row--top">1327            <div class="peach-settings-row-main">1328              <span>Intercept passkey requests</span>1329              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1330                Peach can handle passkey sign-in and creation on supported Chromium browsers.1331              </span>1332            </div>1333            <button1334              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1335              onClick={() => void handleTogglePasskeys()}1336              aria-label="Toggle passkey interception"1337              disabled={!passkeyState()?.supported || passkeySaving()}1338            >1339              <span class="peach-toggle-knob"></span>1340            </button>1341          </div>1342          <div class="peach-settings-row peach-settings-row--meta">1343            <span>Status</span>1344            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1345          </div>1346          <div class="peach-settings-row peach-settings-row--meta">1347            <span>Browser</span>1348            <span class="peach-settings-value">1349              {passkeyState()?.browserLabel ?? "Unknown"}1350              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1351            </span>1352          </div>1353          <div class="peach-info-notice" style="margin-top:8px">1354            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1355          </div>1356          <Show when={passkeyState()?.conflict}>1357            <div class="peach-settings-row peach-settings-row--meta">1358              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1359            </div>1360          </Show>1361        </div>1362      </div>1363    );1364  }13651366  function renderVault() {1367    return (1368      <div class="peach-settings-body">1369        <div class="peach-settings-group">1370          <p class="peach-settings-sub-label">ENTRIES</p>1371          <div class="peach-settings-row peach-settings-row--meta">1372            <span>Entries</span>1373            <span class="peach-settings-value">{props.entriesCount}</span>1374          </div>1375          <div class="peach-settings-row peach-settings-row--meta">1376            <span>Duplicates</span>1377            <Show when={dedupDone() !== null} fallback={1378              <button1379                class="peach-btn peach-btn--subtle peach-btn--sm"1380                onClick={() => void handleCountDuplicates()}1381                disabled={dedupRunning()}1382              >1383                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1384              </button>1385            }>1386              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1387                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1388              </span>1389            </Show>1390          </div>1391          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1392            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1393              <div class="peach-restore-warning">1394                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1395              </div>1396              <div style="display:flex;gap:6px">1397                <button1398                  class="peach-btn peach-btn--subtle peach-btn--sm"1399                  onClick={() => setDedupCount(null)}1400                >1401                  cancel1402                </button>1403                <button1404                  class="peach-btn peach-btn--accent peach-btn--sm"1405                  onClick={() => void handleDeduplicate()}1406                  disabled={dedupRunning()}1407                >1408                  {dedupRunning() ? "removing..." : "remove duplicates"}1409                </button>1410              </div>1411            </div>1412          </Show>14131414          <p class="peach-settings-sub-label">MAINTENANCE</p>1415          <div class="peach-settings-row peach-settings-row--meta">1416            <span>Trash auto-delete</span>1417            <select1418              class="peach-settings-select"1419              value={String(props.trashRetentionDays)}1420              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1421            >1422              <option value="7">7 days</option>1423              <option value="30">30 days</option>1424              <option value="90">90 days</option>1425              <option value="0">Never</option>1426            </select>1427          </div>1428          <div class="peach-settings-row peach-settings-row--meta">1429            <span>Advanced Vault Health</span>1430            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1431              open1432            </button>1433          </div>1434          <Show when={declinedCardCount() > 0}>1435            <div class="peach-settings-row peach-settings-row--meta">1436              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1437              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1438                Clear1439              </button>1440            </div>1441          </Show>14421443          <p class="peach-settings-sub-label">DATA</p>1444          <div class="peach-settings-row peach-settings-row--meta">1445            <span>Import passwords</span>1446            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1447              open1448            </button>1449          </div>1450          <div class="peach-settings-row peach-settings-row--meta">1451            <span>Export backup</span>1452            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1453              open1454            </button>1455          </div>1456          <div class="peach-settings-row peach-settings-row--meta">1457            <span>1458              Peach Codex1459              <Show when={!isPro()}>1460                <span class="peach-pro-badge">Pro</span>1461              </Show>1462            </span>1463            <button1464              class="peach-btn peach-btn--subtle peach-btn--sm"1465              onClick={() => props.onOpenCodexGenerate?.()}1466            >1467              open1468            </button>1469          </div>1470          {/* Restore from Codex: intentionally never gated — a printed1471              Codex must remain restorable regardless of license state. */}1472          <div class="peach-settings-row peach-settings-row--meta">1473            <span>Restore from Codex</span>1474            <button1475              class="peach-btn peach-btn--subtle peach-btn--sm"1476              onClick={() => props.onOpenCodexRestore?.()}1477            >1478              open1479            </button>1480          </div>14811482          <Show when={hasReleaseNotes()}>1483            <p class="peach-settings-sub-label">WHAT'S NEW</p>1484            <div class="peach-settings-row peach-settings-row--meta">1485              <span>v{PEACH_VERSION}</span>1486              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1487                view1488              </button>1489            </div>1490          </Show>14911492          <span class="peach-settings-sub-label">VAULT HISTORY</span>1493          <Show when={historyLoading()}>1494            <p class="peach-settings-hint">Loading…</p>1495          </Show>1496          <Show when={!historyLoading() && historySnapshots().length === 0}>1497            <p class="peach-settings-hint">1498              No snapshots yet. Peach saves automatically before every change —1499              saves, syncs, and imports.1500            </p>1501          </Show>1502          <Show when={!historyLoading() && historySnapshots().length > 0}>1503            <p class="peach-settings-hint">1504              Restoring locks your vault. Unlock with the password that1505              encrypted that snapshot to confirm.1506            </p>1507            <div class="peach-history-list">1508              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1509                {(snap) => (1510                  <div class="peach-history-row">1511                    <div class="peach-history-meta">1512                      <span class="peach-history-time">1513                        {formatSnapshotTime(snap.savedAt)}1514                      </span>1515                      <span class="peach-history-sub">1516                        {[1517                          snap.entryCount !== null1518                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1519                            : null,1520                          reasonLabel(snap.reason),1521                        ]1522                          .filter(Boolean)1523                          .join(" · ")}1524                      </span>1525                    </div>1526                    <button1527                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1528                        historyRestoring() === snap.id ? " is-busy" : ""1529                      }`}1530                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1531                      disabled={historyRestoring() !== null}1532                    >1533                      <Show when={historyRestoring() === snap.id} fallback="Restore">1534                        …1535                      </Show>1536                    </button>1537                  </div>1538                )}1539              </For>1540            </div>1541            <Show when={historySnapshots().length > 3}>1542              <button1543                class="peach-settings-advanced-toggle"1544                onClick={() => setHistoryShowAll((v) => !v)}1545                aria-expanded={historyShowAll()}1546              >1547                <span class="peach-settings-hint" style="margin:0">1548                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1549                </span>1550                <span class="peach-settings-advanced-chevron">1551                  {historyShowAll() ? "▲" : "▼"}1552                </span>1553              </button>1554            </Show>1555            <p class="peach-history-footer">1556              {historySnapshots().length} of 10 snapshots1557            </p>1558          </Show>15591560          <button1561            class="peach-settings-advanced-toggle"1562            onClick={() => setShowVaultAdvanced((v) => !v)}1563            aria-expanded={showVaultAdvanced()}1564          >1565            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1566            <span class="peach-settings-advanced-chevron">1567              {showVaultAdvanced() ? "▲" : "▼"}1568            </span>1569          </button>15701571          <Show when={showVaultAdvanced()}>1572            <div class="peach-danger-card">1573              <div class="peach-danger-card-head">1574                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1575                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1576              </div>1577              <p class="peach-danger-card-copy">1578                Wipes the current vault and restores from a backup file.1579                Recoverable only if your backup is good.1580              </p>1581              <button1582                class="peach-danger-card-button"1583                onClick={() => props.onOpenFileImport?.()}1584              >1585                Choose backup file1586              </button>1587            </div>15881589            <Show when={props.onOpenPanic}>1590              <div class="peach-danger-card peach-danger-card--hazard">1591                <div class="peach-danger-card-head">1592                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1593                  <h3 class="peach-danger-card-title">Destroy vault</h3>1594                </div>1595                <p class="peach-danger-card-copy">1596                  Permanently wipes every entry, setting, and license on this1597                  device. There is no recovery.1598                </p>1599                <button1600                  class="peach-danger-card-button"1601                  onClick={() => props.onOpenPanic?.()}1602                >1603                  Begin destruction1604                </button>1605              </div>1606            </Show>1607          </Show>1608        </div>1609      </div>1610    );1611  }16121613  function renderSync() {1614    return (1615      <div class="peach-settings-body">1616        <div class="peach-settings-group">1617          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1618          <div1619            class="peach-settings-row peach-settings-row--meta"1620            style="align-items:center;gap:8px"1621            role="status"1622            aria-label={syncLightLabel()}1623            title={syncLightLabel()}1624          >1625            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1626            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1627              {syncFlash() ?? lastSyncLine()}1628            </span>1629          </div>1630          <Show when={pairedDevices().length > 0}>1631            <div class="peach-settings-row peach-settings-row--meta">1632              <span class="peach-settings-value" style="color:var(--peach-t2)">1633                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01634                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1635                  : ""}1636              </span>1637              <button1638                class="peach-btn peach-btn--subtle peach-btn--sm"1639                aria-label="Sync this vault now"1640                disabled={syncStatus() === "syncing"}1641                onClick={() => void syncNow()}1642              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1643            </div>1644          </Show>16451646          <p class="peach-settings-sub-label">ADD A DEVICE</p>1647          <button1648            class="peach-sync-pair-option"1649            onClick={() => props.onOpenSyncPairing?.("code")}1650          >1651            <span class="peach-sync-pair-option-icon" aria-hidden="true">1652              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1653                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1654                <path d="M9 15 L11 15" />1655              </svg>1656            </span>1657            <span class="peach-sync-pair-option-main">1658              <span class="peach-sync-pair-option-title">Show a code</span>1659              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1660            </span>1661            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1662          </button>1663          <Show when={!isPro()}>1664            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1665              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1666            </p>1667          </Show>16681669          <Show when={pairedDevices().length > 0}>1670            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1671            <For each={pairedDevices()}>1672              {(d) => (1673                <div class="peach-settings-row peach-settings-row--top">1674                  <div class="peach-settings-row-main">1675                    <Show1676                      when={renameDeviceId() === d.deviceId}1677                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1678                    >1679                      <input1680                        class="peach-settings-input"1681                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1682                        maxlength={64}1683                        value={renameDraft()}1684                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1685                        onKeyDown={(event) => {1686                          if (event.key === "Enter") void renameDevice(d.deviceId);1687                          if (event.key === "Escape") setRenameDeviceId(null);1688                        }}1689                      />1690                    </Show>1691                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1692                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1693                    </span>1694                  </div>1695                  <Show1696                    when={confirmUnpair() === d.deviceId}1697                    fallback={1698                      <div style="display:flex;gap:4px;flex-shrink:0">1699                        <Show1700                          when={renameDeviceId() === d.deviceId}1701                          fallback={1702                            <button1703                              class="peach-btn peach-btn--subtle peach-btn--sm"1704                              onClick={() => {1705                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1706                                setRenameDeviceId(d.deviceId);1707                              }}1708                            >rename</button>1709                          }1710                        >1711                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1712                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1713                        </Show>1714                        <button1715                          class="peach-btn peach-btn--subtle peach-btn--sm"1716                          onClick={() => setConfirmUnpair(d.deviceId)}1717                        >remove</button>1718                      </div>1719                    }1720                  >1721                    {/* HOLE 4: revocation stops this device receiving future1722                        changes. It does NOT secure the vault against it — that1723                        device may already hold the recovery words, which are1724                        master-password-equivalent and restore the whole vault1725                        anywhere, forever, with no sync involved. Say so here,1726                        rather than leaving the user falsely reassured. */}1727                    <div class="peach-unpair-confirm">1728                      <p class="peach-unpair-confirm-note">1729                        Stops this device receiving future changes. It doesn't1730                        lock it out of what it already has — if it's lost or1731                        stolen, also change your master password and generate1732                        new recovery words.1733                      </p>1734                      <div style="display:flex;gap:6px;justify-content:flex-end">1735                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1736                        <button1737                          class="peach-btn peach-btn--sm"1738                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1739                          onClick={() => void unpairDevice(d.deviceId)}1740                        >remove</button>1741                      </div>1742                    </div>1743                  </Show>1744                </div>1745              )}1746            </For>1747          </Show>17481749          <Show when={pendingVouched().length > 0}>1750            <p class="peach-settings-sub-label">1751              PENDING DEVICES1752              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1753            </p>1754            <For each={pendingVouched()}>1755              {(dev) => (1756                <div class="peach-settings-row peach-settings-row--top">1757                  <div class="peach-settings-row-main">1758                    <span>{dev.deviceName}</span>1759                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1760                      Was added by another of your devices. Allow it to sync with this vault?1761                    </span>1762                  </div>1763                  <div style="display:flex;gap:6px;flex-shrink:0">1764                    <button1765                      class="peach-btn peach-btn--sm"1766                      disabled={vouchedBusy() === dev.deviceId}1767                      onClick={() => void confirmVouched(dev.deviceId)}1768                    >Allow</button>1769                    <button1770                      class="peach-btn peach-btn--subtle peach-btn--sm"1771                      disabled={vouchedBusy() === dev.deviceId}1772                      onClick={() => void rejectVouched(dev.deviceId)}1773                    >Reject</button>1774                  </div>1775                </div>1776              )}1777            </For>1778          </Show>17791780          <button1781            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1782            onClick={() => setShowSyncConnection((value) => !value)}1783            aria-expanded={showSyncConnection()}1784            aria-controls="peach-sync-connection"1785          >1786            <span>1787              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1788              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1789            </span>1790            <span class="peach-settings-advanced-chevron">1791              {showSyncConnection() ? "▲" : "▼"}1792            </span>1793          </button>1794          <Show when={showSyncConnection()}>1795            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1796              <SyncModeSelector />1797            </div>1798          </Show>17991800          <button1801            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1802            onClick={() => setShowSyncRecovery((value) => !value)}1803            aria-expanded={showSyncRecovery()}1804            aria-controls="peach-sync-recovery"1805          >1806            <span>1807              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1808              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1809            </span>1810            <span class="peach-settings-advanced-chevron">1811              {showSyncRecovery() ? "▲" : "▼"}1812            </span>1813          </button>1814          <Show when={showSyncRecovery()}>1815            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1816              <Show1817                when={rotateConfirmOpen()}1818                fallback={1819                  <div class="peach-settings-row peach-settings-row--top">1820                    <div class="peach-settings-row-main">1821                      <span>Rotate sync identity</span>1822                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1823                        Disconnects every device and replaces the keys used for sync.1824                      </span>1825                    </div>1826                    <button1827                      class="peach-btn peach-btn--subtle peach-btn--sm"1828                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1829                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1830                    >rotate</button>1831                  </div>1832                }1833              >1834                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1835                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1836                  <Show when={rotateError()}>1837                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1838                  </Show>1839                  <div style="display:flex;gap:6px;margin-top:10px">1840                    <button1841                      class="peach-btn peach-btn--subtle peach-btn--sm"1842                      disabled={rotating()}1843                      onClick={() => setRotateConfirmOpen(false)}1844                    >Cancel</button>1845                    <button1846                      class="peach-btn peach-btn--sm"1847                      style="background:var(--peach-breach);border-color:var(--peach-breach)"1848                      disabled={rotating()}1849                      onClick={() => void doRotateSyncIdentity()}1850                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1851                  </div>1852                </div>1853              </Show>1854            </div>1855          </Show>1856        </div>1857      </div>1858    );1859  }18601861  function renderAppearance() {1862    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1863      { id: "medium", label: "Default" },1864      { id: "large", label: "Large" },1865    ];1866    return (1867      <div class="peach-settings-body">1868        <div class="peach-settings-group">1869          <p class="peach-settings-sub-label">THEME</p>1870          <div class="peach-settings-row peach-settings-row--meta">1871            <span>Theme</span>1872            <button1873              class="peach-btn peach-btn--subtle peach-btn--sm"1874              onClick={() =>1875                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1876              }1877            >1878              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1879            </button>1880          </div>18811882          <p class="peach-settings-sub-label">ACCENT</p>1883          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1884            <span>Accent</span>1885            <div class="peach-accent-picker">1886              <For each={ACCENT_COLORS}>1887                {(color) => (1888                  <button1889                    class="peach-accent-swatch peach-swatch"1890                    classList={{ "is-active": props.accentColor === color.name }}1891                    style={{ background: color.hex }}1892                    onClick={() => void props.onAccentChange(color.name)}1893                    aria-label={color.name}1894                    title={color.name}1895                  />1896                )}1897              </For>1898            </div>1899          </div>19001901          <p class="peach-settings-sub-label">TEXT SIZE</p>1902          <div class="peach-settings-row peach-settings-row--meta">1903            <span>Size</span>1904            <div class="peach-pill-group">1905              <For each={sizeOptions}>1906                {(opt) => (1907                  <button1908                    class="peach-btn peach-btn--ghost peach-btn--sm"1909                    classList={{ "is-active": props.textSize === opt.id }}1910                    onClick={() => void props.onTextSizeChange(opt.id)}1911                  >1912                    {opt.label}1913                  </button>1914                )}1915              </For>1916            </div>1917          </div>19181919          <p class="peach-settings-sub-label">MOTION</p>1920          <div class="peach-settings-row peach-settings-row--meta">1921            <span>Reduce motion</span>1922            <button1923              class="peach-btn peach-btn--subtle peach-btn--sm"1924              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1925            >1926              {props.reduceMotion ? "On" : "Off"}1927            </button>1928          </div>1929        </div>1930      </div>1931    );1932  }19331934  function renderShortcuts() {1935    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1936    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);19371938    function startCapture(action: ShortcutAction) {1939      setConflictMsg(null);1940      setCapturingAction(action);1941    }1942    function cancelCapture() {1943      setCapturingAction(null);1944      setConflictMsg(null);1945    }1946    function commitBinding(action: ShortcutAction, binding: Keybinding) {1947      const conflict = findConflict(props.shortcuts, binding, action);1948      if (conflict) {1949        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1950        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1951        return;1952      }1953      const next = { ...props.shortcuts, [action]: binding };1954      setCapturingAction(null);1955      setConflictMsg(null);1956      void props.onShortcutsChange(next);1957    }1958    function resetAction(action: ShortcutAction) {1959      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1960      void props.onShortcutsChange(next);1961    }1962    function resetAll() {1963      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1964    }19651966    // Capture-mode key handler — wired via ref onto the chip element so it1967    // catches the next non-modifier keypress and ignores other bindings.1968    function handleCaptureKey(e: KeyboardEvent) {1969      e.preventDefault();1970      e.stopPropagation();1971      if (e.key === "Escape") { cancelCapture(); return; }1972      const binding = keybindingFromEvent(e);1973      if (!binding) return; // bare modifier — keep waiting1974      const action = capturingAction();1975      if (action) commitBinding(action, binding);1976    }19771978    return (1979      <div class="peach-settings-body">1980        <div class="peach-settings-group peach-shortcuts-group">1981          <p class="peach-settings-sub-label">ENTRY VIEW</p>1982          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1983            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1984          </For>1985        </div>19861987        <div class="peach-settings-group peach-shortcuts-group">1988          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1989          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1990            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1991          </For>1992        </div>19931994        <Show when={conflictMsg()}>1995          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1996        </Show>19971998        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1999          <div class="peach-settings-row peach-settings-row--meta">2000            <span>Inline autofill on page</span>2001            <a2002              class="peach-settings-link"2003              href="chrome://extensions/shortcuts"2004              onClick={(e) => {2005                e.preventDefault();2006                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2007              }}2008            >2009              ⌘⇧P — customize ↗2010            </a>2011          </div>2012          <button2013            class="peach-btn peach-btn--subtle peach-btn--sm"2014            style="align-self:flex-start"2015            onClick={resetAll}2016          >2017            Reset all to defaults2018          </button>2019        </div>2020      </div>2021    );20222023    function renderRow(2024      desc: typeof SHORTCUT_DESCRIPTORS[number],2025      capturing: () => ShortcutAction | null,2026      onStart: (a: ShortcutAction) => void,2027      onCancel: () => void,2028      onReset: (a: ShortcutAction) => void,2029      onKey: (e: KeyboardEvent) => void,2030    ) {2031      const isActive = () => capturing() === desc.action;2032      const binding = () => props.shortcuts[desc.action];2033      const isCustom = () => {2034        const b = binding();2035        const d = DEFAULT_BINDINGS[desc.action];2036        return b.key !== d.key2037          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2038          || Boolean(b.meta)  !== Boolean(d.meta)2039          || Boolean(b.shift) !== Boolean(d.shift)2040          || Boolean(b.alt)   !== Boolean(d.alt);2041      };2042      return (2043        <div class="peach-shortcut-row">2044          <span class="peach-shortcut-row-label">{desc.label}</span>2045          <button2046            class="peach-shortcut-chip"2047            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2048            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2049            onKeyDown={(e) => { if (isActive()) onKey(e); }}2050            ref={(el) => {2051              // Auto-focus when entering capture mode so the keydown listener catches the next press.2052              if (el) {2053                queueMicrotask(() => { if (isActive()) el.focus(); });2054              }2055            }}2056          >2057            {isActive() ? "Press a key…" : formatKeybinding(binding())}2058          </button>2059          <Show when={isCustom() && !isActive()}>2060            <button2061              class="peach-shortcut-reset"2062              title="Reset to default"2063              onClick={() => onReset(desc.action)}2064            >2065              ↺2066            </button>2067          </Show>2068        </div>2069      );2070    }2071  }20722073  const PRO_FEATURES: Array<{2074    feature: ProFeature;2075    name: string;2076    desc: string;2077  }> = [2078    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2079    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2080    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2081    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2082  ];20832084  const PRO_SECURITY_FEATURES: Array<{2085    feature: ProFeature;2086    name: string;2087    desc: string;2088  }> = [2089    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2090    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2091  ];20922093  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2094  const [showKey, setShowKey] = createSignal(false);20952096  const [deactivating, setDeactivating] = createSignal(false);20972098  async function handleDeactivateLicense() {2099    setDeactivating(true);2100    await sendMessage({ type: "DEACTIVATE_LICENSE" });2101    setLicenseInfo(null);2102    await props.onLicenseChange?.();2103    setDeactivating(false);2104  }21052106  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2107    setCanaryTestingId(entry.id);2108    setCanaryTestResult(null);2109    const res = await sendMessage({2110      type: "FIRE_CANARY_PING",2111      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2112    }) as { ok: boolean };2113    setCanaryTestResult({ id: entry.id, ok: res.ok });2114    setCanaryTestingId(null);2115    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2116  }21172118  function renderLicense() {2119    async function onActivate() {2120      await handleActivateLicense();2121      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2122        await props.onLicenseChange?.();2123      }2124    }21252126    return (2127      <div class="peach-settings-body">2128        <Show2129          when={isPro()}2130          fallback={2131                <div class="peach-pro-upgrade">2132                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>21332134                  <h2 class="peach-pro-headline">2135                    Every<em>thing,</em><br />every<em>where.</em>2136                  </h2>21372138                  <p class="peach-pro-subtitle">2139                    One key unlocks every Pro feature — for the lifetime of the app.2140                  </p>21412142                  <div class="peach-pro-grid">2143                    <For each={PRO_FEATURES}>2144                      {(item, i) => (2145                        <div2146                          class="peach-pro-grid-item"2147                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2148                        >2149                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2150                          <span class="peach-pro-grid-name">{item.name}</span>2151                          <span class="peach-pro-grid-desc">{item.desc}</span>2152                        </div>2153                      )}2154                    </For>2155                  </div>21562157                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2158                  <div class="peach-pro-grid">2159                    <For each={PRO_SECURITY_FEATURES}>2160                      {(item, i) => (2161                        <div2162                          class="peach-pro-grid-item"2163                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2164                        >2165                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2166                          <span class="peach-pro-grid-name">{item.name}</span>2167                          <span class="peach-pro-grid-desc">{item.desc}</span>2168                        </div>2169                      )}2170                    </For>2171                  </div>21722173                  <div class="peach-pro-upgrade-actions">2174                    <p class="peach-pro-pricing">2175                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2176                    </p>2177                    <a2178                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2179                      target="_blank"2180                      rel="noopener noreferrer"2181                      class="peach-pro-upgrade-cta"2182                    >2183                      Get Peach Pro2184                    </a>2185                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2186                    <div class="peach-pro-key-inline">2187                      <input2188                        class="peach-pro-key-inline-input"2189                        type="text"2190                        placeholder="PEACH-XXXX-XXXX-XXXX"2191                        value={licenseKeyInput()}2192                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2193                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2194                        autocomplete="off"2195                        spellcheck={false}2196                      />2197                      <button2198                        class="peach-pro-key-apply"2199                        onClick={() => void onActivate()}2200                        disabled={activatingLicense() || !licenseKeyInput().trim()}2201                      >2202                        {activatingLicense() ? "…" : "APPLY"}2203                      </button>2204                    </div>2205                    <Show when={licenseError()}>2206                      <p class="peach-pro-key-error">{licenseError()}</p>2207                    </Show>2208                  </div>2209                </div>2210          }2211        >2212          <div class="peach-pro-active">2213            <div class="peach-pro-active-hero">2214              <button2215                class="peach-pro-active-mark-wrap"2216                type="button"2217                aria-label="Celebrate"2218                onClick={handleProCelebrationClick}2219              >2220                <PeachMark class="peach-pro-active-mark" />2221                <Show when={justActivated()}>2222                  <div class="peach-pro-sparks" aria-hidden="true">2223                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2224                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2225                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2226                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2227                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2228                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2229                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2230                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2231                  </div>2232                </Show>2233              </button>2234              <h2 class="peach-pro-active-title">Peach Pro</h2>2235              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2236              <Show when={justActivated()}>2237                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2238              </Show>2239            </div>22402241            <Show when={licenseKey()}>2242              <div class="peach-settings-license">2243                <span class="peach-settings-label">LICENSE KEY</span>2244                <div class="peach-settings-license-row">2245                  <Show2246                    when={showKey()}2247                    fallback={2248                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2249                        ••••••••••••••••••••••••••••2250                      </span>2251                    }2252                  >2253                    <span class="peach-settings-license-key">{licenseKey()!}</span>2254                  </Show>2255                  <div class="peach-settings-license-actions">2256                    <button2257                      class="peach-settings-license-copy"2258                      title="Copy license key"2259                      onClick={() => copyLicenseKey(licenseKey()!)}2260                    >2261                      {copied() ? "✓" : "copy"}2262                    </button>2263                    <button2264                      class="peach-settings-license-toggle"2265                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2266                      title={showKey() ? "Hide" : "Reveal"}2267                      onClick={() => setShowKey(v => !v)}2268                    >2269                      <Show2270                        when={showKey()}2271                        fallback={2272                          <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">2273                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2274                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2275                          </svg>2276                        }2277                      >2278                        <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">2279                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2280                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2281                          <line x1="2" y1="2" x2="12" y2="12" />2282                        </svg>2283                      </Show>2284                    </button>2285                  </div>2286                </div>2287              </div>2288            </Show>22892290            <div class="peach-pro-active-features">2291              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2292                {(item) => (2293                  <div class="peach-pro-active-feature">2294                    <span class="peach-pro-active-feature-mark">✦</span>2295                    <span class="peach-pro-active-feature-name">{item.name}</span>2296                  </div>2297                )}2298              </For>2299            </div>23002301            <button2302              class="peach-pro-deactivate"2303              onClick={() => void handleDeactivateLicense()}2304              disabled={deactivating()}2305            >2306              {deactivating() ? "Deactivating…" : "Deactivate"}2307            </button>2308          </div>2309        </Show>2310      </div>2311    );2312  }23132314  function renderFeedback() {2315    return (2316      <div class="peach-settings-body">2317        <div class="peach-settings-group peach-feedback-group">2318          <Show2319            when={feedbackState() === 'sent'}2320            fallback={2321              <>2322                <p class="peach-feedback-intro">2323                  Help us improve Peach. Every report goes directly to our inbox.2324                </p>23252326                <p class="peach-settings-sub-label">TYPE</p>2327                <div class="peach-feedback-chips">2328                  <For each={FEEDBACK_CATEGORIES}>2329                    {(cat) => {2330                      const selected = () => fbCategory() === cat;2331                      return (2332                        <button2333                          type="button"2334                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2335                          aria-pressed={selected()}2336                          onClick={() => setFbCategory(cat)}2337                        >2338                          {cat}2339                        </button>2340                      );2341                    }}2342                  </For>2343                </div>23442345                <p class="peach-settings-sub-label">MESSAGE</p>2346                <textarea2347                  class="peach-feedback-textarea"2348                  placeholder="Describe the issue or idea..."2349                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2350                  value={fbMessage()}2351                  onInput={(e) => setFbMessage(e.currentTarget.value)}2352                />23532354                <p class="peach-feedback-meta">2355                  Version {version} · will be included automatically2356                </p>23572358                <button2359                  type="button"2360                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2361                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2362                  onClick={() => void handleSubmitFeedback()}2363                >2364                  <Switch>2365                    <Match when={feedbackState() === 'idle'}>Send</Match>2366                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2367                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2368                  </Switch>2369                </button>23702371                <p class="peach-feedback-anon">2372                  Sent anonymously · no account required2373                </p>2374              </>2375            }2376          >2377            <div class="peach-feedback-thanks" role="status" aria-live="polite">2378              <p class="peach-feedback-thanks-title">Thank you</p>2379              <p>2380                Your note is in the queue. Someone will process it soon.2381              </p>2382            </div>2383          </Show>2384        </div>2385      </div>2386    );2387  }23882389  const bodies: StackBodies = {2390    protection: renderSecurity(),2391    vault: renderVault(),2392    sync: renderSync(),2393    appearance: renderAppearance(),2394    shortcuts: renderShortcuts(),2395    pro: renderLicense(),2396    feedback: renderFeedback(),2397  };23982399  return (2400    <Show2401      when={showWhatsNew()}2402      fallback={2403    <Show2404      when={showNewRecoveryWords()}2405      fallback={2406        <div2407          class="peach-overlay-view peach-settings-overlay"2408          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2409          style="display:flex;flex-direction:column"2410        >2411          <StackDeck2412            active={activeCard()}2413            onActiveChange={setActiveCard}2414            onBack={props.onBack}2415            cards={cards()}2416            bodies={bodies}2417            version={version}2418            isFanning={isFanning()}2419            celebratingCardId={justActivated() ? "pro" : null}2420            isPro={isPro()}2421          />2422        </div>2423      }2424    >2425      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2426    </Show>2427      }2428    >2429      <div class="peach-overlay-view peach-settings-overlay">2430        <WhatsNewView onContinue={() => setShowWhatsNew(false)} />2431      </div>2432    </Show>2433  );2434}