Settings.tsx

98.9 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 {247    deviceId: string;248    deviceName: string;249    longTermPublicKeyHex: string;250    receivedAt: number;251    vouchedByDeviceId: string;252  }253  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);254  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);255  // Paired-device management lives here — Settings is the sync hub.256  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);257  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);258  const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);259  const [renameDraft, setRenameDraft] = createSignal("");260  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");261  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);262  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —263  // a transient toast is no use for diagnosing a phone that isn't syncing.264  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});265  const syncLight = () =>266    syncStatus() === "syncing"267      ? "busy"268      : syncIndicator(syncSummary(), pairedDevices().length);269  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());270  const lastSyncLine = () => {271    const s = syncSummary();272    if (!s.outcome) return "No sync attempted yet";273    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;274  };275  async function loadSyncState() {276    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {277      ok: boolean;278      data?: SyncOutcomeSummary;279    };280    if (r.ok && r.data) {281      setSyncSummary(r.data);282      setSyncStatus(r.data.status ?? "idle");283    }284  }285286  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;287  function syncRelativeTime(when: number | undefined): string {288    if (!when) return "Not synced yet";289    const elapsed = Date.now() - when;290    if (elapsed < 60_000) return "Just now";291    const minutes = Math.floor(elapsed / 60_000);292    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;293    const hours = Math.floor(minutes / 60);294    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;295    const days = Math.floor(hours / 24);296    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";297  }298  function isSyncStale(d: PairedDevice): boolean {299    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;300  }301  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {302    switch (t) {303      case "android": return "Android";304      case "ios": return "iPhone";305      case "extension": return "Browser";306      default: return "Device";307    }308  }309310  async function loadPairedDevices() {311    if (!isPro()) return;312    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {313      ok: boolean;314      data?: { devices: PairedDevice[] };315    };316    if (r.ok) setPairedDevices(r.data?.devices ?? []);317  }318  async function unpairDevice(deviceId: string) {319    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });320    setConfirmUnpair(null);321    await loadPairedDevices();322  }323  async function renameDevice(deviceId: string) {324    const name = renameDraft().trim();325    if (!name) return;326    const result = (await sendMessage({327      type: "RENAME_PAIRED_DEVICE",328      payload: { deviceId, name },329    })) as { ok: boolean };330    if (!result.ok) return;331    setRenameDeviceId(null);332    await loadPairedDevices();333  }334  async function syncNow() {335    setSyncStatus("syncing");336    setSyncFlash(null);337    const res = (await sendMessage({338      type: "TRIGGER_SYNC",339      payload: { reason: "manual", force: true },340    })) as { ok: boolean; error?: string };341    if (!res.ok) {342      setSyncStatus("error");343      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });344      return;345    }346    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the347    // outcome. A single delayed read used to land while the run was still348    // "syncing" and leave the button disabled forever.349    let s: SyncOutcomeSummary = { status: "syncing" };350    for (let i = 0; i < 40 && s.status === "syncing"; i++) {351      await new Promise((r) => setTimeout(r, 250));352      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {353        ok: boolean;354        data?: SyncOutcomeSummary;355      };356      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };357    }358    setSyncSummary(s);359    setSyncStatus(s.status ?? "idle");360    // The persistent line below carries the outcome; the flash is only the361    // "that click did something" acknowledgement.362    setSyncFlash(describeSyncOutcome(s));363    setTimeout(() => setSyncFlash(null), 4000);364    await loadPairedDevices();365  }366  // UI-4 / MV-8: rotate sync identity confirmation dialog.367  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);368  const [rotating, setRotating] = createSignal(false);369  const [rotateError, setRotateError] = createSignal<string | null>(null);370  const [showSyncConnection, setShowSyncConnection] = createSignal(false);371  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);372373  async function loadPendingVouched() {374    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {375      ok: boolean;376      data?: PendingVouched[];377    };378    if (r.ok) setPendingVouched(r.data ?? []);379  }380  async function confirmVouched(deviceId: string) {381    setVouchedBusy(deviceId);382    try {383      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });384      await loadPendingVouched();385    } finally {386      setVouchedBusy(null);387    }388  }389  async function rejectVouched(deviceId: string) {390    setVouchedBusy(deviceId);391    try {392      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });393      await loadPendingVouched();394    } finally {395      setVouchedBusy(null);396    }397  }398  async function doRotateSyncIdentity() {399    setRotateError(null);400    setRotating(true);401    try {402      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {403        ok: boolean;404        error?: string;405      };406      if (!r.ok) {407        setRotateError(r.error ?? "Rotation failed.");408        return;409      }410      setRotateConfirmOpen(false);411      // Backend set peach_recovery_pending; reuse the existing recovery-words412      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).413      setShowNewRecoveryWords(true);414    } finally {415      setRotating(false);416    }417  }418419  const [dedupCount, setDedupCount] = createSignal<number | null>(null);420  const [dedupRunning, setDedupRunning] = createSignal(false);421  const [dedupDone, setDedupDone] = createSignal<number | null>(null);422423  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);424425  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted426  // BEFORE every real-mode vault write so the user can roll back if a sync427  // or import overwrites the wrong state.428  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);429  const [historyLoading, setHistoryLoading] = createSignal(false);430  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);431  const [historyShowAll, setHistoryShowAll] = createSignal(false);432433  async function loadVaultHistory() {434    setHistoryLoading(true);435    try {436      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });437      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];438      setHistorySnapshots(list);439    } finally {440      setHistoryLoading(false);441    }442  }443444  async function handleRestoreVaultSnapshot(id: string) {445    if (historyRestoring()) return;446    setHistoryRestoring(id);447    const res = await sendMessage({448      type: "RESTORE_VAULT_SNAPSHOT",449      payload: { snapshotId: id },450    });451    setHistoryRestoring(null);452    if ((res as { ok: boolean }).ok) {453      // Vault is now locked — let the parent close Settings; App.tsx's454      // VAULT_LOCKED listener will route back to the lock screen.455      props.onBack();456    }457  }458459  function formatSnapshotTime(savedAt: number): string {460    const diff = Date.now() - savedAt;461    if (diff < 60_000) return "Just now";462    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;463    const d = new Date(savedAt);464    const isToday = new Date().toDateString() === d.toDateString();465    const isYest =466      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();467    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });468    if (isToday) return `Today ${t}`;469    if (isYest) return `Yesterday ${t}`;470    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;471  }472473  function reasonLabel(reason: VaultSnapshot["reason"]): string {474    return (475      {476        local_save: "saved",477        sync_received: "synced",478        import: "imported",479        restore: "restored",480      } as const481    )[reason] ?? reason;482  }483484  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);485  const [showWhatsNew, setShowWhatsNew] = createSignal(false);486487  const [showChangePw, setShowChangePw] = createSignal(false);488  const [changePwCurrent, setChangePwCurrent] = createSignal("");489  const [changePwNew, setChangePwNew] = createSignal("");490  const [changePwConfirm, setChangePwConfirm] = createSignal("");491  const [changePwSaving, setChangePwSaving] = createSignal(false);492  const [changePwError, setChangePwError] = createSignal<string | null>(null);493  const [changePwSuccess] = createSignal(false);494  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);495496  const [pinEnabled, setPinEnabled] = createSignal(false);497  const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);498  const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");499  const [pinValue, setPinValue] = createSignal("");500  const [pinConfirm, setPinConfirm] = createSignal("");501  const [pinSaving, setPinSaving] = createSignal(false);502  const [pinError, setPinError] = createSignal<string | null>(null);503504  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.505  const [showViewWords, setShowViewWords] = createSignal(false);506  const [viewWordsPassword, setViewWordsPassword] = createSignal("");507  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);508  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);509  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);510511  async function handleViewRecoveryWords() {512    setViewWordsError(null);513    if (!viewWordsPassword()) {514      setViewWordsError("Enter your master password.");515      return;516    }517    setViewWordsBusy(true);518    try {519      const r = (await sendMessage({520        type: "GET_RECOVERY_WORDS",521        payload: { password: viewWordsPassword() },522      })) as { ok: boolean; data?: string[]; error?: string };523      if (r.ok) {524        setViewedWords(r.data ?? []);525        setViewWordsPassword("");526        return;527      }528      switch (r.error) {529        case "wrong_password":530          setViewWordsError("Incorrect password");531          break;532        case "password_required":533          setViewWordsError("Enter your master password.");534          break;535        case "recovery_not_setup":536          setViewWordsError("Recovery words aren't set up for this vault yet.");537          break;538        default:539          setViewWordsError("Couldn't retrieve recovery words.");540      }541    } finally {542      setViewWordsBusy(false);543    }544  }545  function closeViewWords() {546    setShowViewWords(false);547    setViewWordsPassword("");548    setViewWordsError(null);549    setViewedWords(null);550  }551552  const [duressEnabled, setDuressEnabled] = createSignal(false);553  const [showDuressSetup, setShowDuressSetup] = createSignal(false);554  const [duressPassword, setDuressPassword] = createSignal("");555  const [duressConfirm, setDuressConfirm] = createSignal("");556  const [duressMainPassword, setDuressMainPassword] = createSignal("");557  const [duressSaving, setDuressSaving] = createSignal(false);558  const [duressError, setDuressError] = createSignal<string | null>(null);559  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);560561  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);562563  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);564  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);565566  const [fbCategory, setFbCategory] = createSignal("Bug report");567  const [fbMessage, setFbMessage] = createSignal("");568  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';569  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');570571  createEffect(() => {572    if (props.upgradeHighlight) {573      setActiveCard("pro");574    }575  });576577  onMount(async () => {578    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([579      sendMessage({ type: "GET_LICENSE" }),580      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),581      sendMessage({ type: "IS_DURESS_ENABLED" }),582      sendMessage({ type: "GET_SETTINGS" }),583      sendMessage({ type: "GET_LICENSE_KEY" }),584    ]);585586    // Vault history loads in the background — the Vault sub-view picks it up587    // when the user navigates in. Loading it lazily keeps Settings boot fast.588    void loadVaultHistory();589590    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the591    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.592    void loadPendingVouched();593    void loadPairedDevices();594    void loadSyncState();595    const onBroadcast = (msg: unknown) => {596      const m = msg as { type?: string; payload?: unknown } | null;597      if (m?.type === "VOUCHED_DEVICE_PENDING") {598        void loadPendingVouched();599      }600      if (m?.type === "SYNC_STATE_CHANGED") {601        const payload = (m.payload ?? {}) as SyncOutcomeSummary;602        setSyncSummary(payload);603        setSyncStatus(payload.status ?? "idle");604      }605    };606    chrome.runtime?.onMessage?.addListener(onBroadcast);607    onCleanup(() => {608      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }609    });610611    if ((licenseRes as { ok: boolean }).ok) {612      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);613    }614615    if ((passkeyRes as { ok: boolean }).ok) {616      setPasskeyState(617        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null618      );619    }620621    if ((duressRes as { ok: boolean }).ok) {622      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);623    }624625    if ((settingsRes as { ok: boolean }).ok) {626      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;627      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);628      setPinEnabled(sData?.pinEnabled === true);629    }630631    if ((keyRes as { ok: boolean }).ok) {632      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);633    }634635    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });636    if ((entriesRes as { ok: boolean }).ok) {637      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];638      setCanaryEntries(639        entries640          .filter((e) => e.canary?.webhookUrl)641          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))642      );643    }644645  });646647  function resetPinFlow() {648    setPinFlow(null);649    setPinCurrentPassword("");650    setPinValue("");651    setPinConfirm("");652    setPinError(null);653  }654655  async function handleSetupPin() {656    setPinError(null);657    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }658    if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }659    if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }660    setPinSaving(true);661    const res = await sendMessage({662      type: "SETUP_PIN",663      payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },664    }) as { ok: boolean; error?: string };665    if (res.ok) {666      setPinEnabled(true);667      resetPinFlow();668    } else {669      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");670    }671    setPinSaving(false);672  }673674  async function handleDisablePin() {675    setPinError(null);676    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }677    setPinSaving(true);678    const res = await sendMessage({679      type: "DISABLE_PIN",680      payload: { currentPassword: pinCurrentPassword() },681    }) as { ok: boolean; error?: string };682    if (res.ok) {683      setPinEnabled(false);684      resetPinFlow();685    } else {686      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");687    }688    setPinSaving(false);689  }690691  async function handleChangePassword() {692    setChangePwError(null);693    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }694    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }695    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }696    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }697    setChangePwSaving(true);698    const res = await sendMessage({699      type: "CHANGE_PASSWORD",700      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },701    }) as { ok: boolean; error?: string };702    if (res.ok) {703      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");704      setShowChangePw(false);705      // LV-4: the user just authenticated (current password), so the recovery706      // words reveal that follows is exempt from the re-auth gate. The707      // background owns this session-scoped exemption; the display clears it.708      await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });709      setShowNewRecoveryWords(true);710    } else {711      setChangePwError(res.error ?? "failed to change password");712    }713    setChangePwSaving(false);714  }715716  const [copied, setCopied] = createSignal(false);717718  async function copyLicenseKey(key: string) {719    await navigator.clipboard.writeText(key);720    setCopied(true);721    setTimeout(() => setCopied(false), 2000);722  }723724  async function handleActivateLicense() {725    const key = licenseKeyInput().trim();726    if (!key) return;727    setActivatingLicense(true);728    setLicenseError(null);729    setLicenseSuccess(null);730    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });731    setActivatingLicense(false);732    if ((res as { ok: boolean }).ok) {733      const data = (res as { ok: boolean; data?: LicenseInfo }).data;734      if (data?.valid) {735        setLicenseInfo(data);736        setLicenseKey(key);737        setLicenseSuccess("License activated!");738        setLicenseKeyInput("");739      } else {740        setLicenseError("Invalid license key");741      }742    } else {743      setLicenseError("Activation failed");744    }745  }746747  async function handleTogglePasskeys() {748    const current = passkeyState();749    if (!current || !current.supported) return;750    setPasskeySaving(true);751    const res = await sendMessage({752      type: "SET_PASSKEY_PROVIDER_ENABLED",753      payload: { enabled: !current.enabled },754    });755    setPasskeySaving(false);756    if ((res as { ok: boolean }).ok) {757      setPasskeyState(758        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null759      );760    }761  }762763  async function handleCheckAllBreaches() {764    setCheckingBreaches(true);765    setBreachResult(null);766    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });767    setCheckingBreaches(false);768    if ((res as { ok: boolean }).ok) {769      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};770      const breached = Object.values(data).filter((count) => count > 0).length;771      setBreachResult(772        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"773      );774      props.onBreachCheckDone();775    }776  }777778779  async function handleSetupDuress() {780    const mainPw = duressMainPassword().trim();781    const duressPw = duressPassword().trim();782    const confirmPw = duressConfirm().trim();783784    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }785    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }786    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }787    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }788789    setDuressSaving(true);790    setDuressError(null);791792    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };793    if (!verifyRes.ok || !verifyRes.data) {794      setDuressError("incorrect main password");795      setDuressSaving(false);796      return;797    }798799    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };800    setDuressSaving(false);801    if (res.ok) {802      setDuressEnabled(true);803      setShowDuressSetup(false);804      setDuressPassword("");805      setDuressConfirm("");806      setDuressMainPassword("");807      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");808      setTimeout(() => setDuressSuccess(null), 5000);809    } else {810      setDuressError(811        res.error === "session_expired"812          ? "Vault session expired — lock and unlock Peach, then try again"813          : res.error ?? "failed to set duress password"814      );815    }816  }817818  async function handleClearDeclinedCards() {819    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });820    setDeclinedCardCount(0);821  }822823  async function handleCountDuplicates() {824    setDedupRunning(true);825    setDedupDone(null);826    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };827    setDedupRunning(false);828    if (res.ok) setDedupCount(res.data ?? 0);829  }830831  async function handleDeduplicate() {832    setDedupRunning(true);833    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };834    setDedupRunning(false);835    if (res.ok) {836      setDedupDone(res.data ?? 0);837      setDedupCount(0);838    }839  }840841  async function handleDisableDuress() {842    const mainPw = duressMainPassword().trim();843    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }844845    setDuressSaving(true);846    setDuressError(null);847848    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };849    if (!verifyRes.ok || !verifyRes.data) {850      setDuressError("incorrect main password");851      setDuressSaving(false);852      return;853    }854855    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };856    setDuressSaving(false);857    if (res.ok) {858      setDuressEnabled(false);859      setShowDuressSetup(false);860      setDuressMainPassword("");861      setDuressSuccess("Duress password disabled.");862      setTimeout(() => setDuressSuccess(null), 4000);863    } else {864      setDuressError(res.error ?? "failed to disable duress password");865    }866  }867868  async function handleSubmitFeedback() {869    const message = sanitizeFeedbackMessage(fbMessage());870    if (!message) return;871872    setFeedbackState('sending');873874    try {875      const category = FEEDBACK_CATEGORIES.includes(fbCategory())876        ? fbCategory()877        : FEEDBACK_CATEGORIES[0];878      const res = await fetch('https://api.web3forms.com/submit', {879        method: 'POST',880        headers: { 'Content-Type': 'application/json' },881        body: JSON.stringify({882          access_key: WEB3FORMS_KEY,883          subject: `Peach Feedback - ${category}`,884          message,885          from_name: 'Peach User',886          // Honeypot — leave blank887          botcheck: '',888        }),889      });890891      if (!res.ok) throw new Error(`HTTP ${res.status}`);892      const data = await res.json() as { success: boolean; message?: string };893      if (!data.success) throw new Error(data.message ?? 'unknown error');894895      setFeedbackState('sent');896      setFbMessage('');897      setFbCategory(FEEDBACK_CATEGORIES[0]);898    } catch (err) {899      console.error('[feedback]', err);900      setFeedbackState('error');901      setTimeout(() => setFeedbackState('idle'), 4000);902    }903  }904905  function renderSecurity() {906    return (907      <div class="peach-settings-body">908        <div class="peach-settings-group">909          <p class="peach-settings-sub-label">LOCKING</p>910          <div class="peach-settings-row">911            <span>Lock on close</span>912            <button913              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}914              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}915              aria-label="Toggle lock on close"916            >917              <span class="peach-toggle-knob"></span>918            </button>919          </div>920          <div class="peach-settings-row">921            <span>Auto-lock after</span>922            <select923              class="peach-settings-select"924              value={String(props.autoLockMinutes)}925              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}926            >927              <option value="1">1 minute</option>928              <option value="5">5 minutes</option>929              <option value="15">15 minutes</option>930              <option value="30">30 minutes</option>931              <option value="0">Never</option>932            </select>933          </div>934935          <p class="peach-settings-sub-label">QUICK UNLOCK</p>936          <div class="peach-settings-row peach-settings-row--meta">937            <span>PIN unlock</span>938            <button939              class="peach-btn peach-btn--subtle peach-btn--sm"940              onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}941            >942              {pinEnabled() ? "remove" : "set up"}943            </button>944          </div>945          <Show when={pinFlow() === "setup"}>946            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">947              <input948                class="peach-settings-input"949                type="password"950                placeholder="current password"951                value={pinCurrentPassword()}952                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}953                autocomplete="current-password"954              />955              <input956                class="peach-settings-input"957                type="password"958                inputmode="numeric"959                maxlength="12"960                placeholder="new 4–12 digit PIN"961                value={pinValue()}962                onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}963                autocomplete="off"964              />965              <input966                class="peach-settings-input"967                type="password"968                inputmode="numeric"969                maxlength="12"970                placeholder="confirm PIN"971                value={pinConfirm()}972                onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}973                autocomplete="off"974                onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}975              />976              <Show when={pinError()}>977                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>978              </Show>979              <button980                class="peach-btn peach-btn--accent peach-btn--sm"981                style="align-self:flex-start"982                onClick={() => void handleSetupPin()}983                disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}984              >985                {pinSaving() ? "saving…" : "save PIN"}986              </button>987            </div>988          </Show>989          <Show when={pinFlow() === "disable"}>990            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">991              <input992                class="peach-settings-input"993                type="password"994                placeholder="current password"995                value={pinCurrentPassword()}996                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}997                autocomplete="current-password"998                onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}999              />1000              <Show when={pinError()}>1001                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1002              </Show>1003              <button1004                class="peach-btn peach-btn--subtle peach-btn--sm"1005                style="align-self:flex-start"1006                onClick={() => void handleDisablePin()}1007                disabled={pinSaving() || !pinCurrentPassword()}1008              >1009                {pinSaving() ? "removing…" : "remove PIN"}1010              </button>1011            </div>1012          </Show>10131014          <p class="peach-settings-sub-label">BREACH PROTECTION</p>1015          <div class="peach-settings-row">1016            <span>Breach monitoring</span>1017            <button1018              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1019              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1020              aria-label="Toggle breach monitoring"1021            >1022              <span class="peach-toggle-knob"></span>1023            </button>1024          </div>1025          <div class="peach-settings-row peach-settings-row--meta">1026            <span>Check all passwords</span>1027            <button1028              class="peach-btn peach-btn--subtle peach-btn--sm"1029              onClick={() => void handleCheckAllBreaches()}1030              disabled={checkingBreaches()}1031            >1032              {checkingBreaches() ? "checking..." : "run"}1033            </button>1034          </div>1035          <Show when={breachResult()}>1036            <div class="peach-settings-row peach-settings-row--meta">1037              <span>Last check</span>1038              <span class="peach-settings-value">{breachResult()}</span>1039            </div>1040          </Show>10411042          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1043          <div class="peach-settings-row peach-settings-row--meta">1044            <span>Master password</span>1045            <button1046              class="peach-btn peach-btn--subtle peach-btn--sm"1047              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1048            >1049              {showChangePw() ? "cancel" : "change"}1050            </button>1051          </div>1052          <Show when={showChangePw()}>1053            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1054              <input1055                class="peach-settings-input"1056                type="password"1057                placeholder="current password"1058                value={changePwCurrent()}1059                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1060                autocomplete="current-password"1061              />1062              <input1063                class="peach-settings-input"1064                type="password"1065                placeholder="new password (min 12 chars)"1066                value={changePwNew()}1067                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1068                autocomplete="new-password"1069              />1070              <input1071                class="peach-settings-input"1072                type="password"1073                placeholder="confirm new password"1074                value={changePwConfirm()}1075                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1076                autocomplete="new-password"1077                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1078              />1079              <Show when={changePwError()}>1080                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1081              </Show>1082              <Show when={changePwSuccess()}>1083                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1084              </Show>1085              <button1086                class="peach-btn peach-btn--accent peach-btn--sm"1087                style="align-self:flex-start"1088                onClick={() => void handleChangePassword()}1089                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1090              >1091                {changePwSaving() ? "saving…" : "save new password"}1092              </button>1093            </div>1094          </Show>10951096          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1097          <div class="peach-settings-row peach-settings-row--meta">1098            <span>Recovery words</span>1099            <button1100              class="peach-btn peach-btn--subtle peach-btn--sm"1101              onClick={() => {1102                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1103              }}1104            >1105              {showViewWords() ? "cancel" : "view"}1106            </button>1107          </div>1108          <Show when={showViewWords()}>1109            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1110              <Show1111                when={viewedWords()}1112                fallback={1113                  <>1114                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1115                      Enter your master password to reveal your recovery words.1116                    </p>1117                    <input1118                      class="peach-settings-input"1119                      type="password"1120                      placeholder="master password"1121                      value={viewWordsPassword()}1122                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1123                      autocomplete="current-password"1124                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1125                    />1126                    <Show when={viewWordsError()}>1127                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1128                    </Show>1129                    <button1130                      class="peach-btn peach-btn--accent peach-btn--sm"1131                      style="align-self:flex-start"1132                      onClick={() => void handleViewRecoveryWords()}1133                      disabled={viewWordsBusy() || !viewWordsPassword()}1134                    >1135                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1136                    </button>1137                  </>1138                }1139              >1140                <div class="peach-recovery-words-grid">1141                  <For each={viewedWords()!}>1142                    {(word, i) => (1143                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1144                    )}1145                  </For>1146                </div>1147                <button1148                  class="peach-btn peach-btn--subtle peach-btn--sm"1149                  style="align-self:flex-start"1150                  onClick={closeViewWords}1151                >done</button>1152              </Show>1153            </div>1154          </Show>11551156          <p class="peach-settings-sub-label">1157            DURESS PASSWORD1158            <Show when={!isPro()}>1159              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1160            </Show>1161          </p>1162          <div class="peach-settings-row peach-settings-row--top">1163            <div class="peach-settings-row-main">1164              <span>1165                Duress password1166                <Show when={!isPro()}>1167                  <span class="peach-pro-badge">Pro</span>1168                </Show>1169              </span>1170              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1171                If entered under coercion, shows a decoy empty vault instead of your real one.1172              </span>1173            </div>1174            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1175              {duressEnabled() ? "on" : "off"}1176            </div>1177          </div>11781179          <Show when={duressEnabled() && !showDuressSetup()}>1180            <div class="peach-settings-row peach-settings-row--meta">1181              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1182                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.1183              </span>1184            </div>1185          </Show>11861187          <Show when={duressSuccess()}>1188            <div class="peach-settings-row peach-settings-row--meta">1189              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1190            </div>1191          </Show>11921193          <Show when={!showDuressSetup()}>1194            <div class="peach-settings-row peach-settings-row--meta">1195              <button1196                class="peach-btn peach-btn--subtle peach-btn--sm"1197                onClick={() => {1198                  // Pro gate entry-point: free users are redirected to the1199                  // upgrade screen before the setup form opens. The server-1200                  // side SETUP_DURESS handler is the single enforcement1201                  // point; this is UI redirection so the form never appears1202                  // for a user who can't complete it.1203                  if (!isPro() && !duressEnabled()) {1204                    props.onProGate?.("duress_vault");1205                    setActiveCard("pro");1206                    return;1207                  }1208                  setShowDuressSetup(true);1209                  setDuressError(null);1210                  setDuressSuccess(null);1211                  setDuressPassword("");1212                  setDuressConfirm("");1213                  setDuressMainPassword("");1214                }}1215              >1216                {duressEnabled() ? "Change / disable" : "Set up"}1217              </button>1218            </div>1219          </Show>12201221          <Show when={showDuressSetup()}>1222            <div class="peach-duress-form">1223              <label class="peach-label">main password (to confirm)</label>1224              <input1225                class="peach-settings-input"1226                type="password"1227                placeholder="your current master password"1228                autocomplete="current-password"1229                value={duressMainPassword()}1230                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1231              />1232              <Show when={!duressEnabled()}>1233                <label class="peach-label">new duress password</label>1234                <input1235                  class="peach-settings-input"1236                  type="password"1237                  placeholder="duress password (min 8 chars)"1238                  autocomplete="new-password"1239                  value={duressPassword()}1240                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1241                />1242                <label class="peach-label">confirm duress password</label>1243                <input1244                  class="peach-settings-input"1245                  type="password"1246                  placeholder="confirm duress password"1247                  autocomplete="new-password"1248                  value={duressConfirm()}1249                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1250                />1251              </Show>12521253              <Show when={duressError()}>1254                <p class="peach-hint peach-hint-error">{duressError()}</p>1255              </Show>12561257              <div class="peach-duress-actions">1258                <button1259                  class="peach-btn peach-btn--subtle peach-btn--sm"1260                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1261                >1262                  cancel1263                </button>1264                <Show when={duressEnabled()}>1265                  <button1266                    class="peach-btn peach-btn--ghost peach-btn--sm"1267                    style="color:var(--peach-breach)"1268                    onClick={() => void handleDisableDuress()}1269                    disabled={duressSaving()}1270                  >1271                    {duressSaving() ? "disabling..." : "disable duress"}1272                  </button>1273                </Show>1274                <Show when={!duressEnabled()}>1275                  <button1276                    class="peach-btn peach-btn--accent peach-btn--sm"1277                    onClick={() => void handleSetupDuress()}1278                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1279                  >1280                    {duressSaving() ? "saving..." : "save"}1281                  </button>1282                </Show>1283              </div>1284            </div>1285          </Show>12861287          <p class="peach-settings-sub-label">1288            CANARY ENTRIES1289            <Show when={!isPro()}>1290              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1291            </Show>1292          </p>1293          <div class="peach-settings-row peach-settings-row--top">1294            <div class="peach-settings-row-main">1295              <span>1296                Canary entries1297                <Show when={!isPro()}>1298                  <span class="peach-pro-badge">Pro</span>1299                </Show>1300              </span>1301              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1302                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1303              </span>1304            </div>1305            <span class="peach-settings-value" style="flex-shrink:0">1306              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1307            </span>1308          </div>1309          <Show when={canaryEntries().length > 0}>1310            <For each={canaryEntries()}>1311              {(entry) => (1312                <div class="peach-settings-row peach-settings-row--meta">1313                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1314                  <button1315                    class="peach-btn peach-btn--subtle peach-btn--sm"1316                    onClick={() => void handleTestCanary(entry)}1317                    disabled={canaryTestingId() === entry.id}1318                    style="flex-shrink:0"1319                  >1320                    {canaryTestingId() === entry.id1321                      ? "sending…"1322                      : canaryTestResult()?.id === entry.id1323                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1324                        : "test"}1325                  </button>1326                </div>1327              )}1328            </For>1329          </Show>13301331          <p class="peach-settings-sub-label">PASSKEYS</p>1332          <div class="peach-settings-row peach-settings-row--top">1333            <div class="peach-settings-row-main">1334              <span>Intercept passkey requests</span>1335              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1336                Peach can handle passkey sign-in and creation on supported Chromium browsers.1337              </span>1338            </div>1339            <button1340              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1341              onClick={() => void handleTogglePasskeys()}1342              aria-label="Toggle passkey interception"1343              disabled={!passkeyState()?.supported || passkeySaving()}1344            >1345              <span class="peach-toggle-knob"></span>1346            </button>1347          </div>1348          <div class="peach-settings-row peach-settings-row--meta">1349            <span>Status</span>1350            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1351          </div>1352          <div class="peach-settings-row peach-settings-row--meta">1353            <span>Browser</span>1354            <span class="peach-settings-value">1355              {passkeyState()?.browserLabel ?? "Unknown"}1356              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1357            </span>1358          </div>1359          <div class="peach-info-notice" style="margin-top:8px">1360            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1361          </div>1362          <Show when={passkeyState()?.conflict}>1363            <div class="peach-settings-row peach-settings-row--meta">1364              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1365            </div>1366          </Show>1367        </div>1368      </div>1369    );1370  }13711372  function renderVault() {1373    return (1374      <div class="peach-settings-body">1375        <div class="peach-settings-group">1376          <p class="peach-settings-sub-label">ENTRIES</p>1377          <div class="peach-settings-row peach-settings-row--meta">1378            <span>Entries</span>1379            <span class="peach-settings-value">{props.entriesCount}</span>1380          </div>1381          <div class="peach-settings-row peach-settings-row--meta">1382            <span>Duplicates</span>1383            <Show when={dedupDone() !== null} fallback={1384              <button1385                class="peach-btn peach-btn--subtle peach-btn--sm"1386                onClick={() => void handleCountDuplicates()}1387                disabled={dedupRunning()}1388              >1389                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1390              </button>1391            }>1392              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1393                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1394              </span>1395            </Show>1396          </div>1397          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1398            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1399              <div class="peach-restore-warning">1400                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1401              </div>1402              <div style="display:flex;gap:6px">1403                <button1404                  class="peach-btn peach-btn--subtle peach-btn--sm"1405                  onClick={() => setDedupCount(null)}1406                >1407                  cancel1408                </button>1409                <button1410                  class="peach-btn peach-btn--accent peach-btn--sm"1411                  onClick={() => void handleDeduplicate()}1412                  disabled={dedupRunning()}1413                >1414                  {dedupRunning() ? "removing..." : "remove duplicates"}1415                </button>1416              </div>1417            </div>1418          </Show>14191420          <p class="peach-settings-sub-label">MAINTENANCE</p>1421          <div class="peach-settings-row peach-settings-row--meta">1422            <span>Trash auto-delete</span>1423            <select1424              class="peach-settings-select"1425              value={String(props.trashRetentionDays)}1426              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1427            >1428              <option value="7">7 days</option>1429              <option value="30">30 days</option>1430              <option value="90">90 days</option>1431              <option value="0">Never</option>1432            </select>1433          </div>1434          <div class="peach-settings-row peach-settings-row--meta">1435            <span>Advanced Vault Health</span>1436            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1437              open1438            </button>1439          </div>1440          <Show when={declinedCardCount() > 0}>1441            <div class="peach-settings-row peach-settings-row--meta">1442              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1443              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1444                Clear1445              </button>1446            </div>1447          </Show>14481449          <p class="peach-settings-sub-label">DATA</p>1450          <div class="peach-settings-row peach-settings-row--meta">1451            <span>Import passwords</span>1452            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1453              open1454            </button>1455          </div>1456          <div class="peach-settings-row peach-settings-row--meta">1457            <span>Export backup</span>1458            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1459              open1460            </button>1461          </div>1462          <div class="peach-settings-row peach-settings-row--meta">1463            <span>1464              Peach Codex1465              <Show when={!isPro()}>1466                <span class="peach-pro-badge">Pro</span>1467              </Show>1468            </span>1469            <button1470              class="peach-btn peach-btn--subtle peach-btn--sm"1471              onClick={() => props.onOpenCodexGenerate?.()}1472            >1473              open1474            </button>1475          </div>1476          {/* Restore from Codex: intentionally never gated — a printed1477              Codex must remain restorable regardless of license state. */}1478          <div class="peach-settings-row peach-settings-row--meta">1479            <span>Restore from Codex</span>1480            <button1481              class="peach-btn peach-btn--subtle peach-btn--sm"1482              onClick={() => props.onOpenCodexRestore?.()}1483            >1484              open1485            </button>1486          </div>14871488          <Show when={hasReleaseNotes()}>1489            <p class="peach-settings-sub-label">RELEASE NOTES</p>1490            <div class="peach-settings-row peach-settings-row--meta">1491              <span>v{PEACH_VERSION}</span>1492              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1493                view1494              </button>1495            </div>1496          </Show>14971498          <span class="peach-settings-sub-label">VAULT HISTORY</span>1499          <Show when={historyLoading()}>1500            <p class="peach-settings-hint">Loading…</p>1501          </Show>1502          <Show when={!historyLoading() && historySnapshots().length === 0}>1503            <p class="peach-settings-hint">1504              No snapshots yet. Peach saves automatically before every change —1505              saves, syncs, and imports.1506            </p>1507          </Show>1508          <Show when={!historyLoading() && historySnapshots().length > 0}>1509            <p class="peach-settings-hint">1510              Restoring locks your vault. Unlock with the password that1511              encrypted that snapshot to confirm.1512            </p>1513            <div class="peach-history-list">1514              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1515                {(snap) => (1516                  <div class="peach-history-row">1517                    <div class="peach-history-meta">1518                      <span class="peach-history-time">1519                        {formatSnapshotTime(snap.savedAt)}1520                      </span>1521                      <span class="peach-history-sub">1522                        {[1523                          snap.entryCount !== null1524                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1525                            : null,1526                          reasonLabel(snap.reason),1527                        ]1528                          .filter(Boolean)1529                          .join(" · ")}1530                      </span>1531                    </div>1532                    <button1533                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1534                        historyRestoring() === snap.id ? " is-busy" : ""1535                      }`}1536                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1537                      disabled={historyRestoring() !== null}1538                    >1539                      <Show when={historyRestoring() === snap.id} fallback="Restore">1540                        …1541                      </Show>1542                    </button>1543                  </div>1544                )}1545              </For>1546            </div>1547            <Show when={historySnapshots().length > 3}>1548              <button1549                class="peach-settings-advanced-toggle"1550                onClick={() => setHistoryShowAll((v) => !v)}1551                aria-expanded={historyShowAll()}1552              >1553                <span class="peach-settings-hint" style="margin:0">1554                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1555                </span>1556                <span class="peach-settings-advanced-chevron">1557                  {historyShowAll() ? "▲" : "▼"}1558                </span>1559              </button>1560            </Show>1561            <p class="peach-history-footer">1562              {historySnapshots().length} of 10 snapshots1563            </p>1564          </Show>15651566          <button1567            class="peach-settings-advanced-toggle"1568            onClick={() => setShowVaultAdvanced((v) => !v)}1569            aria-expanded={showVaultAdvanced()}1570          >1571            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1572            <span class="peach-settings-advanced-chevron">1573              {showVaultAdvanced() ? "▲" : "▼"}1574            </span>1575          </button>15761577          <Show when={showVaultAdvanced()}>1578            <div class="peach-danger-card">1579              <div class="peach-danger-card-head">1580                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1581                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1582              </div>1583              <p class="peach-danger-card-copy">1584                Wipes the current vault and restores from a backup file.1585                Recoverable only if your backup is good.1586              </p>1587              <button1588                class="peach-danger-card-button"1589                onClick={() => props.onOpenFileImport?.()}1590              >1591                Choose backup file1592              </button>1593            </div>15941595            <Show when={props.onOpenPanic}>1596              <div class="peach-danger-card peach-danger-card--hazard">1597                <div class="peach-danger-card-head">1598                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1599                  <h3 class="peach-danger-card-title">Destroy vault</h3>1600                </div>1601                <p class="peach-danger-card-copy">1602                  Permanently wipes every entry, setting, and license on this1603                  device. There is no recovery.1604                </p>1605                <button1606                  class="peach-danger-card-button"1607                  onClick={() => props.onOpenPanic?.()}1608                >1609                  Begin destruction1610                </button>1611              </div>1612            </Show>1613          </Show>1614        </div>1615      </div>1616    );1617  }16181619  function renderSync() {1620    return (1621      <div class="peach-settings-body">1622        <div class="peach-settings-group">1623          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1624          <div1625            class="peach-settings-row peach-settings-row--meta"1626            style="align-items:center;gap:8px"1627            role="status"1628            aria-label={syncLightLabel()}1629            title={syncLightLabel()}1630          >1631            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1632            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1633              {syncFlash() ?? lastSyncLine()}1634            </span>1635          </div>1636          <Show when={pairedDevices().length > 0}>1637            <div class="peach-settings-row peach-settings-row--meta">1638              <span class="peach-settings-value" style="color:var(--peach-t2)">1639                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01640                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1641                  : ""}1642              </span>1643              <button1644                class="peach-btn peach-btn--subtle peach-btn--sm"1645                aria-label="Sync this vault now"1646                disabled={syncStatus() === "syncing"}1647                onClick={() => void syncNow()}1648              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1649            </div>1650          </Show>16511652          <p class="peach-settings-sub-label">ADD A DEVICE</p>1653          <button1654            class="peach-sync-pair-option"1655            onClick={() => props.onOpenSyncPairing?.("code")}1656          >1657            <span class="peach-sync-pair-option-icon" aria-hidden="true">1658              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1659                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1660                <path d="M9 15 L11 15" />1661              </svg>1662            </span>1663            <span class="peach-sync-pair-option-main">1664              <span class="peach-sync-pair-option-title">Show a code</span>1665              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1666            </span>1667            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1668          </button>1669          <Show when={!isPro()}>1670            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1671              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1672            </p>1673          </Show>16741675          <Show when={pairedDevices().length > 0}>1676            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1677            <For each={pairedDevices()}>1678              {(d) => (1679                <div class="peach-settings-row peach-settings-row--top">1680                  <div class="peach-settings-row-main">1681                    <Show1682                      when={renameDeviceId() === d.deviceId}1683                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1684                    >1685                      <input1686                        class="peach-settings-input"1687                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1688                        maxlength={64}1689                        value={renameDraft()}1690                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1691                        onKeyDown={(event) => {1692                          if (event.key === "Enter") void renameDevice(d.deviceId);1693                          if (event.key === "Escape") setRenameDeviceId(null);1694                        }}1695                      />1696                    </Show>1697                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1698                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1699                    </span>1700                  </div>1701                  <Show1702                    when={confirmUnpair() === d.deviceId}1703                    fallback={1704                      <div style="display:flex;gap:4px;flex-shrink:0">1705                        <Show1706                          when={renameDeviceId() === d.deviceId}1707                          fallback={1708                            <button1709                              class="peach-btn peach-btn--subtle peach-btn--sm"1710                              onClick={() => {1711                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1712                                setRenameDeviceId(d.deviceId);1713                              }}1714                            >rename</button>1715                          }1716                        >1717                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1718                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1719                        </Show>1720                        <button1721                          class="peach-btn peach-btn--subtle peach-btn--sm"1722                          onClick={() => setConfirmUnpair(d.deviceId)}1723                        >remove</button>1724                      </div>1725                    }1726                  >1727                    {/* HOLE 4: revocation stops this device receiving future1728                        changes. It does NOT secure the vault against it — that1729                        device may already hold the recovery words, which are1730                        master-password-equivalent and restore the whole vault1731                        anywhere, forever, with no sync involved. Say so here,1732                        rather than leaving the user falsely reassured. */}1733                    <div class="peach-unpair-confirm">1734                      <p class="peach-unpair-confirm-note">1735                        Stops this device receiving future changes. It doesn't1736                        lock it out of what it already has — if it's lost or1737                        stolen, also change your master password and generate1738                        new recovery words.1739                      </p>1740                      <div style="display:flex;gap:6px;justify-content:flex-end">1741                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1742                        <button1743                          class="peach-btn peach-btn--sm"1744                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1745                          onClick={() => void unpairDevice(d.deviceId)}1746                        >remove</button>1747                      </div>1748                    </div>1749                  </Show>1750                </div>1751              )}1752            </For>1753          </Show>17541755          <Show when={pendingVouched().length > 0}>1756            <p class="peach-settings-sub-label">1757              PENDING DEVICES1758              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1759            </p>1760            <For each={pendingVouched()}>1761              {(dev) => (1762                <div class="peach-settings-row peach-settings-row--top">1763                  <div class="peach-settings-row-main">1764                    <span>{dev.deviceName}</span>1765                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1766                      Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1767                      requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1768                      vouched by device {dev.vouchedByDeviceId}. Allowing it can read1769                      existing vault contents and all future changes.1770                    </span>1771                  </div>1772                  <div style="display:flex;gap:6px;flex-shrink:0">1773                    <button1774                      class="peach-btn peach-btn--sm"1775                      disabled={vouchedBusy() === dev.deviceId}1776                      onClick={() => void confirmVouched(dev.deviceId)}1777                    >Allow</button>1778                    <button1779                      class="peach-btn peach-btn--subtle peach-btn--sm"1780                      disabled={vouchedBusy() === dev.deviceId}1781                      onClick={() => void rejectVouched(dev.deviceId)}1782                    >Reject</button>1783                  </div>1784                </div>1785              )}1786            </For>1787          </Show>17881789          <button1790            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1791            onClick={() => setShowSyncConnection((value) => !value)}1792            aria-expanded={showSyncConnection()}1793            aria-controls="peach-sync-connection"1794          >1795            <span>1796              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1797              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1798            </span>1799            <span class="peach-settings-advanced-chevron">1800              {showSyncConnection() ? "▲" : "▼"}1801            </span>1802          </button>1803          <Show when={showSyncConnection()}>1804            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1805              <SyncModeSelector />1806            </div>1807          </Show>18081809          <button1810            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1811            onClick={() => setShowSyncRecovery((value) => !value)}1812            aria-expanded={showSyncRecovery()}1813            aria-controls="peach-sync-recovery"1814          >1815            <span>1816              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1817              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1818            </span>1819            <span class="peach-settings-advanced-chevron">1820              {showSyncRecovery() ? "▲" : "▼"}1821            </span>1822          </button>1823          <Show when={showSyncRecovery()}>1824            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1825              <Show1826                when={rotateConfirmOpen()}1827                fallback={1828                  <div class="peach-settings-row peach-settings-row--top">1829                    <div class="peach-settings-row-main">1830                      <span>Rotate sync identity</span>1831                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1832                        Disconnects every device and replaces the keys used for sync.1833                      </span>1834                    </div>1835                    <button1836                      class="peach-btn peach-btn--subtle peach-btn--sm"1837                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1838                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1839                    >rotate</button>1840                  </div>1841                }1842              >1843                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1844                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1845                  <Show when={rotateError()}>1846                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1847                  </Show>1848                  <div style="display:flex;gap:6px;margin-top:10px">1849                    <button1850                      class="peach-btn peach-btn--subtle peach-btn--sm"1851                      disabled={rotating()}1852                      onClick={() => setRotateConfirmOpen(false)}1853                    >Cancel</button>1854                    <button1855                      class="peach-btn peach-btn--sm"1856                      style="background:var(--peach-breach);border-color:var(--peach-breach)"1857                      disabled={rotating()}1858                      onClick={() => void doRotateSyncIdentity()}1859                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1860                  </div>1861                </div>1862              </Show>1863            </div>1864          </Show>1865        </div>1866      </div>1867    );1868  }18691870  function renderAppearance() {1871    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1872      { id: "medium", label: "Default" },1873      { id: "large", label: "Large" },1874    ];1875    return (1876      <div class="peach-settings-body">1877        <div class="peach-settings-group">1878          <p class="peach-settings-sub-label">THEME</p>1879          <div class="peach-settings-row peach-settings-row--meta">1880            <span>Theme</span>1881            <button1882              class="peach-btn peach-btn--subtle peach-btn--sm"1883              onClick={() =>1884                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1885              }1886            >1887              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1888            </button>1889          </div>18901891          <p class="peach-settings-sub-label">ACCENT</p>1892          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1893            <span>Accent</span>1894            <div class="peach-accent-picker">1895              <For each={ACCENT_COLORS}>1896                {(color) => (1897                  <button1898                    class="peach-accent-swatch peach-swatch"1899                    classList={{ "is-active": props.accentColor === color.name }}1900                    style={{ background: color.hex }}1901                    onClick={() => void props.onAccentChange(color.name)}1902                    aria-label={color.name}1903                    title={color.name}1904                  />1905                )}1906              </For>1907            </div>1908          </div>19091910          <p class="peach-settings-sub-label">TEXT SIZE</p>1911          <div class="peach-settings-row peach-settings-row--meta">1912            <span>Size</span>1913            <div class="peach-pill-group">1914              <For each={sizeOptions}>1915                {(opt) => (1916                  <button1917                    class="peach-btn peach-btn--ghost peach-btn--sm"1918                    classList={{ "is-active": props.textSize === opt.id }}1919                    onClick={() => void props.onTextSizeChange(opt.id)}1920                  >1921                    {opt.label}1922                  </button>1923                )}1924              </For>1925            </div>1926          </div>19271928          <p class="peach-settings-sub-label">MOTION</p>1929          <div class="peach-settings-row peach-settings-row--meta">1930            <span>Reduce motion</span>1931            <button1932              class="peach-btn peach-btn--subtle peach-btn--sm"1933              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1934            >1935              {props.reduceMotion ? "On" : "Off"}1936            </button>1937          </div>1938        </div>1939      </div>1940    );1941  }19421943  function renderShortcuts() {1944    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1945    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);19461947    function startCapture(action: ShortcutAction) {1948      setConflictMsg(null);1949      setCapturingAction(action);1950    }1951    function cancelCapture() {1952      setCapturingAction(null);1953      setConflictMsg(null);1954    }1955    function commitBinding(action: ShortcutAction, binding: Keybinding) {1956      const conflict = findConflict(props.shortcuts, binding, action);1957      if (conflict) {1958        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1959        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1960        return;1961      }1962      const next = { ...props.shortcuts, [action]: binding };1963      setCapturingAction(null);1964      setConflictMsg(null);1965      void props.onShortcutsChange(next);1966    }1967    function resetAction(action: ShortcutAction) {1968      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1969      void props.onShortcutsChange(next);1970    }1971    function resetAll() {1972      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1973    }19741975    // Capture-mode key handler — wired via ref onto the chip element so it1976    // catches the next non-modifier keypress and ignores other bindings.1977    function handleCaptureKey(e: KeyboardEvent) {1978      e.preventDefault();1979      e.stopPropagation();1980      if (e.key === "Escape") { cancelCapture(); return; }1981      const binding = keybindingFromEvent(e);1982      if (!binding) return; // bare modifier — keep waiting1983      const action = capturingAction();1984      if (action) commitBinding(action, binding);1985    }19861987    return (1988      <div class="peach-settings-body">1989        <div class="peach-settings-group peach-shortcuts-group">1990          <p class="peach-settings-sub-label">ENTRY VIEW</p>1991          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1992            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1993          </For>1994        </div>19951996        <div class="peach-settings-group peach-shortcuts-group">1997          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1998          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1999            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2000          </For>2001        </div>20022003        <Show when={conflictMsg()}>2004          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2005        </Show>20062007        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2008          <div class="peach-settings-row peach-settings-row--meta">2009            <span>Inline autofill on page</span>2010            <a2011              class="peach-settings-link"2012              href="chrome://extensions/shortcuts"2013              onClick={(e) => {2014                e.preventDefault();2015                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2016              }}2017            >2018              ⌘⇧P — customize ↗2019            </a>2020          </div>2021          <button2022            class="peach-btn peach-btn--subtle peach-btn--sm"2023            style="align-self:flex-start"2024            onClick={resetAll}2025          >2026            Reset all to defaults2027          </button>2028        </div>2029      </div>2030    );20312032    function renderRow(2033      desc: typeof SHORTCUT_DESCRIPTORS[number],2034      capturing: () => ShortcutAction | null,2035      onStart: (a: ShortcutAction) => void,2036      onCancel: () => void,2037      onReset: (a: ShortcutAction) => void,2038      onKey: (e: KeyboardEvent) => void,2039    ) {2040      const isActive = () => capturing() === desc.action;2041      const binding = () => props.shortcuts[desc.action];2042      const isCustom = () => {2043        const b = binding();2044        const d = DEFAULT_BINDINGS[desc.action];2045        return b.key !== d.key2046          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2047          || Boolean(b.meta)  !== Boolean(d.meta)2048          || Boolean(b.shift) !== Boolean(d.shift)2049          || Boolean(b.alt)   !== Boolean(d.alt);2050      };2051      return (2052        <div class="peach-shortcut-row">2053          <span class="peach-shortcut-row-label">{desc.label}</span>2054          <button2055            class="peach-shortcut-chip"2056            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2057            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2058            onKeyDown={(e) => { if (isActive()) onKey(e); }}2059            ref={(el) => {2060              // Auto-focus when entering capture mode so the keydown listener catches the next press.2061              if (el) {2062                queueMicrotask(() => { if (isActive()) el.focus(); });2063              }2064            }}2065          >2066            {isActive() ? "Press a key…" : formatKeybinding(binding())}2067          </button>2068          <Show when={isCustom() && !isActive()}>2069            <button2070              class="peach-shortcut-reset"2071              title="Reset to default"2072              onClick={() => onReset(desc.action)}2073            >2074              ↺2075            </button>2076          </Show>2077        </div>2078      );2079    }2080  }20812082  const PRO_FEATURES: Array<{2083    feature: ProFeature;2084    name: string;2085    desc: string;2086  }> = [2087    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2088    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2089    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2090    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2091  ];20922093  const PRO_SECURITY_FEATURES: Array<{2094    feature: ProFeature;2095    name: string;2096    desc: string;2097  }> = [2098    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2099    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2100  ];21012102  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2103  const [showKey, setShowKey] = createSignal(false);21042105  const [deactivating, setDeactivating] = createSignal(false);21062107  async function handleDeactivateLicense() {2108    setDeactivating(true);2109    await sendMessage({ type: "DEACTIVATE_LICENSE" });2110    setLicenseInfo(null);2111    await props.onLicenseChange?.();2112    setDeactivating(false);2113  }21142115  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2116    setCanaryTestingId(entry.id);2117    setCanaryTestResult(null);2118    const res = await sendMessage({2119      type: "FIRE_CANARY_PING",2120      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2121    }) as { ok: boolean };2122    setCanaryTestResult({ id: entry.id, ok: res.ok });2123    setCanaryTestingId(null);2124    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2125  }21262127  function renderLicense() {2128    async function onActivate() {2129      await handleActivateLicense();2130      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2131        await props.onLicenseChange?.();2132      }2133    }21342135    return (2136      <div class="peach-settings-body">2137        <Show2138          when={isPro()}2139          fallback={2140                <div class="peach-pro-upgrade">2141                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>21422143                  <h2 class="peach-pro-headline">2144                    Every<em>thing,</em><br />every<em>where.</em>2145                  </h2>21462147                  <p class="peach-pro-subtitle">2148                    One key unlocks every Pro feature — for the lifetime of the app.2149                  </p>21502151                  <div class="peach-pro-grid">2152                    <For each={PRO_FEATURES}>2153                      {(item, i) => (2154                        <div2155                          class="peach-pro-grid-item"2156                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2157                        >2158                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2159                          <span class="peach-pro-grid-name">{item.name}</span>2160                          <span class="peach-pro-grid-desc">{item.desc}</span>2161                        </div>2162                      )}2163                    </For>2164                  </div>21652166                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2167                  <div class="peach-pro-grid">2168                    <For each={PRO_SECURITY_FEATURES}>2169                      {(item, i) => (2170                        <div2171                          class="peach-pro-grid-item"2172                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2173                        >2174                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2175                          <span class="peach-pro-grid-name">{item.name}</span>2176                          <span class="peach-pro-grid-desc">{item.desc}</span>2177                        </div>2178                      )}2179                    </For>2180                  </div>21812182                  <div class="peach-pro-upgrade-actions">2183                    <p class="peach-pro-pricing">2184                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2185                    </p>2186                    <a2187                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2188                      target="_blank"2189                      rel="noopener noreferrer"2190                      class="peach-pro-upgrade-cta"2191                    >2192                      Get Peach Pro2193                    </a>2194                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2195                    <div class="peach-pro-key-inline">2196                      <input2197                        class="peach-pro-key-inline-input"2198                        type="text"2199                        placeholder="PEACH-XXXX-XXXX-XXXX"2200                        value={licenseKeyInput()}2201                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2202                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2203                        autocomplete="off"2204                        spellcheck={false}2205                      />2206                      <button2207                        class="peach-pro-key-apply"2208                        onClick={() => void onActivate()}2209                        disabled={activatingLicense() || !licenseKeyInput().trim()}2210                      >2211                        {activatingLicense() ? "…" : "APPLY"}2212                      </button>2213                    </div>2214                    <Show when={licenseError()}>2215                      <p class="peach-pro-key-error">{licenseError()}</p>2216                    </Show>2217                  </div>2218                </div>2219          }2220        >2221          <div class="peach-pro-active">2222            <div class="peach-pro-active-hero">2223              <button2224                class="peach-pro-active-mark-wrap"2225                type="button"2226                aria-label="Celebrate"2227                onClick={handleProCelebrationClick}2228              >2229                <PeachMark class="peach-pro-active-mark" />2230                <Show when={justActivated()}>2231                  <div class="peach-pro-sparks" aria-hidden="true">2232                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2233                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2234                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2235                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2236                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2237                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2238                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2239                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2240                  </div>2241                </Show>2242              </button>2243              <h2 class="peach-pro-active-title">Peach Pro</h2>2244              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2245              <Show when={justActivated()}>2246                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2247              </Show>2248            </div>22492250            <Show when={licenseKey()}>2251              <div class="peach-settings-license">2252                <span class="peach-settings-label">LICENSE KEY</span>2253                <div class="peach-settings-license-row">2254                  <Show2255                    when={showKey()}2256                    fallback={2257                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2258                        ••••••••••••••••••••••••••••2259                      </span>2260                    }2261                  >2262                    <span class="peach-settings-license-key">{licenseKey()!}</span>2263                  </Show>2264                  <div class="peach-settings-license-actions">2265                    <button2266                      class="peach-settings-license-copy"2267                      title="Copy license key"2268                      onClick={() => copyLicenseKey(licenseKey()!)}2269                    >2270                      {copied() ? "✓" : "copy"}2271                    </button>2272                    <button2273                      class="peach-settings-license-toggle"2274                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2275                      title={showKey() ? "Hide" : "Reveal"}2276                      onClick={() => setShowKey(v => !v)}2277                    >2278                      <Show2279                        when={showKey()}2280                        fallback={2281                          <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">2282                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2283                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2284                          </svg>2285                        }2286                      >2287                        <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">2288                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2289                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2290                          <line x1="2" y1="2" x2="12" y2="12" />2291                        </svg>2292                      </Show>2293                    </button>2294                  </div>2295                </div>2296              </div>2297            </Show>22982299            <div class="peach-pro-active-features">2300              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2301                {(item) => (2302                  <div class="peach-pro-active-feature">2303                    <span class="peach-pro-active-feature-mark">✦</span>2304                    <span class="peach-pro-active-feature-name">{item.name}</span>2305                  </div>2306                )}2307              </For>2308            </div>23092310            <button2311              class="peach-pro-deactivate"2312              onClick={() => void handleDeactivateLicense()}2313              disabled={deactivating()}2314            >2315              {deactivating() ? "Deactivating…" : "Deactivate"}2316            </button>2317          </div>2318        </Show>2319      </div>2320    );2321  }23222323  function renderFeedback() {2324    return (2325      <div class="peach-settings-body">2326        <div class="peach-settings-group peach-feedback-group">2327          <Show2328            when={feedbackState() === 'sent'}2329            fallback={2330              <>2331                <p class="peach-feedback-intro">2332                  Help us improve Peach. Every report goes directly to our inbox.2333                </p>23342335                <p class="peach-settings-sub-label">TYPE</p>2336                <div class="peach-feedback-chips">2337                  <For each={FEEDBACK_CATEGORIES}>2338                    {(cat) => {2339                      const selected = () => fbCategory() === cat;2340                      return (2341                        <button2342                          type="button"2343                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2344                          aria-pressed={selected()}2345                          onClick={() => setFbCategory(cat)}2346                        >2347                          {cat}2348                        </button>2349                      );2350                    }}2351                  </For>2352                </div>23532354                <p class="peach-settings-sub-label">MESSAGE</p>2355                <textarea2356                  class="peach-feedback-textarea"2357                  placeholder="Describe the issue or idea..."2358                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2359                  value={fbMessage()}2360                  onInput={(e) => setFbMessage(e.currentTarget.value)}2361                />23622363                <p class="peach-feedback-meta">2364                  Version {version} · will be included automatically2365                </p>23662367                <button2368                  type="button"2369                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2370                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2371                  onClick={() => void handleSubmitFeedback()}2372                >2373                  <Switch>2374                    <Match when={feedbackState() === 'idle'}>Send</Match>2375                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2376                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2377                  </Switch>2378                </button>23792380                <p class="peach-feedback-anon">2381                  Sent anonymously · no account required2382                </p>2383              </>2384            }2385          >2386            <div class="peach-feedback-thanks" role="status" aria-live="polite">2387              <p class="peach-feedback-thanks-title">Thank you</p>2388              <p>2389                Your note is in the queue. Someone will process it soon.2390              </p>2391            </div>2392          </Show>2393        </div>2394      </div>2395    );2396  }23972398  const bodies: StackBodies = {2399    protection: renderSecurity(),2400    vault: renderVault(),2401    sync: renderSync(),2402    appearance: renderAppearance(),2403    shortcuts: renderShortcuts(),2404    pro: renderLicense(),2405    feedback: renderFeedback(),2406  };24072408  return (2409    <Show2410      when={showWhatsNew()}2411      fallback={2412    <Show2413      when={showNewRecoveryWords()}2414      fallback={2415        <div2416          class="peach-overlay-view peach-settings-overlay"2417          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2418          style="display:flex;flex-direction:column"2419        >2420          <StackDeck2421            active={activeCard()}2422            onActiveChange={setActiveCard}2423            onBack={props.onBack}2424            cards={cards()}2425            bodies={bodies}2426            version={version}2427            isFanning={isFanning()}2428            celebratingCardId={justActivated() ? "pro" : null}2429            isPro={isPro()}2430          />2431        </div>2432      }2433    >2434      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2435    </Show>2436      }2437    >2438      <div class="peach-overlay-view peach-settings-overlay">2439        <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2440      </div>2441    </Show>2442  );2443}