Settings.tsx

102.2 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 type { Canary } from "../../shared/types";9import { ACCENT_COLORS } from "../../shared/types";10import type {11  AccentColorName,12  PasskeyProviderState,13  ProFeature,14  VaultSnapshot,15} from "../../shared/types";16import type { PairedDevice } from "../../shared/sync-types";17import {18  describeSyncOutcome,19  syncIndicator,20  syncIndicatorLabel,21  type SyncOutcomeSummary,22} from "../../shared/sync-outcome";23import {24  DEFAULT_BINDINGS,25  findConflict,26  formatKeybinding,27  keybindingFromEvent,28  SHORTCUT_DESCRIPTORS,29  type Keybinding,30  type ShortcutAction,31} from "../../shared/shortcuts";32import { isNativePlatform, nativePlugin } from "../../shared/native";33import { WEB3FORMS_KEY } from "../../shared/config";3435interface LicenseInfo {36  valid: boolean;37  plan: "free" | "pro";38  issued?: string;39}4041interface SettingsProps {42  entriesCount: number;43  breachMonitoring: boolean;44  lockOnClose: boolean;45  autoLockMinutes: number;46  trashRetentionDays: number;47  theme: "dark" | "light";48  accentColor: AccentColorName;49  textSize: "small" | "medium" | "large";50  reduceMotion: boolean;51  isPro?: boolean;52  initialSection?: string | null;53  upgradeHighlight?: ProFeature | null;54  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;55  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;56  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;57  onTrashRetentionChange: (days: number) => void | Promise<void>;58  onThemeChange: (theme: "dark" | "light") => void;59  onAccentChange: (name: AccentColorName) => void | Promise<void>;60  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;61  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;62  shortcuts: Record<ShortcutAction, Keybinding>;63  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;64  onLicenseChange?: () => void | Promise<void>;65  isExiting?: boolean;66  onBack: () => void;67  onImport: () => void;68  onExport: () => void;69  onBreachCheckDone: () => void;70  onOpenHealth?: () => void;71  onOpenSyncPairing?: (method?: "code") => void;72  onOpenFileImport?: () => void;73  onOpenPanic?: () => void;74  onOpenCodexGenerate?: () => void;75  onOpenCodexRestore?: () => void;76  /** Invoked from within Settings when a Pro-gated control is tapped on77   *  free tier. App owns the upgrade navigation + highlight state, so this78   *  callback is the single way for Settings to request that navigation. */79  onProGate?: (feature: ProFeature) => void;80  onCardChange?: (id: CardId) => void;81}8283const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];84const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";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}101102type DataCollectionPermissionApi = {103  permissions?: {104    getAll?: () => Promise<{ data_collection?: string[] }>;105    request?: (permissions: { data_collection: string[] }) => Promise<boolean>;106  };107};108109async function requestFeedbackDataPermission(): Promise<boolean> {110  const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??111    (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;112  if (!api?.permissions?.getAll) return true;113114  try {115    const permissions = await api.permissions.getAll();116    if (!("data_collection" in permissions)) return true;117    if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;118    return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));119  } catch {120    return false;121  }122}123124function formatActivatedSince(issued: string | undefined): string {125  if (!issued) return "Active";126  try {127    const d = new Date(issued);128    if (Number.isNaN(d.getTime())) return "Active";129    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;130  } catch {131    return "Active";132  }133}134135function initialSectionToCard(value: string | null | undefined): CardId | null {136  if (!value) return null;137  if (value === "security") return "protection";138  if (value === "license") return "pro";139  if (140    value === "protection" ||141    value === "vault" ||142    value === "sync" ||143    value === "appearance" ||144    value === "shortcuts" ||145    value === "pro" ||146    value === "feedback"147  ) {148    return value;149  }150  return null;151}152153export function SettingsView(props: SettingsProps) {154  const manifest = chrome.runtime.getManifest();155  const version = manifest.version;156  const initial = props.initialSection;157  const [activeCard, setActiveCard] = createSignal<CardId>(158    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"159  );160161  createEffect(() => { props.onCardChange?.(activeCard()); });162163  const [justActivated, setJustActivated] = createSignal(false);164  const [isFanning, setIsFanning] = createSignal(false);165166  function triggerCelebration() {167    if (isFanning()) return;168    setJustActivated(false);169    setIsFanning(true);170    setActiveCard("pro");171    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];172    fanOrder.forEach((id, i) => {173      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);174    });175    window.setTimeout(() => {176      setActiveCard("pro");177      setIsFanning(false);178      setJustActivated(true);179      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);180    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);181  }182183  function handleProCelebrationClick(e: MouseEvent) {184    e.stopPropagation();185    triggerCelebration();186  }187188  const [checkingBreaches, setCheckingBreaches] = createSignal(false);189  const [breachResult, setBreachResult] = createSignal<string | null>(null);190  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);191  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");192  const [licenseError, setLicenseError] = createSignal<string | null>(null);193  const [, setLicenseSuccess] = createSignal<string | null>(null);194  const [activatingLicense, setActivatingLicense] = createSignal(false);195196  const isPro = () => props.isPro ?? false;197  const cards = createMemo<StackCardSpec[]>(() => [198    {199      id: "pro",200      num: "I · FOREVER",201      title: "Peach Pro",202      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",203      bgVar: "--stack-pro-bg",204      fgVar: "--stack-pro-fg",205      icon: "spark",206      onIconClick: isPro() ? triggerCelebration : undefined,207    },208    {209      id: "protection",210      num: "II · PROTECT",211      title: "Protection",212      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,213      bgVar: "--stack-protect-bg",214      fgVar: "--stack-protect-fg",215      icon: "shield",216    },217    {218      id: "vault",219      num: "III · KEEP",220      title: "Vault",221      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,222      bgVar: "--stack-vault-bg",223      fgVar: "--stack-vault-fg",224      icon: "lock",225    },226    {227      id: "sync",228      num: "IV · ECHO",229      title: "Sync",230      lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",231      bgVar: "--stack-sync-bg",232      fgVar: "--stack-sync-fg",233      icon: "sync",234    },235    {236      id: "appearance",237      num: "V · DRESS",238      title: "Appearance",239      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,240      bgVar: "--stack-appear-bg",241      fgVar: "--stack-appear-fg",242      icon: "sun",243    },244    {245      id: "shortcuts",246      num: "VI · KEYS",247      title: "Shortcuts",248      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,249      bgVar: "--stack-shortcut-bg",250      fgVar: "--stack-shortcut-fg",251      icon: "keys",252    },253    {254      id: "feedback",255      num: "VII · ECHO",256      title: "Feedback",257      lede: `version ${version} · tell us what would make it better`,258      bgVar: "--stack-feedback-bg",259      fgVar: "--stack-feedback-fg",260      icon: "bubble",261    },262  ]);263  const [biometricSupported, setBiometricSupported] = createSignal(false);264  const [biometricStored, setBiometricStored] = createSignal(false);265  const [biometricBusy, setBiometricBusy] = createSignal(false);266  const [biometricError, setBiometricError] = createSignal<string | null>(null);267  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);268  const [passkeySaving, setPasskeySaving] = createSignal(false);269270  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting271  // explicit confirmation before they become sync targets.272  interface PendingVouched { deviceId: string; deviceName: string }273  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);274  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);275  // Paired-device management lives here — Settings is the sync hub.276  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);277  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);278  const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);279  const [renameDraft, setRenameDraft] = createSignal("");280  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");281  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);282  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —283  // a transient toast is no use for diagnosing a phone that isn't syncing.284  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});285  const syncLight = () =>286    syncStatus() === "syncing"287      ? "busy"288      : syncIndicator(syncSummary(), pairedDevices().length);289  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());290  const lastSyncLine = () => {291    const s = syncSummary();292    if (!s.outcome) return "No sync attempted yet";293    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;294  };295  async function loadSyncState() {296    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {297      ok: boolean;298      data?: SyncOutcomeSummary;299    };300    if (r.ok && r.data) {301      setSyncSummary(r.data);302      setSyncStatus(r.data.status ?? "idle");303    }304  }305306  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;307  function syncRelativeTime(when: number | undefined): string {308    if (!when) return "Not synced yet";309    const elapsed = Date.now() - when;310    if (elapsed < 60_000) return "Just now";311    const minutes = Math.floor(elapsed / 60_000);312    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;313    const hours = Math.floor(minutes / 60);314    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;315    const days = Math.floor(hours / 24);316    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";317  }318  function isSyncStale(d: PairedDevice): boolean {319    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;320  }321  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {322    switch (t) {323      case "android": return "Android";324      case "ios": return "iPhone";325      case "extension": return "Browser";326      default: return "Device";327    }328  }329330  async function loadPairedDevices() {331    if (!isPro()) return;332    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {333      ok: boolean;334      data?: { devices: PairedDevice[] };335    };336    if (r.ok) setPairedDevices(r.data?.devices ?? []);337  }338  async function unpairDevice(deviceId: string) {339    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });340    setConfirmUnpair(null);341    await loadPairedDevices();342  }343  async function renameDevice(deviceId: string) {344    const name = renameDraft().trim();345    if (!name) return;346    const result = (await sendMessage({347      type: "RENAME_PAIRED_DEVICE",348      payload: { deviceId, name },349    })) as { ok: boolean };350    if (!result.ok) return;351    setRenameDeviceId(null);352    await loadPairedDevices();353  }354  async function syncNow() {355    setSyncStatus("syncing");356    setSyncFlash(null);357    const res = (await sendMessage({358      type: "TRIGGER_SYNC",359      payload: { reason: "manual", force: true },360    })) as { ok: boolean; error?: string };361    if (!res.ok) {362      setSyncStatus("error");363      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });364      return;365    }366    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the367    // outcome. A single delayed read used to land while the run was still368    // "syncing" and leave the button disabled forever.369    let s: SyncOutcomeSummary = { status: "syncing" };370    for (let i = 0; i < 40 && s.status === "syncing"; i++) {371      await new Promise((r) => setTimeout(r, 250));372      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {373        ok: boolean;374        data?: SyncOutcomeSummary;375      };376      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };377    }378    setSyncSummary(s);379    setSyncStatus(s.status ?? "idle");380    // The persistent line below carries the outcome; the flash is only the381    // "that click did something" acknowledgement.382    setSyncFlash(describeSyncOutcome(s));383    setTimeout(() => setSyncFlash(null), 4000);384    await loadPairedDevices();385  }386  // UI-4 / MV-8: rotate sync identity confirmation dialog.387  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);388  const [rotating, setRotating] = createSignal(false);389  const [rotateError, setRotateError] = createSignal<string | null>(null);390  const [showSyncConnection, setShowSyncConnection] = createSignal(false);391  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);392393  async function loadPendingVouched() {394    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {395      ok: boolean;396      data?: PendingVouched[];397    };398    if (r.ok) setPendingVouched(r.data ?? []);399  }400  async function confirmVouched(deviceId: string) {401    setVouchedBusy(deviceId);402    try {403      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });404      await loadPendingVouched();405    } finally {406      setVouchedBusy(null);407    }408  }409  async function rejectVouched(deviceId: string) {410    setVouchedBusy(deviceId);411    try {412      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });413      await loadPendingVouched();414    } finally {415      setVouchedBusy(null);416    }417  }418  async function doRotateSyncIdentity() {419    setRotateError(null);420    setRotating(true);421    try {422      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {423        ok: boolean;424        error?: string;425      };426      if (!r.ok) {427        setRotateError(r.error ?? "Rotation failed.");428        return;429      }430      setRotateConfirmOpen(false);431      // Backend set peach_recovery_pending; reuse the existing recovery-words432      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).433      setShowNewRecoveryWords(true);434    } finally {435      setRotating(false);436    }437  }438439  const [dedupCount, setDedupCount] = createSignal<number | null>(null);440  const [dedupRunning, setDedupRunning] = createSignal(false);441  const [dedupDone, setDedupDone] = createSignal<number | null>(null);442443  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);444445  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted446  // BEFORE every real-mode vault write so the user can roll back if a sync447  // or import overwrites the wrong state.448  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);449  const [historyLoading, setHistoryLoading] = createSignal(false);450  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);451  const [historyShowAll, setHistoryShowAll] = createSignal(false);452453  async function loadVaultHistory() {454    setHistoryLoading(true);455    try {456      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });457      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];458      setHistorySnapshots(list);459    } finally {460      setHistoryLoading(false);461    }462  }463464  async function handleRestoreVaultSnapshot(id: string) {465    if (historyRestoring()) return;466    setHistoryRestoring(id);467    const res = await sendMessage({468      type: "RESTORE_VAULT_SNAPSHOT",469      payload: { snapshotId: id },470    });471    setHistoryRestoring(null);472    if ((res as { ok: boolean }).ok) {473      // Vault is now locked — let the parent close Settings; App.tsx's474      // VAULT_LOCKED listener will route back to the lock screen.475      props.onBack();476    }477  }478479  function formatSnapshotTime(savedAt: number): string {480    const diff = Date.now() - savedAt;481    if (diff < 60_000) return "Just now";482    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;483    const d = new Date(savedAt);484    const isToday = new Date().toDateString() === d.toDateString();485    const isYest =486      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();487    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });488    if (isToday) return `Today ${t}`;489    if (isYest) return `Yesterday ${t}`;490    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;491  }492493  function reasonLabel(reason: VaultSnapshot["reason"]): string {494    return (495      {496        local_save: "saved",497        sync_received: "synced",498        import: "imported",499        restore: "restored",500      } as const501    )[reason] ?? reason;502  }503504  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);505506  const [showChangePw, setShowChangePw] = createSignal(false);507  const [changePwCurrent, setChangePwCurrent] = createSignal("");508  const [changePwNew, setChangePwNew] = createSignal("");509  const [changePwConfirm, setChangePwConfirm] = createSignal("");510  const [changePwSaving, setChangePwSaving] = createSignal(false);511  const [changePwError, setChangePwError] = createSignal<string | null>(null);512  const [changePwSuccess] = createSignal(false);513  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);514515  const [pinEnabled, setPinEnabled] = createSignal(false);516  const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);517  const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");518  const [pinValue, setPinValue] = createSignal("");519  const [pinConfirm, setPinConfirm] = createSignal("");520  const [pinSaving, setPinSaving] = createSignal(false);521  const [pinError, setPinError] = createSignal<string | null>(null);522523  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.524  const [showViewWords, setShowViewWords] = createSignal(false);525  const [viewWordsPassword, setViewWordsPassword] = createSignal("");526  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);527  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);528  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);529530  async function handleViewRecoveryWords() {531    setViewWordsError(null);532    if (!viewWordsPassword()) {533      setViewWordsError("Enter your master password.");534      return;535    }536    setViewWordsBusy(true);537    try {538      const r = (await sendMessage({539        type: "GET_RECOVERY_WORDS",540        payload: { password: viewWordsPassword() },541      })) as { ok: boolean; data?: string[]; error?: string };542      if (r.ok) {543        setViewedWords(r.data ?? []);544        setViewWordsPassword("");545        return;546      }547      switch (r.error) {548        case "wrong_password":549          setViewWordsError("Incorrect password");550          break;551        case "password_required":552          setViewWordsError("Enter your master password.");553          break;554        case "recovery_not_setup":555          setViewWordsError("Recovery words aren't set up for this vault yet.");556          break;557        default:558          setViewWordsError("Couldn't retrieve recovery words.");559      }560    } finally {561      setViewWordsBusy(false);562    }563  }564  function closeViewWords() {565    setShowViewWords(false);566    setViewWordsPassword("");567    setViewWordsError(null);568    setViewedWords(null);569  }570571  const [duressEnabled, setDuressEnabled] = createSignal(false);572  const [showDuressSetup, setShowDuressSetup] = createSignal(false);573  const [duressPassword, setDuressPassword] = createSignal("");574  const [duressConfirm, setDuressConfirm] = createSignal("");575  const [duressMainPassword, setDuressMainPassword] = createSignal("");576  const [duressSaving, setDuressSaving] = createSignal(false);577  const [duressError, setDuressError] = createSignal<string | null>(null);578  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);579580  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);581582  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);583  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);584585  const [fbCategory, setFbCategory] = createSignal("Bug report");586  const [fbMessage, setFbMessage] = createSignal("");587  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';588  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');589590  createEffect(() => {591    if (props.upgradeHighlight) {592      setActiveCard("pro");593    }594  });595596  onMount(async () => {597    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([598      sendMessage({ type: "GET_LICENSE" }),599      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),600      sendMessage({ type: "IS_DURESS_ENABLED" }),601      sendMessage({ type: "GET_SETTINGS" }),602      sendMessage({ type: "GET_LICENSE_KEY" }),603    ]);604605    // Vault history loads in the background — the Vault sub-view picks it up606    // when the user navigates in. Loading it lazily keeps Settings boot fast.607    void loadVaultHistory();608609    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the610    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.611    void loadPendingVouched();612    void loadPairedDevices();613    void loadSyncState();614    const onBroadcast = (msg: unknown) => {615      const m = msg as { type?: string; payload?: unknown } | null;616      if (m?.type === "VOUCHED_DEVICE_PENDING") {617        void loadPendingVouched();618      }619      if (m?.type === "SYNC_STATE_CHANGED") {620        const payload = (m.payload ?? {}) as SyncOutcomeSummary;621        setSyncSummary(payload);622        setSyncStatus(payload.status ?? "idle");623      }624    };625    chrome.runtime?.onMessage?.addListener(onBroadcast);626    onCleanup(() => {627      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }628    });629630    if ((licenseRes as { ok: boolean }).ok) {631      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);632    }633634    if ((passkeyRes as { ok: boolean }).ok) {635      setPasskeyState(636        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null637      );638    }639640    if ((duressRes as { ok: boolean }).ok) {641      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);642    }643644    if ((settingsRes as { ok: boolean }).ok) {645      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;646      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);647      setPinEnabled(sData?.pinEnabled === true);648    }649650    if ((keyRes as { ok: boolean }).ok) {651      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);652    }653654    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });655    if ((entriesRes as { ok: boolean }).ok) {656      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];657      setCanaryEntries(658        entries659          .filter((e) => e.canary?.webhookUrl)660          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))661      );662    }663664665    if (isNativePlatform()) {666      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");667      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");668      try {669        if (biometric) {670          const a = await biometric.isAvailable();671          setBiometricSupported(a.available);672        }673        if (keystore) {674          const k = await keystore.hasMasterKey();675          setBiometricStored(k.exists);676        }677      } catch { /* missing plugins — leave defaults */ }678    }679  });680681  async function handleEnableBiometrics() {682    setBiometricBusy(true);683    setBiometricError(null);684    try {685      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {686        ok: boolean;687        data?: { masterKeyHex: string };688        error?: string;689      };690      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {691        setBiometricError(keyRes.error === "vault_locked"692          ? "unlock the vault first"693          : "couldn't read derived key");694        return;695      }696      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");697      if (!keystore) {698        setBiometricError("biometric plugin unavailable");699        return;700      }701      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });702      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });703      setBiometricStored(true);704    } catch (e) {705      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");706    } finally {707      setBiometricBusy(false);708    }709  }710711  async function handleDisableBiometrics() {712    setBiometricBusy(true);713    setBiometricError(null);714    try {715      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");716      if (keystore) await keystore.clearMasterKey();717      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });718      setBiometricStored(false);719    } catch (e) {720      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");721    } finally {722      setBiometricBusy(false);723    }724  }725726  function resetPinFlow() {727    setPinFlow(null);728    setPinCurrentPassword("");729    setPinValue("");730    setPinConfirm("");731    setPinError(null);732  }733734  async function handleSetupPin() {735    setPinError(null);736    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }737    if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }738    if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }739    setPinSaving(true);740    const res = await sendMessage({741      type: "SETUP_PIN",742      payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },743    }) as { ok: boolean; error?: string };744    if (res.ok) {745      setPinEnabled(true);746      resetPinFlow();747    } else {748      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");749    }750    setPinSaving(false);751  }752753  async function handleDisablePin() {754    setPinError(null);755    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }756    setPinSaving(true);757    const res = await sendMessage({758      type: "DISABLE_PIN",759      payload: { currentPassword: pinCurrentPassword() },760    }) as { ok: boolean; error?: string };761    if (res.ok) {762      setPinEnabled(false);763      resetPinFlow();764    } else {765      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");766    }767    setPinSaving(false);768  }769770  async function handleChangePassword() {771    setChangePwError(null);772    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }773    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }774    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }775    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }776    setChangePwSaving(true);777    const res = await sendMessage({778      type: "CHANGE_PASSWORD",779      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },780    }) as { ok: boolean; error?: string };781    if (res.ok) {782      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");783      setShowChangePw(false);784      // LV-4: the user just authenticated (current password), so the recovery785      // words reveal that follows is exempt from the re-auth gate. Set the786      // pending flag the same way the create/recovery flow does; the display787      // clears it on confirm.788      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });789      setShowNewRecoveryWords(true);790    } else {791      setChangePwError(res.error ?? "failed to change password");792    }793    setChangePwSaving(false);794  }795796  const [copied, setCopied] = createSignal(false);797798  async function copyLicenseKey(key: string) {799    await navigator.clipboard.writeText(key);800    setCopied(true);801    setTimeout(() => setCopied(false), 2000);802  }803804  async function handleActivateLicense() {805    const key = licenseKeyInput().trim();806    if (!key) return;807    setActivatingLicense(true);808    setLicenseError(null);809    setLicenseSuccess(null);810    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });811    setActivatingLicense(false);812    if ((res as { ok: boolean }).ok) {813      const data = (res as { ok: boolean; data?: LicenseInfo }).data;814      if (data?.valid) {815        setLicenseInfo(data);816        setLicenseKey(key);817        setLicenseSuccess("License activated!");818        setLicenseKeyInput("");819      } else {820        setLicenseError("Invalid license key");821      }822    } else {823      setLicenseError("Activation failed");824    }825  }826827  async function handleTogglePasskeys() {828    const current = passkeyState();829    if (!current || !current.supported) return;830    setPasskeySaving(true);831    const res = await sendMessage({832      type: "SET_PASSKEY_PROVIDER_ENABLED",833      payload: { enabled: !current.enabled },834    });835    setPasskeySaving(false);836    if ((res as { ok: boolean }).ok) {837      setPasskeyState(838        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null839      );840    }841  }842843  async function handleCheckAllBreaches() {844    setCheckingBreaches(true);845    setBreachResult(null);846    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });847    setCheckingBreaches(false);848    if ((res as { ok: boolean }).ok) {849      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};850      const breached = Object.values(data).filter((count) => count > 0).length;851      setBreachResult(852        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"853      );854      props.onBreachCheckDone();855    }856  }857858859  async function handleSetupDuress() {860    const mainPw = duressMainPassword().trim();861    const duressPw = duressPassword().trim();862    const confirmPw = duressConfirm().trim();863864    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }865    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }866    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }867    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }868869    setDuressSaving(true);870    setDuressError(null);871872    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };873    if (!verifyRes.ok || !verifyRes.data) {874      setDuressError("incorrect main password");875      setDuressSaving(false);876      return;877    }878879    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };880    setDuressSaving(false);881    if (res.ok) {882      setDuressEnabled(true);883      setShowDuressSetup(false);884      setDuressPassword("");885      setDuressConfirm("");886      setDuressMainPassword("");887      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");888      setTimeout(() => setDuressSuccess(null), 5000);889    } else {890      setDuressError(891        res.error === "session_expired"892          ? "Vault session expired — lock and unlock Peach, then try again"893          : res.error ?? "failed to set duress password"894      );895    }896  }897898  async function handleClearDeclinedCards() {899    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });900    setDeclinedCardCount(0);901  }902903  async function handleCountDuplicates() {904    setDedupRunning(true);905    setDedupDone(null);906    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };907    setDedupRunning(false);908    if (res.ok) setDedupCount(res.data ?? 0);909  }910911  async function handleDeduplicate() {912    setDedupRunning(true);913    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };914    setDedupRunning(false);915    if (res.ok) {916      setDedupDone(res.data ?? 0);917      setDedupCount(0);918    }919  }920921  async function handleDisableDuress() {922    const mainPw = duressMainPassword().trim();923    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }924925    setDuressSaving(true);926    setDuressError(null);927928    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };929    if (!verifyRes.ok || !verifyRes.data) {930      setDuressError("incorrect main password");931      setDuressSaving(false);932      return;933    }934935    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };936    setDuressSaving(false);937    if (res.ok) {938      setDuressEnabled(false);939      setShowDuressSetup(false);940      setDuressMainPassword("");941      setDuressSuccess("Duress password disabled.");942      setTimeout(() => setDuressSuccess(null), 4000);943    } else {944      setDuressError(res.error ?? "failed to disable duress password");945    }946  }947948  async function handleSubmitFeedback() {949    const message = sanitizeFeedbackMessage(fbMessage());950    if (!message) return;951952    setFeedbackState('sending');953954    try {955      const hasDataPermission = await requestFeedbackDataPermission();956      if (!hasDataPermission) {957        setFeedbackState('idle');958        return;959      }960961      const category = FEEDBACK_CATEGORIES.includes(fbCategory())962        ? fbCategory()963        : FEEDBACK_CATEGORIES[0];964      const res = await fetch('https://api.web3forms.com/submit', {965        method: 'POST',966        headers: { 'Content-Type': 'application/json' },967        body: JSON.stringify({968          access_key: WEB3FORMS_KEY,969          subject: `Peach Feedback - ${category}`,970          message,971          from_name: 'Peach User',972          // Honeypot — leave blank973          botcheck: '',974        }),975      });976977      if (!res.ok) throw new Error(`HTTP ${res.status}`);978      const data = await res.json() as { success: boolean; message?: string };979      if (!data.success) throw new Error(data.message ?? 'unknown error');980981      setFeedbackState('sent');982      setFbMessage('');983      setFbCategory(FEEDBACK_CATEGORIES[0]);984    } catch (err) {985      console.error('[feedback]', err);986      setFeedbackState('error');987      setTimeout(() => setFeedbackState('idle'), 4000);988    }989  }990991  function renderSecurity() {992    return (993      <div class="peach-settings-body">994        <div class="peach-settings-group">995          <p class="peach-settings-sub-label">LOCKING</p>996          <div class="peach-settings-row">997            <span>Lock on close</span>998            <button999              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}1000              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}1001              aria-label="Toggle lock on close"1002            >1003              <span class="peach-toggle-knob"></span>1004            </button>1005          </div>1006          <div class="peach-settings-row">1007            <span>Auto-lock after</span>1008            <select1009              class="peach-settings-select"1010              value={String(props.autoLockMinutes)}1011              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1012            >1013              <option value="1">1 minute</option>1014              <option value="5">5 minutes</option>1015              <option value="15">15 minutes</option>1016              <option value="30">30 minutes</option>1017              <option value="0">Never</option>1018            </select>1019          </div>10201021          <p class="peach-settings-sub-label">QUICK UNLOCK</p>1022          <div class="peach-settings-row peach-settings-row--meta">1023            <span>PIN unlock</span>1024            <button1025              class="peach-btn peach-btn--subtle peach-btn--sm"1026              onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1027            >1028              {pinEnabled() ? "remove" : "set up"}1029            </button>1030          </div>1031          <Show when={pinFlow() === "setup"}>1032            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1033              <input1034                class="peach-settings-input"1035                type="password"1036                placeholder="current password"1037                value={pinCurrentPassword()}1038                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1039                autocomplete="current-password"1040              />1041              <input1042                class="peach-settings-input"1043                type="password"1044                inputmode="numeric"1045                maxlength="12"1046                placeholder="new 4–12 digit PIN"1047                value={pinValue()}1048                onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1049                autocomplete="off"1050              />1051              <input1052                class="peach-settings-input"1053                type="password"1054                inputmode="numeric"1055                maxlength="12"1056                placeholder="confirm PIN"1057                value={pinConfirm()}1058                onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1059                autocomplete="off"1060                onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1061              />1062              <Show when={pinError()}>1063                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1064              </Show>1065              <button1066                class="peach-btn peach-btn--accent peach-btn--sm"1067                style="align-self:flex-start"1068                onClick={() => void handleSetupPin()}1069                disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1070              >1071                {pinSaving() ? "saving…" : "save PIN"}1072              </button>1073            </div>1074          </Show>1075          <Show when={pinFlow() === "disable"}>1076            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1077              <input1078                class="peach-settings-input"1079                type="password"1080                placeholder="current password"1081                value={pinCurrentPassword()}1082                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1083                autocomplete="current-password"1084                onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1085              />1086              <Show when={pinError()}>1087                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1088              </Show>1089              <button1090                class="peach-btn peach-btn--subtle peach-btn--sm"1091                style="align-self:flex-start"1092                onClick={() => void handleDisablePin()}1093                disabled={pinSaving() || !pinCurrentPassword()}1094              >1095                {pinSaving() ? "removing…" : "remove PIN"}1096              </button>1097            </div>1098          </Show>10991100          <Show when={isNativePlatform() && biometricSupported()}>1101            <p class="peach-settings-sub-label">BIOMETRICS</p>1102            <div class="peach-settings-row">1103              <span>Unlock with biometrics</span>1104              <button1105                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}1106                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}1107                disabled={biometricBusy()}1108                aria-label="Toggle biometric unlock"1109              >1110                <span class="peach-toggle-knob"></span>1111              </button>1112            </div>1113            <Show when={biometricError()}>1114              <div class="peach-settings-row peach-settings-row--meta">1115                <span class="peach-hint peach-hint-error">{biometricError()}</span>1116              </div>1117            </Show>1118          </Show>11191120          <p class="peach-settings-sub-label">BREACH PROTECTION</p>1121          <div class="peach-settings-row">1122            <span>Breach monitoring</span>1123            <button1124              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1125              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1126              aria-label="Toggle breach monitoring"1127            >1128              <span class="peach-toggle-knob"></span>1129            </button>1130          </div>1131          <div class="peach-settings-row peach-settings-row--meta">1132            <span>Check all passwords</span>1133            <button1134              class="peach-btn peach-btn--subtle peach-btn--sm"1135              onClick={() => void handleCheckAllBreaches()}1136              disabled={checkingBreaches()}1137            >1138              {checkingBreaches() ? "checking..." : "run"}1139            </button>1140          </div>1141          <Show when={breachResult()}>1142            <div class="peach-settings-row peach-settings-row--meta">1143              <span>Last check</span>1144              <span class="peach-settings-value">{breachResult()}</span>1145            </div>1146          </Show>11471148          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1149          <div class="peach-settings-row peach-settings-row--meta">1150            <span>Master password</span>1151            <button1152              class="peach-btn peach-btn--subtle peach-btn--sm"1153              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1154            >1155              {showChangePw() ? "cancel" : "change"}1156            </button>1157          </div>1158          <Show when={showChangePw()}>1159            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1160              <input1161                class="peach-settings-input"1162                type="password"1163                placeholder="current password"1164                value={changePwCurrent()}1165                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1166                autocomplete="current-password"1167              />1168              <input1169                class="peach-settings-input"1170                type="password"1171                placeholder="new password (min 12 chars)"1172                value={changePwNew()}1173                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1174                autocomplete="new-password"1175              />1176              <input1177                class="peach-settings-input"1178                type="password"1179                placeholder="confirm new password"1180                value={changePwConfirm()}1181                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1182                autocomplete="new-password"1183                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1184              />1185              <Show when={changePwError()}>1186                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1187              </Show>1188              <Show when={changePwSuccess()}>1189                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1190              </Show>1191              <button1192                class="peach-btn peach-btn--accent peach-btn--sm"1193                style="align-self:flex-start"1194                onClick={() => void handleChangePassword()}1195                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1196              >1197                {changePwSaving() ? "saving…" : "save new password"}1198              </button>1199            </div>1200          </Show>12011202          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1203          <div class="peach-settings-row peach-settings-row--meta">1204            <span>Recovery words</span>1205            <button1206              class="peach-btn peach-btn--subtle peach-btn--sm"1207              onClick={() => {1208                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1209              }}1210            >1211              {showViewWords() ? "cancel" : "view"}1212            </button>1213          </div>1214          <Show when={showViewWords()}>1215            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1216              <Show1217                when={viewedWords()}1218                fallback={1219                  <>1220                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1221                      Enter your master password to reveal your recovery words.1222                    </p>1223                    <input1224                      class="peach-settings-input"1225                      type="password"1226                      placeholder="master password"1227                      value={viewWordsPassword()}1228                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1229                      autocomplete="current-password"1230                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1231                    />1232                    <Show when={viewWordsError()}>1233                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1234                    </Show>1235                    <button1236                      class="peach-btn peach-btn--accent peach-btn--sm"1237                      style="align-self:flex-start"1238                      onClick={() => void handleViewRecoveryWords()}1239                      disabled={viewWordsBusy() || !viewWordsPassword()}1240                    >1241                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1242                    </button>1243                  </>1244                }1245              >1246                <div class="peach-recovery-words-grid">1247                  <For each={viewedWords()!}>1248                    {(word, i) => (1249                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1250                    )}1251                  </For>1252                </div>1253                <button1254                  class="peach-btn peach-btn--subtle peach-btn--sm"1255                  style="align-self:flex-start"1256                  onClick={closeViewWords}1257                >done</button>1258              </Show>1259            </div>1260          </Show>12611262          <p class="peach-settings-sub-label">1263            DURESS PASSWORD1264            <Show when={!isPro()}>1265              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1266            </Show>1267          </p>1268          <div class="peach-settings-row peach-settings-row--top">1269            <div class="peach-settings-row-main">1270              <span>1271                Duress password1272                <Show when={!isPro()}>1273                  <span class="peach-pro-badge">Pro</span>1274                </Show>1275              </span>1276              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1277                If entered under coercion, shows a decoy empty vault instead of your real one.1278              </span>1279            </div>1280            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1281              {duressEnabled() ? "on" : "off"}1282            </div>1283          </div>12841285          <Show when={duressEnabled() && !showDuressSetup()}>1286            <div class="peach-settings-row peach-settings-row--meta">1287              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1288                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.1289              </span>1290            </div>1291          </Show>12921293          <Show when={duressSuccess()}>1294            <div class="peach-settings-row peach-settings-row--meta">1295              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1296            </div>1297          </Show>12981299          <Show when={!showDuressSetup()}>1300            <div class="peach-settings-row peach-settings-row--meta">1301              <button1302                class="peach-btn peach-btn--subtle peach-btn--sm"1303                onClick={() => {1304                  // Pro gate entry-point: free users are redirected to the1305                  // upgrade screen before the setup form opens. The server-1306                  // side SETUP_DURESS handler is the single enforcement1307                  // point; this is UI redirection so the form never appears1308                  // for a user who can't complete it.1309                  if (!isPro() && !duressEnabled()) {1310                    props.onProGate?.("duress_vault");1311                    setActiveCard("pro");1312                    return;1313                  }1314                  setShowDuressSetup(true);1315                  setDuressError(null);1316                  setDuressSuccess(null);1317                  setDuressPassword("");1318                  setDuressConfirm("");1319                  setDuressMainPassword("");1320                }}1321              >1322                {duressEnabled() ? "Change / disable" : "Set up"}1323              </button>1324            </div>1325          </Show>13261327          <Show when={showDuressSetup()}>1328            <div class="peach-duress-form">1329              <label class="peach-label">main password (to confirm)</label>1330              <input1331                class="peach-settings-input"1332                type="password"1333                placeholder="your current master password"1334                autocomplete="current-password"1335                value={duressMainPassword()}1336                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1337              />1338              <Show when={!duressEnabled()}>1339                <label class="peach-label">new duress password</label>1340                <input1341                  class="peach-settings-input"1342                  type="password"1343                  placeholder="duress password (min 8 chars)"1344                  autocomplete="new-password"1345                  value={duressPassword()}1346                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1347                />1348                <label class="peach-label">confirm duress password</label>1349                <input1350                  class="peach-settings-input"1351                  type="password"1352                  placeholder="confirm duress password"1353                  autocomplete="new-password"1354                  value={duressConfirm()}1355                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1356                />1357              </Show>13581359              <Show when={duressError()}>1360                <p class="peach-hint peach-hint-error">{duressError()}</p>1361              </Show>13621363              <div class="peach-duress-actions">1364                <button1365                  class="peach-btn peach-btn--subtle peach-btn--sm"1366                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1367                >1368                  cancel1369                </button>1370                <Show when={duressEnabled()}>1371                  <button1372                    class="peach-btn peach-btn--ghost peach-btn--sm"1373                    style="color:var(--peach-breach)"1374                    onClick={() => void handleDisableDuress()}1375                    disabled={duressSaving()}1376                  >1377                    {duressSaving() ? "disabling..." : "disable duress"}1378                  </button>1379                </Show>1380                <Show when={!duressEnabled()}>1381                  <button1382                    class="peach-btn peach-btn--accent peach-btn--sm"1383                    onClick={() => void handleSetupDuress()}1384                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1385                  >1386                    {duressSaving() ? "saving..." : "save"}1387                  </button>1388                </Show>1389              </div>1390            </div>1391          </Show>13921393          <p class="peach-settings-sub-label">1394            CANARY ENTRIES1395            <Show when={!isPro()}>1396              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1397            </Show>1398          </p>1399          <div class="peach-settings-row peach-settings-row--top">1400            <div class="peach-settings-row-main">1401              <span>1402                Canary entries1403                <Show when={!isPro()}>1404                  <span class="peach-pro-badge">Pro</span>1405                </Show>1406              </span>1407              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1408                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1409              </span>1410            </div>1411            <span class="peach-settings-value" style="flex-shrink:0">1412              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1413            </span>1414          </div>1415          <Show when={canaryEntries().length > 0}>1416            <For each={canaryEntries()}>1417              {(entry) => (1418                <div class="peach-settings-row peach-settings-row--meta">1419                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1420                  <button1421                    class="peach-btn peach-btn--subtle peach-btn--sm"1422                    onClick={() => void handleTestCanary(entry)}1423                    disabled={canaryTestingId() === entry.id}1424                    style="flex-shrink:0"1425                  >1426                    {canaryTestingId() === entry.id1427                      ? "sending…"1428                      : canaryTestResult()?.id === entry.id1429                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1430                        : "test"}1431                  </button>1432                </div>1433              )}1434            </For>1435          </Show>14361437          <p class="peach-settings-sub-label">PASSKEYS</p>1438          <div class="peach-settings-row peach-settings-row--top">1439            <div class="peach-settings-row-main">1440              <span>Intercept passkey requests</span>1441              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1442                Peach can handle passkey sign-in and creation on supported Chromium browsers.1443              </span>1444            </div>1445            <button1446              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1447              onClick={() => void handleTogglePasskeys()}1448              aria-label="Toggle passkey interception"1449              disabled={!passkeyState()?.supported || passkeySaving()}1450            >1451              <span class="peach-toggle-knob"></span>1452            </button>1453          </div>1454          <div class="peach-settings-row peach-settings-row--meta">1455            <span>Status</span>1456            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1457          </div>1458          <div class="peach-settings-row peach-settings-row--meta">1459            <span>Browser</span>1460            <span class="peach-settings-value">1461              {passkeyState()?.browserLabel ?? "Unknown"}1462              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1463            </span>1464          </div>1465          <div class="peach-info-notice" style="margin-top:8px">1466            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1467          </div>1468          <Show when={passkeyState()?.conflict}>1469            <div class="peach-settings-row peach-settings-row--meta">1470              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1471            </div>1472          </Show>1473        </div>1474      </div>1475    );1476  }14771478  function renderVault() {1479    return (1480      <div class="peach-settings-body">1481        <div class="peach-settings-group">1482          <p class="peach-settings-sub-label">ENTRIES</p>1483          <div class="peach-settings-row peach-settings-row--meta">1484            <span>Entries</span>1485            <span class="peach-settings-value">{props.entriesCount}</span>1486          </div>1487          <div class="peach-settings-row peach-settings-row--meta">1488            <span>Duplicates</span>1489            <Show when={dedupDone() !== null} fallback={1490              <button1491                class="peach-btn peach-btn--subtle peach-btn--sm"1492                onClick={() => void handleCountDuplicates()}1493                disabled={dedupRunning()}1494              >1495                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1496              </button>1497            }>1498              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1499                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1500              </span>1501            </Show>1502          </div>1503          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1504            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1505              <div class="peach-restore-warning">1506                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1507              </div>1508              <div style="display:flex;gap:6px">1509                <button1510                  class="peach-btn peach-btn--subtle peach-btn--sm"1511                  onClick={() => setDedupCount(null)}1512                >1513                  cancel1514                </button>1515                <button1516                  class="peach-btn peach-btn--accent peach-btn--sm"1517                  onClick={() => void handleDeduplicate()}1518                  disabled={dedupRunning()}1519                >1520                  {dedupRunning() ? "removing..." : "remove duplicates"}1521                </button>1522              </div>1523            </div>1524          </Show>15251526          <p class="peach-settings-sub-label">MAINTENANCE</p>1527          <div class="peach-settings-row peach-settings-row--meta">1528            <span>Trash auto-delete</span>1529            <select1530              class="peach-settings-select"1531              value={String(props.trashRetentionDays)}1532              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1533            >1534              <option value="7">7 days</option>1535              <option value="30">30 days</option>1536              <option value="90">90 days</option>1537              <option value="0">Never</option>1538            </select>1539          </div>1540          <div class="peach-settings-row peach-settings-row--meta">1541            <span>Advanced Vault Health</span>1542            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1543              open1544            </button>1545          </div>1546          <Show when={declinedCardCount() > 0}>1547            <div class="peach-settings-row peach-settings-row--meta">1548              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1549              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1550                Clear1551              </button>1552            </div>1553          </Show>15541555          <p class="peach-settings-sub-label">DATA</p>1556          <div class="peach-settings-row peach-settings-row--meta">1557            <span>Import passwords</span>1558            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1559              open1560            </button>1561          </div>1562          <div class="peach-settings-row peach-settings-row--meta">1563            <span>Export backup</span>1564            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1565              open1566            </button>1567          </div>1568          <div class="peach-settings-row peach-settings-row--meta">1569            <span>1570              Peach Codex1571              <Show when={!isPro()}>1572                <span class="peach-pro-badge">Pro</span>1573              </Show>1574            </span>1575            <button1576              class="peach-btn peach-btn--subtle peach-btn--sm"1577              onClick={() => props.onOpenCodexGenerate?.()}1578            >1579              open1580            </button>1581          </div>1582          {/* Restore from Codex: intentionally never gated — a printed1583              Codex must remain restorable regardless of license state. */}1584          <div class="peach-settings-row peach-settings-row--meta">1585            <span>Restore from Codex</span>1586            <button1587              class="peach-btn peach-btn--subtle peach-btn--sm"1588              onClick={() => props.onOpenCodexRestore?.()}1589            >1590              open1591            </button>1592          </div>15931594          <span class="peach-settings-sub-label">VAULT HISTORY</span>1595          <Show when={historyLoading()}>1596            <p class="peach-settings-hint">Loading…</p>1597          </Show>1598          <Show when={!historyLoading() && historySnapshots().length === 0}>1599            <p class="peach-settings-hint">1600              No snapshots yet. Peach saves automatically before every change —1601              saves, syncs, and imports.1602            </p>1603          </Show>1604          <Show when={!historyLoading() && historySnapshots().length > 0}>1605            <p class="peach-settings-hint">1606              Restoring locks your vault. Unlock with the password that1607              encrypted that snapshot to confirm.1608            </p>1609            <div class="peach-history-list">1610              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1611                {(snap) => (1612                  <div class="peach-history-row">1613                    <div class="peach-history-meta">1614                      <span class="peach-history-time">1615                        {formatSnapshotTime(snap.savedAt)}1616                      </span>1617                      <span class="peach-history-sub">1618                        {[1619                          snap.entryCount !== null1620                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1621                            : null,1622                          reasonLabel(snap.reason),1623                        ]1624                          .filter(Boolean)1625                          .join(" · ")}1626                      </span>1627                    </div>1628                    <button1629                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1630                        historyRestoring() === snap.id ? " is-busy" : ""1631                      }`}1632                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1633                      disabled={historyRestoring() !== null}1634                    >1635                      <Show when={historyRestoring() === snap.id} fallback="Restore">1636                        …1637                      </Show>1638                    </button>1639                  </div>1640                )}1641              </For>1642            </div>1643            <Show when={historySnapshots().length > 3}>1644              <button1645                class="peach-settings-advanced-toggle"1646                onClick={() => setHistoryShowAll((v) => !v)}1647                aria-expanded={historyShowAll()}1648              >1649                <span class="peach-settings-hint" style="margin:0">1650                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1651                </span>1652                <span class="peach-settings-advanced-chevron">1653                  {historyShowAll() ? "▲" : "▼"}1654                </span>1655              </button>1656            </Show>1657            <p class="peach-history-footer">1658              {historySnapshots().length} of 10 snapshots1659            </p>1660          </Show>16611662          <button1663            class="peach-settings-advanced-toggle"1664            onClick={() => setShowVaultAdvanced((v) => !v)}1665            aria-expanded={showVaultAdvanced()}1666          >1667            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1668            <span class="peach-settings-advanced-chevron">1669              {showVaultAdvanced() ? "▲" : "▼"}1670            </span>1671          </button>16721673          <Show when={showVaultAdvanced()}>1674            <div class="peach-danger-card">1675              <div class="peach-danger-card-head">1676                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1677                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1678              </div>1679              <p class="peach-danger-card-copy">1680                Wipes the current vault and restores from a backup file.1681                Recoverable only if your backup is good.1682              </p>1683              <button1684                class="peach-danger-card-button"1685                onClick={() => props.onOpenFileImport?.()}1686              >1687                Choose backup file1688              </button>1689            </div>16901691            <Show when={props.onOpenPanic}>1692              <div class="peach-danger-card peach-danger-card--hazard">1693                <div class="peach-danger-card-head">1694                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1695                  <h3 class="peach-danger-card-title">Destroy vault</h3>1696                </div>1697                <p class="peach-danger-card-copy">1698                  Permanently wipes every entry, setting, and license on this1699                  device. There is no recovery.1700                </p>1701                <button1702                  class="peach-danger-card-button"1703                  onClick={() => props.onOpenPanic?.()}1704                >1705                  Begin destruction1706                </button>1707              </div>1708            </Show>1709          </Show>1710        </div>1711      </div>1712    );1713  }17141715  function renderSync() {1716    return (1717      <div class="peach-settings-body">1718        <div class="peach-settings-group">1719          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1720          <div1721            class="peach-settings-row peach-settings-row--meta"1722            style="align-items:center;gap:8px"1723            role="status"1724            aria-label={syncLightLabel()}1725            title={syncLightLabel()}1726          >1727            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1728            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1729              {syncFlash() ?? lastSyncLine()}1730            </span>1731          </div>1732          <Show when={pairedDevices().length > 0}>1733            <div class="peach-settings-row peach-settings-row--meta">1734              <span class="peach-settings-value" style="color:var(--peach-t2)">1735                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01736                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1737                  : ""}1738              </span>1739              <button1740                class="peach-btn peach-btn--subtle peach-btn--sm"1741                aria-label="Sync this vault now"1742                disabled={syncStatus() === "syncing"}1743                onClick={() => void syncNow()}1744              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1745            </div>1746          </Show>17471748          <p class="peach-settings-sub-label">ADD A DEVICE</p>1749          <button1750            class="peach-sync-pair-option"1751            onClick={() => props.onOpenSyncPairing?.("code")}1752          >1753            <span class="peach-sync-pair-option-icon" aria-hidden="true">1754              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1755                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1756                <path d="M9 15 L11 15" />1757              </svg>1758            </span>1759            <span class="peach-sync-pair-option-main">1760              <span class="peach-sync-pair-option-title">Show a code</span>1761              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1762            </span>1763            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1764          </button>1765          <Show when={!isPro()}>1766            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1767              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1768            </p>1769          </Show>17701771          <Show when={pairedDevices().length > 0}>1772            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1773            <For each={pairedDevices()}>1774              {(d) => (1775                <div class="peach-settings-row peach-settings-row--top">1776                  <div class="peach-settings-row-main">1777                    <Show1778                      when={renameDeviceId() === d.deviceId}1779                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1780                    >1781                      <input1782                        class="peach-settings-input"1783                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1784                        maxlength={64}1785                        value={renameDraft()}1786                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1787                        onKeyDown={(event) => {1788                          if (event.key === "Enter") void renameDevice(d.deviceId);1789                          if (event.key === "Escape") setRenameDeviceId(null);1790                        }}1791                      />1792                    </Show>1793                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1794                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1795                    </span>1796                  </div>1797                  <Show1798                    when={confirmUnpair() === d.deviceId}1799                    fallback={1800                      <div style="display:flex;gap:4px;flex-shrink:0">1801                        <Show1802                          when={renameDeviceId() === d.deviceId}1803                          fallback={1804                            <button1805                              class="peach-btn peach-btn--subtle peach-btn--sm"1806                              onClick={() => {1807                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1808                                setRenameDeviceId(d.deviceId);1809                              }}1810                            >rename</button>1811                          }1812                        >1813                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1814                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1815                        </Show>1816                        <button1817                          class="peach-btn peach-btn--subtle peach-btn--sm"1818                          onClick={() => setConfirmUnpair(d.deviceId)}1819                        >remove</button>1820                      </div>1821                    }1822                  >1823                    {/* HOLE 4: revocation stops this device receiving future1824                        changes. It does NOT secure the vault against it — that1825                        device may already hold the recovery words, which are1826                        master-password-equivalent and restore the whole vault1827                        anywhere, forever, with no sync involved. Say so here,1828                        rather than leaving the user falsely reassured. */}1829                    <div class="peach-unpair-confirm">1830                      <p class="peach-unpair-confirm-note">1831                        Stops this device receiving future changes. It doesn't1832                        lock it out of what it already has — if it's lost or1833                        stolen, also change your master password and generate1834                        new recovery words.1835                      </p>1836                      <div style="display:flex;gap:6px;justify-content:flex-end">1837                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1838                        <button1839                          class="peach-btn peach-btn--sm"1840                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1841                          onClick={() => void unpairDevice(d.deviceId)}1842                        >remove</button>1843                      </div>1844                    </div>1845                  </Show>1846                </div>1847              )}1848            </For>1849          </Show>18501851          <Show when={pendingVouched().length > 0}>1852            <p class="peach-settings-sub-label">1853              PENDING DEVICES1854              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1855            </p>1856            <For each={pendingVouched()}>1857              {(dev) => (1858                <div class="peach-settings-row peach-settings-row--top">1859                  <div class="peach-settings-row-main">1860                    <span>{dev.deviceName}</span>1861                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1862                      Was added by another of your devices. Allow it to sync with this vault?1863                    </span>1864                  </div>1865                  <div style="display:flex;gap:6px;flex-shrink:0">1866                    <button1867                      class="peach-btn peach-btn--sm"1868                      disabled={vouchedBusy() === dev.deviceId}1869                      onClick={() => void confirmVouched(dev.deviceId)}1870                    >Allow</button>1871                    <button1872                      class="peach-btn peach-btn--subtle peach-btn--sm"1873                      disabled={vouchedBusy() === dev.deviceId}1874                      onClick={() => void rejectVouched(dev.deviceId)}1875                    >Reject</button>1876                  </div>1877                </div>1878              )}1879            </For>1880          </Show>18811882          <button1883            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1884            onClick={() => setShowSyncConnection((value) => !value)}1885            aria-expanded={showSyncConnection()}1886            aria-controls="peach-sync-connection"1887          >1888            <span>1889              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1890              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1891            </span>1892            <span class="peach-settings-advanced-chevron">1893              {showSyncConnection() ? "▲" : "▼"}1894            </span>1895          </button>1896          <Show when={showSyncConnection()}>1897            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1898              <SyncModeSelector />1899            </div>1900          </Show>19011902          <button1903            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1904            onClick={() => setShowSyncRecovery((value) => !value)}1905            aria-expanded={showSyncRecovery()}1906            aria-controls="peach-sync-recovery"1907          >1908            <span>1909              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1910              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1911            </span>1912            <span class="peach-settings-advanced-chevron">1913              {showSyncRecovery() ? "▲" : "▼"}1914            </span>1915          </button>1916          <Show when={showSyncRecovery()}>1917            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1918              <Show1919                when={rotateConfirmOpen()}1920                fallback={1921                  <div class="peach-settings-row peach-settings-row--top">1922                    <div class="peach-settings-row-main">1923                      <span>Rotate sync identity</span>1924                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1925                        Disconnects every device and replaces the keys used for sync.1926                      </span>1927                    </div>1928                    <button1929                      class="peach-btn peach-btn--subtle peach-btn--sm"1930                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1931                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1932                    >rotate</button>1933                  </div>1934                }1935              >1936                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1937                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1938                  <Show when={rotateError()}>1939                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1940                  </Show>1941                  <div style="display:flex;gap:6px;margin-top:10px">1942                    <button1943                      class="peach-btn peach-btn--subtle peach-btn--sm"1944                      disabled={rotating()}1945                      onClick={() => setRotateConfirmOpen(false)}1946                    >Cancel</button>1947                    <button1948                      class="peach-btn peach-btn--sm"1949                      style="background:var(--peach-breach);border-color:var(--peach-breach)"1950                      disabled={rotating()}1951                      onClick={() => void doRotateSyncIdentity()}1952                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1953                  </div>1954                </div>1955              </Show>1956            </div>1957          </Show>1958        </div>1959      </div>1960    );1961  }19621963  function renderAppearance() {1964    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1965      { id: "medium", label: "Default" },1966      { id: "large", label: "Large" },1967    ];1968    return (1969      <div class="peach-settings-body">1970        <div class="peach-settings-group">1971          <p class="peach-settings-sub-label">THEME</p>1972          <div class="peach-settings-row peach-settings-row--meta">1973            <span>Theme</span>1974            <button1975              class="peach-btn peach-btn--subtle peach-btn--sm"1976              onClick={() =>1977                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1978              }1979            >1980              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1981            </button>1982          </div>19831984          <p class="peach-settings-sub-label">ACCENT</p>1985          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1986            <span>Accent</span>1987            <div class="peach-accent-picker">1988              <For each={ACCENT_COLORS}>1989                {(color) => (1990                  <button1991                    class="peach-accent-swatch peach-swatch"1992                    classList={{ "is-active": props.accentColor === color.name }}1993                    style={{ background: color.hex }}1994                    onClick={() => void props.onAccentChange(color.name)}1995                    aria-label={color.name}1996                    title={color.name}1997                  />1998                )}1999              </For>2000            </div>2001          </div>20022003          <p class="peach-settings-sub-label">TEXT SIZE</p>2004          <div class="peach-settings-row peach-settings-row--meta">2005            <span>Size</span>2006            <div class="peach-pill-group">2007              <For each={sizeOptions}>2008                {(opt) => (2009                  <button2010                    class="peach-btn peach-btn--ghost peach-btn--sm"2011                    classList={{ "is-active": props.textSize === opt.id }}2012                    onClick={() => void props.onTextSizeChange(opt.id)}2013                  >2014                    {opt.label}2015                  </button>2016                )}2017              </For>2018            </div>2019          </div>20202021          <p class="peach-settings-sub-label">MOTION</p>2022          <div class="peach-settings-row peach-settings-row--meta">2023            <span>Reduce motion</span>2024            <button2025              class="peach-btn peach-btn--subtle peach-btn--sm"2026              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2027            >2028              {props.reduceMotion ? "On" : "Off"}2029            </button>2030          </div>2031        </div>2032      </div>2033    );2034  }20352036  function renderShortcuts() {2037    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2038    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);20392040    function startCapture(action: ShortcutAction) {2041      setConflictMsg(null);2042      setCapturingAction(action);2043    }2044    function cancelCapture() {2045      setCapturingAction(null);2046      setConflictMsg(null);2047    }2048    function commitBinding(action: ShortcutAction, binding: Keybinding) {2049      const conflict = findConflict(props.shortcuts, binding, action);2050      if (conflict) {2051        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2052        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2053        return;2054      }2055      const next = { ...props.shortcuts, [action]: binding };2056      setCapturingAction(null);2057      setConflictMsg(null);2058      void props.onShortcutsChange(next);2059    }2060    function resetAction(action: ShortcutAction) {2061      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2062      void props.onShortcutsChange(next);2063    }2064    function resetAll() {2065      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2066    }20672068    // Capture-mode key handler — wired via ref onto the chip element so it2069    // catches the next non-modifier keypress and ignores other bindings.2070    function handleCaptureKey(e: KeyboardEvent) {2071      e.preventDefault();2072      e.stopPropagation();2073      if (e.key === "Escape") { cancelCapture(); return; }2074      const binding = keybindingFromEvent(e);2075      if (!binding) return; // bare modifier — keep waiting2076      const action = capturingAction();2077      if (action) commitBinding(action, binding);2078    }20792080    return (2081      <div class="peach-settings-body">2082        <div class="peach-settings-group peach-shortcuts-group">2083          <p class="peach-settings-sub-label">ENTRY VIEW</p>2084          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2085            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2086          </For>2087        </div>20882089        <div class="peach-settings-group peach-shortcuts-group">2090          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2091          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2092            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2093          </For>2094        </div>20952096        <Show when={conflictMsg()}>2097          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2098        </Show>20992100        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2101          <div class="peach-settings-row peach-settings-row--meta">2102            <span>Inline autofill on page</span>2103            <a2104              class="peach-settings-link"2105              href="chrome://extensions/shortcuts"2106              onClick={(e) => {2107                e.preventDefault();2108                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2109              }}2110            >2111              ⌘⇧P — customize ↗2112            </a>2113          </div>2114          <button2115            class="peach-btn peach-btn--subtle peach-btn--sm"2116            style="align-self:flex-start"2117            onClick={resetAll}2118          >2119            Reset all to defaults2120          </button>2121        </div>2122      </div>2123    );21242125    function renderRow(2126      desc: typeof SHORTCUT_DESCRIPTORS[number],2127      capturing: () => ShortcutAction | null,2128      onStart: (a: ShortcutAction) => void,2129      onCancel: () => void,2130      onReset: (a: ShortcutAction) => void,2131      onKey: (e: KeyboardEvent) => void,2132    ) {2133      const isActive = () => capturing() === desc.action;2134      const binding = () => props.shortcuts[desc.action];2135      const isCustom = () => {2136        const b = binding();2137        const d = DEFAULT_BINDINGS[desc.action];2138        return b.key !== d.key2139          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2140          || Boolean(b.meta)  !== Boolean(d.meta)2141          || Boolean(b.shift) !== Boolean(d.shift)2142          || Boolean(b.alt)   !== Boolean(d.alt);2143      };2144      return (2145        <div class="peach-shortcut-row">2146          <span class="peach-shortcut-row-label">{desc.label}</span>2147          <button2148            class="peach-shortcut-chip"2149            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2150            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2151            onKeyDown={(e) => { if (isActive()) onKey(e); }}2152            ref={(el) => {2153              // Auto-focus when entering capture mode so the keydown listener catches the next press.2154              if (el) {2155                queueMicrotask(() => { if (isActive()) el.focus(); });2156              }2157            }}2158          >2159            {isActive() ? "Press a key…" : formatKeybinding(binding())}2160          </button>2161          <Show when={isCustom() && !isActive()}>2162            <button2163              class="peach-shortcut-reset"2164              title="Reset to default"2165              onClick={() => onReset(desc.action)}2166            >2167              ↺2168            </button>2169          </Show>2170        </div>2171      );2172    }2173  }21742175  const PRO_FEATURES: Array<{2176    feature: ProFeature;2177    name: string;2178    desc: string;2179  }> = [2180    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2181    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2182    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2183    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2184  ];21852186  const PRO_SECURITY_FEATURES: Array<{2187    feature: ProFeature;2188    name: string;2189    desc: string;2190  }> = [2191    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2192    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2193  ];21942195  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2196  const [showKey, setShowKey] = createSignal(false);21972198  const [deactivating, setDeactivating] = createSignal(false);21992200  async function handleDeactivateLicense() {2201    setDeactivating(true);2202    await sendMessage({ type: "DEACTIVATE_LICENSE" });2203    setLicenseInfo(null);2204    await props.onLicenseChange?.();2205    setDeactivating(false);2206  }22072208  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2209    setCanaryTestingId(entry.id);2210    setCanaryTestResult(null);2211    const res = await sendMessage({2212      type: "FIRE_CANARY_PING",2213      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2214    }) as { ok: boolean };2215    setCanaryTestResult({ id: entry.id, ok: res.ok });2216    setCanaryTestingId(null);2217    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2218  }22192220  function renderLicense() {2221    async function onActivate() {2222      await handleActivateLicense();2223      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2224        await props.onLicenseChange?.();2225      }2226    }22272228    return (2229      <div class="peach-settings-body">2230        <Show2231          when={isPro()}2232          fallback={2233                <div class="peach-pro-upgrade">2234                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>22352236                  <h2 class="peach-pro-headline">2237                    Every<em>thing,</em><br />every<em>where.</em>2238                  </h2>22392240                  <p class="peach-pro-subtitle">2241                    One key unlocks every Pro feature — for the lifetime of the app.2242                  </p>22432244                  <div class="peach-pro-grid">2245                    <For each={PRO_FEATURES}>2246                      {(item, i) => (2247                        <div2248                          class="peach-pro-grid-item"2249                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2250                        >2251                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2252                          <span class="peach-pro-grid-name">{item.name}</span>2253                          <span class="peach-pro-grid-desc">{item.desc}</span>2254                        </div>2255                      )}2256                    </For>2257                  </div>22582259                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2260                  <div class="peach-pro-grid">2261                    <For each={PRO_SECURITY_FEATURES}>2262                      {(item, i) => (2263                        <div2264                          class="peach-pro-grid-item"2265                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2266                        >2267                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2268                          <span class="peach-pro-grid-name">{item.name}</span>2269                          <span class="peach-pro-grid-desc">{item.desc}</span>2270                        </div>2271                      )}2272                    </For>2273                  </div>22742275                  <div class="peach-pro-upgrade-actions">2276                    <p class="peach-pro-pricing">2277                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2278                    </p>2279                    <a2280                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2281                      target="_blank"2282                      rel="noopener noreferrer"2283                      class="peach-pro-upgrade-cta"2284                    >2285                      Get Peach Pro2286                    </a>2287                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2288                    <div class="peach-pro-key-inline">2289                      <input2290                        class="peach-pro-key-inline-input"2291                        type="text"2292                        placeholder="PEACH-XXXX-XXXX-XXXX"2293                        value={licenseKeyInput()}2294                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2295                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2296                        autocomplete="off"2297                        spellcheck={false}2298                      />2299                      <button2300                        class="peach-pro-key-apply"2301                        onClick={() => void onActivate()}2302                        disabled={activatingLicense() || !licenseKeyInput().trim()}2303                      >2304                        {activatingLicense() ? "…" : "APPLY"}2305                      </button>2306                    </div>2307                    <Show when={licenseError()}>2308                      <p class="peach-pro-key-error">{licenseError()}</p>2309                    </Show>2310                  </div>2311                </div>2312          }2313        >2314          <div class="peach-pro-active">2315            <div class="peach-pro-active-hero">2316              <button2317                class="peach-pro-active-mark-wrap"2318                type="button"2319                aria-label="Celebrate"2320                onClick={handleProCelebrationClick}2321              >2322                <PeachMark class="peach-pro-active-mark" />2323                <Show when={justActivated()}>2324                  <div class="peach-pro-sparks" aria-hidden="true">2325                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2326                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2327                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2328                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2329                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2330                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2331                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2332                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2333                  </div>2334                </Show>2335              </button>2336              <h2 class="peach-pro-active-title">Peach Pro</h2>2337              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2338              <Show when={justActivated()}>2339                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2340              </Show>2341            </div>23422343            <Show when={licenseKey()}>2344              <div class="peach-settings-license">2345                <span class="peach-settings-label">LICENSE KEY</span>2346                <div class="peach-settings-license-row">2347                  <Show2348                    when={showKey()}2349                    fallback={2350                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2351                        ••••••••••••••••••••••••••••2352                      </span>2353                    }2354                  >2355                    <span class="peach-settings-license-key">{licenseKey()!}</span>2356                  </Show>2357                  <div class="peach-settings-license-actions">2358                    <button2359                      class="peach-settings-license-copy"2360                      title="Copy license key"2361                      onClick={() => copyLicenseKey(licenseKey()!)}2362                    >2363                      {copied() ? "✓" : "copy"}2364                    </button>2365                    <button2366                      class="peach-settings-license-toggle"2367                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2368                      title={showKey() ? "Hide" : "Reveal"}2369                      onClick={() => setShowKey(v => !v)}2370                    >2371                      <Show2372                        when={showKey()}2373                        fallback={2374                          <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">2375                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2376                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2377                          </svg>2378                        }2379                      >2380                        <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">2381                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2382                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2383                          <line x1="2" y1="2" x2="12" y2="12" />2384                        </svg>2385                      </Show>2386                    </button>2387                  </div>2388                </div>2389              </div>2390            </Show>23912392            <div class="peach-pro-active-features">2393              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2394                {(item) => (2395                  <div class="peach-pro-active-feature">2396                    <span class="peach-pro-active-feature-mark">✦</span>2397                    <span class="peach-pro-active-feature-name">{item.name}</span>2398                  </div>2399                )}2400              </For>2401            </div>24022403            <button2404              class="peach-pro-deactivate"2405              onClick={() => void handleDeactivateLicense()}2406              disabled={deactivating()}2407            >2408              {deactivating() ? "Deactivating…" : "Deactivate"}2409            </button>2410          </div>2411        </Show>2412      </div>2413    );2414  }24152416  function renderFeedback() {2417    return (2418      <div class="peach-settings-body">2419        <div class="peach-settings-group peach-feedback-group">2420          <Show2421            when={feedbackState() === 'sent'}2422            fallback={2423              <>2424                <p class="peach-feedback-intro">2425                  Help us improve Peach. Every report goes directly to our inbox.2426                </p>24272428                <p class="peach-settings-sub-label">TYPE</p>2429                <div class="peach-feedback-chips">2430                  <For each={FEEDBACK_CATEGORIES}>2431                    {(cat) => {2432                      const selected = () => fbCategory() === cat;2433                      return (2434                        <button2435                          type="button"2436                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2437                          aria-pressed={selected()}2438                          onClick={() => setFbCategory(cat)}2439                        >2440                          {cat}2441                        </button>2442                      );2443                    }}2444                  </For>2445                </div>24462447                <p class="peach-settings-sub-label">MESSAGE</p>2448                <textarea2449                  class="peach-feedback-textarea"2450                  placeholder="Describe the issue or idea..."2451                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2452                  value={fbMessage()}2453                  onInput={(e) => setFbMessage(e.currentTarget.value)}2454                />24552456                <p class="peach-feedback-meta">2457                  Version {version} · will be included automatically2458                </p>24592460                <button2461                  type="button"2462                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2463                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2464                  onClick={() => void handleSubmitFeedback()}2465                >2466                  <Switch>2467                    <Match when={feedbackState() === 'idle'}>Send</Match>2468                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2469                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2470                  </Switch>2471                </button>24722473                <p class="peach-feedback-anon">2474                  Sent anonymously · no account required2475                </p>2476              </>2477            }2478          >2479            <div class="peach-feedback-thanks" role="status" aria-live="polite">2480              <p class="peach-feedback-thanks-title">Thank you</p>2481              <p>2482                Your note is in the queue. Someone will process it soon.2483              </p>2484            </div>2485          </Show>2486        </div>2487      </div>2488    );2489  }24902491  const bodies: StackBodies = {2492    protection: renderSecurity(),2493    vault: renderVault(),2494    sync: renderSync(),2495    appearance: renderAppearance(),2496    shortcuts: renderShortcuts(),2497    pro: renderLicense(),2498    feedback: renderFeedback(),2499  };25002501  return (2502    <Show2503      when={showNewRecoveryWords()}2504      fallback={2505        <div2506          class="peach-overlay-view peach-settings-overlay"2507          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2508          style="display:flex;flex-direction:column"2509        >2510          <StackDeck2511            active={activeCard()}2512            onActiveChange={setActiveCard}2513            onBack={props.onBack}2514            cards={cards()}2515            bodies={bodies}2516            version={version}2517            isFanning={isFanning()}2518            celebratingCardId={justActivated() ? "pro" : null}2519            isPro={isPro()}2520          />2521        </div>2522      }2523    >2524      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2525    </Show>2526  );2527}