Settings.tsx

96.8 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  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.516  const [showViewWords, setShowViewWords] = createSignal(false);517  const [viewWordsPassword, setViewWordsPassword] = createSignal("");518  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);519  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);520  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);521522  async function handleViewRecoveryWords() {523    setViewWordsError(null);524    if (!viewWordsPassword()) {525      setViewWordsError("Enter your master password.");526      return;527    }528    setViewWordsBusy(true);529    try {530      const r = (await sendMessage({531        type: "GET_RECOVERY_WORDS",532        payload: { password: viewWordsPassword() },533      })) as { ok: boolean; data?: string[]; error?: string };534      if (r.ok) {535        setViewedWords(r.data ?? []);536        setViewWordsPassword("");537        return;538      }539      switch (r.error) {540        case "wrong_password":541          setViewWordsError("Incorrect password");542          break;543        case "password_required":544          setViewWordsError("Enter your master password.");545          break;546        case "recovery_not_setup":547          setViewWordsError("Recovery words aren't set up for this vault yet.");548          break;549        default:550          setViewWordsError("Couldn't retrieve recovery words.");551      }552    } finally {553      setViewWordsBusy(false);554    }555  }556  function closeViewWords() {557    setShowViewWords(false);558    setViewWordsPassword("");559    setViewWordsError(null);560    setViewedWords(null);561  }562563  const [duressEnabled, setDuressEnabled] = createSignal(false);564  const [showDuressSetup, setShowDuressSetup] = createSignal(false);565  const [duressPassword, setDuressPassword] = createSignal("");566  const [duressConfirm, setDuressConfirm] = createSignal("");567  const [duressMainPassword, setDuressMainPassword] = createSignal("");568  const [duressSaving, setDuressSaving] = createSignal(false);569  const [duressError, setDuressError] = createSignal<string | null>(null);570  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);571572  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);573574  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);575  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);576577  const [fbCategory, setFbCategory] = createSignal("Bug report");578  const [fbMessage, setFbMessage] = createSignal("");579  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';580  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');581582  createEffect(() => {583    if (props.upgradeHighlight) {584      setActiveCard("pro");585    }586  });587588  onMount(async () => {589    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([590      sendMessage({ type: "GET_LICENSE" }),591      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),592      sendMessage({ type: "IS_DURESS_ENABLED" }),593      sendMessage({ type: "GET_SETTINGS" }),594      sendMessage({ type: "GET_LICENSE_KEY" }),595    ]);596597    // Vault history loads in the background — the Vault sub-view picks it up598    // when the user navigates in. Loading it lazily keeps Settings boot fast.599    void loadVaultHistory();600601    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the602    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.603    void loadPendingVouched();604    void loadPairedDevices();605    void loadSyncState();606    const onBroadcast = (msg: unknown) => {607      const m = msg as { type?: string; payload?: unknown } | null;608      if (m?.type === "VOUCHED_DEVICE_PENDING") {609        void loadPendingVouched();610      }611      if (m?.type === "SYNC_STATE_CHANGED") {612        const payload = (m.payload ?? {}) as SyncOutcomeSummary;613        setSyncSummary(payload);614        setSyncStatus(payload.status ?? "idle");615      }616    };617    chrome.runtime?.onMessage?.addListener(onBroadcast);618    onCleanup(() => {619      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }620    });621622    if ((licenseRes as { ok: boolean }).ok) {623      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);624    }625626    if ((passkeyRes as { ok: boolean }).ok) {627      setPasskeyState(628        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null629      );630    }631632    if ((duressRes as { ok: boolean }).ok) {633      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);634    }635636    if ((settingsRes as { ok: boolean }).ok) {637      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;638      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);639    }640641    if ((keyRes as { ok: boolean }).ok) {642      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);643    }644645    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });646    if ((entriesRes as { ok: boolean }).ok) {647      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];648      setCanaryEntries(649        entries650          .filter((e) => e.canary?.webhookUrl)651          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))652      );653    }654655656    if (isNativePlatform()) {657      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");658      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");659      try {660        if (biometric) {661          const a = await biometric.isAvailable();662          setBiometricSupported(a.available);663        }664        if (keystore) {665          const k = await keystore.hasMasterKey();666          setBiometricStored(k.exists);667        }668      } catch { /* missing plugins — leave defaults */ }669    }670  });671672  async function handleEnableBiometrics() {673    setBiometricBusy(true);674    setBiometricError(null);675    try {676      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {677        ok: boolean;678        data?: { masterKeyHex: string };679        error?: string;680      };681      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {682        setBiometricError(keyRes.error === "vault_locked"683          ? "unlock the vault first"684          : "couldn't read derived key");685        return;686      }687      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");688      if (!keystore) {689        setBiometricError("biometric plugin unavailable");690        return;691      }692      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });693      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });694      setBiometricStored(true);695    } catch (e) {696      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");697    } finally {698      setBiometricBusy(false);699    }700  }701702  async function handleDisableBiometrics() {703    setBiometricBusy(true);704    setBiometricError(null);705    try {706      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");707      if (keystore) await keystore.clearMasterKey();708      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });709      setBiometricStored(false);710    } catch (e) {711      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");712    } finally {713      setBiometricBusy(false);714    }715  }716717  async function handleChangePassword() {718    setChangePwError(null);719    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }720    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }721    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }722    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }723    setChangePwSaving(true);724    const res = await sendMessage({725      type: "CHANGE_PASSWORD",726      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },727    }) as { ok: boolean; error?: string };728    if (res.ok) {729      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");730      setShowChangePw(false);731      // LV-4: the user just authenticated (current password), so the recovery732      // words reveal that follows is exempt from the re-auth gate. Set the733      // pending flag the same way the create/recovery flow does; the display734      // clears it on confirm.735      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });736      setShowNewRecoveryWords(true);737    } else {738      setChangePwError(res.error ?? "failed to change password");739    }740    setChangePwSaving(false);741  }742743  const [copied, setCopied] = createSignal(false);744745  async function copyLicenseKey(key: string) {746    await navigator.clipboard.writeText(key);747    setCopied(true);748    setTimeout(() => setCopied(false), 2000);749  }750751  async function handleActivateLicense() {752    const key = licenseKeyInput().trim();753    if (!key) return;754    setActivatingLicense(true);755    setLicenseError(null);756    setLicenseSuccess(null);757    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });758    setActivatingLicense(false);759    if ((res as { ok: boolean }).ok) {760      const data = (res as { ok: boolean; data?: LicenseInfo }).data;761      if (data?.valid) {762        setLicenseInfo(data);763        setLicenseKey(key);764        setLicenseSuccess("License activated!");765        setLicenseKeyInput("");766      } else {767        setLicenseError("Invalid license key");768      }769    } else {770      setLicenseError("Activation failed");771    }772  }773774  async function handleTogglePasskeys() {775    const current = passkeyState();776    if (!current || !current.supported) return;777    setPasskeySaving(true);778    const res = await sendMessage({779      type: "SET_PASSKEY_PROVIDER_ENABLED",780      payload: { enabled: !current.enabled },781    });782    setPasskeySaving(false);783    if ((res as { ok: boolean }).ok) {784      setPasskeyState(785        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null786      );787    }788  }789790  async function handleCheckAllBreaches() {791    setCheckingBreaches(true);792    setBreachResult(null);793    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });794    setCheckingBreaches(false);795    if ((res as { ok: boolean }).ok) {796      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};797      const breached = Object.values(data).filter((count) => count > 0).length;798      setBreachResult(799        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"800      );801      props.onBreachCheckDone();802    }803  }804805806  async function handleSetupDuress() {807    const mainPw = duressMainPassword().trim();808    const duressPw = duressPassword().trim();809    const confirmPw = duressConfirm().trim();810811    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }812    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }813    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }814    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }815816    setDuressSaving(true);817    setDuressError(null);818819    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };820    if (!verifyRes.ok || !verifyRes.data) {821      setDuressError("incorrect main password");822      setDuressSaving(false);823      return;824    }825826    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };827    setDuressSaving(false);828    if (res.ok) {829      setDuressEnabled(true);830      setShowDuressSetup(false);831      setDuressPassword("");832      setDuressConfirm("");833      setDuressMainPassword("");834      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");835      setTimeout(() => setDuressSuccess(null), 5000);836    } else {837      setDuressError(838        res.error === "session_expired"839          ? "Vault session expired — lock and unlock Peach, then try again"840          : res.error ?? "failed to set duress password"841      );842    }843  }844845  async function handleClearDeclinedCards() {846    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });847    setDeclinedCardCount(0);848  }849850  async function handleCountDuplicates() {851    setDedupRunning(true);852    setDedupDone(null);853    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };854    setDedupRunning(false);855    if (res.ok) setDedupCount(res.data ?? 0);856  }857858  async function handleDeduplicate() {859    setDedupRunning(true);860    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };861    setDedupRunning(false);862    if (res.ok) {863      setDedupDone(res.data ?? 0);864      setDedupCount(0);865    }866  }867868  async function handleDisableDuress() {869    const mainPw = duressMainPassword().trim();870    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }871872    setDuressSaving(true);873    setDuressError(null);874875    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };876    if (!verifyRes.ok || !verifyRes.data) {877      setDuressError("incorrect main password");878      setDuressSaving(false);879      return;880    }881882    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };883    setDuressSaving(false);884    if (res.ok) {885      setDuressEnabled(false);886      setShowDuressSetup(false);887      setDuressMainPassword("");888      setDuressSuccess("Duress password disabled.");889      setTimeout(() => setDuressSuccess(null), 4000);890    } else {891      setDuressError(res.error ?? "failed to disable duress password");892    }893  }894895  async function handleSubmitFeedback() {896    const message = sanitizeFeedbackMessage(fbMessage());897    if (!message) return;898899    setFeedbackState('sending');900901    try {902      const hasDataPermission = await requestFeedbackDataPermission();903      if (!hasDataPermission) {904        setFeedbackState('idle');905        return;906      }907908      const category = FEEDBACK_CATEGORIES.includes(fbCategory())909        ? fbCategory()910        : FEEDBACK_CATEGORIES[0];911      const res = await fetch('https://api.web3forms.com/submit', {912        method: 'POST',913        headers: { 'Content-Type': 'application/json' },914        body: JSON.stringify({915          access_key: WEB3FORMS_KEY,916          subject: `Peach Feedback - ${category}`,917          message,918          from_name: 'Peach User',919          // Honeypot — leave blank920          botcheck: '',921        }),922      });923924      if (!res.ok) throw new Error(`HTTP ${res.status}`);925      const data = await res.json() as { success: boolean; message?: string };926      if (!data.success) throw new Error(data.message ?? 'unknown error');927928      setFeedbackState('sent');929      setFbMessage('');930      setFbCategory(FEEDBACK_CATEGORIES[0]);931    } catch (err) {932      console.error('[feedback]', err);933      setFeedbackState('error');934      setTimeout(() => setFeedbackState('idle'), 4000);935    }936  }937938  function renderSecurity() {939    return (940      <div class="peach-settings-body">941        <div class="peach-settings-group">942          <p class="peach-settings-sub-label">LOCKING</p>943          <div class="peach-settings-row">944            <span>Lock on close</span>945            <button946              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}947              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}948              aria-label="Toggle lock on close"949            >950              <span class="peach-toggle-knob"></span>951            </button>952          </div>953          <div class="peach-settings-row">954            <span>Auto-lock after</span>955            <select956              class="peach-settings-select"957              value={String(props.autoLockMinutes)}958              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}959            >960              <option value="1">1 minute</option>961              <option value="5">5 minutes</option>962              <option value="15">15 minutes</option>963              <option value="30">30 minutes</option>964              <option value="0">Never</option>965            </select>966          </div>967968          <Show when={isNativePlatform() && biometricSupported()}>969            <p class="peach-settings-sub-label">BIOMETRICS</p>970            <div class="peach-settings-row">971              <span>Unlock with biometrics</span>972              <button973                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}974                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}975                disabled={biometricBusy()}976                aria-label="Toggle biometric unlock"977              >978                <span class="peach-toggle-knob"></span>979              </button>980            </div>981            <Show when={biometricError()}>982              <div class="peach-settings-row peach-settings-row--meta">983                <span class="peach-hint peach-hint-error">{biometricError()}</span>984              </div>985            </Show>986          </Show>987988          <p class="peach-settings-sub-label">BREACH PROTECTION</p>989          <div class="peach-settings-row">990            <span>Breach monitoring</span>991            <button992              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}993              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}994              aria-label="Toggle breach monitoring"995            >996              <span class="peach-toggle-knob"></span>997            </button>998          </div>999          <div class="peach-settings-row peach-settings-row--meta">1000            <span>Check all passwords</span>1001            <button1002              class="peach-btn peach-btn--subtle peach-btn--sm"1003              onClick={() => void handleCheckAllBreaches()}1004              disabled={checkingBreaches()}1005            >1006              {checkingBreaches() ? "checking..." : "run"}1007            </button>1008          </div>1009          <Show when={breachResult()}>1010            <div class="peach-settings-row peach-settings-row--meta">1011              <span>Last check</span>1012              <span class="peach-settings-value">{breachResult()}</span>1013            </div>1014          </Show>10151016          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1017          <div class="peach-settings-row peach-settings-row--meta">1018            <span>Master password</span>1019            <button1020              class="peach-btn peach-btn--subtle peach-btn--sm"1021              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1022            >1023              {showChangePw() ? "cancel" : "change"}1024            </button>1025          </div>1026          <Show when={showChangePw()}>1027            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1028              <input1029                class="peach-settings-input"1030                type="password"1031                placeholder="current password"1032                value={changePwCurrent()}1033                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1034                autocomplete="current-password"1035              />1036              <input1037                class="peach-settings-input"1038                type="password"1039                placeholder="new password (min 12 chars)"1040                value={changePwNew()}1041                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1042                autocomplete="new-password"1043              />1044              <input1045                class="peach-settings-input"1046                type="password"1047                placeholder="confirm new password"1048                value={changePwConfirm()}1049                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1050                autocomplete="new-password"1051                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1052              />1053              <Show when={changePwError()}>1054                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1055              </Show>1056              <Show when={changePwSuccess()}>1057                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1058              </Show>1059              <button1060                class="peach-btn peach-btn--accent peach-btn--sm"1061                style="align-self:flex-start"1062                onClick={() => void handleChangePassword()}1063                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1064              >1065                {changePwSaving() ? "saving…" : "save new password"}1066              </button>1067            </div>1068          </Show>10691070          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1071          <div class="peach-settings-row peach-settings-row--meta">1072            <span>Recovery words</span>1073            <button1074              class="peach-btn peach-btn--subtle peach-btn--sm"1075              onClick={() => {1076                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1077              }}1078            >1079              {showViewWords() ? "cancel" : "view"}1080            </button>1081          </div>1082          <Show when={showViewWords()}>1083            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1084              <Show1085                when={viewedWords()}1086                fallback={1087                  <>1088                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1089                      Enter your master password to reveal your recovery words.1090                    </p>1091                    <input1092                      class="peach-settings-input"1093                      type="password"1094                      placeholder="master password"1095                      value={viewWordsPassword()}1096                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1097                      autocomplete="current-password"1098                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1099                    />1100                    <Show when={viewWordsError()}>1101                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1102                    </Show>1103                    <button1104                      class="peach-btn peach-btn--accent peach-btn--sm"1105                      style="align-self:flex-start"1106                      onClick={() => void handleViewRecoveryWords()}1107                      disabled={viewWordsBusy() || !viewWordsPassword()}1108                    >1109                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1110                    </button>1111                  </>1112                }1113              >1114                <div class="peach-recovery-words-grid">1115                  <For each={viewedWords()!}>1116                    {(word, i) => (1117                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1118                    )}1119                  </For>1120                </div>1121                <button1122                  class="peach-btn peach-btn--subtle peach-btn--sm"1123                  style="align-self:flex-start"1124                  onClick={closeViewWords}1125                >done</button>1126              </Show>1127            </div>1128          </Show>11291130          <p class="peach-settings-sub-label">1131            DURESS PASSWORD1132            <Show when={!isPro()}>1133              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1134            </Show>1135          </p>1136          <div class="peach-settings-row peach-settings-row--top">1137            <div class="peach-settings-row-main">1138              <span>1139                Duress password1140                <Show when={!isPro()}>1141                  <span class="peach-pro-badge">Pro</span>1142                </Show>1143              </span>1144              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1145                If entered under coercion, shows a decoy empty vault instead of your real one.1146              </span>1147            </div>1148            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1149              {duressEnabled() ? "on" : "off"}1150            </div>1151          </div>11521153          <Show when={duressEnabled() && !showDuressSetup()}>1154            <div class="peach-settings-row peach-settings-row--meta">1155              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1156                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.1157              </span>1158            </div>1159          </Show>11601161          <Show when={duressSuccess()}>1162            <div class="peach-settings-row peach-settings-row--meta">1163              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1164            </div>1165          </Show>11661167          <Show when={!showDuressSetup()}>1168            <div class="peach-settings-row peach-settings-row--meta">1169              <button1170                class="peach-btn peach-btn--subtle peach-btn--sm"1171                onClick={() => {1172                  // Pro gate entry-point: free users are redirected to the1173                  // upgrade screen before the setup form opens. The server-1174                  // side SETUP_DURESS handler is the single enforcement1175                  // point; this is UI redirection so the form never appears1176                  // for a user who can't complete it.1177                  if (!isPro() && !duressEnabled()) {1178                    props.onProGate?.("duress_vault");1179                    setActiveCard("pro");1180                    return;1181                  }1182                  setShowDuressSetup(true);1183                  setDuressError(null);1184                  setDuressSuccess(null);1185                  setDuressPassword("");1186                  setDuressConfirm("");1187                  setDuressMainPassword("");1188                }}1189              >1190                {duressEnabled() ? "Change / disable" : "Set up"}1191              </button>1192            </div>1193          </Show>11941195          <Show when={showDuressSetup()}>1196            <div class="peach-duress-form">1197              <label class="peach-label">main password (to confirm)</label>1198              <input1199                class="peach-settings-input"1200                type="password"1201                placeholder="your current master password"1202                autocomplete="current-password"1203                value={duressMainPassword()}1204                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1205              />1206              <Show when={!duressEnabled()}>1207                <label class="peach-label">new duress password</label>1208                <input1209                  class="peach-settings-input"1210                  type="password"1211                  placeholder="duress password (min 8 chars)"1212                  autocomplete="new-password"1213                  value={duressPassword()}1214                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1215                />1216                <label class="peach-label">confirm duress password</label>1217                <input1218                  class="peach-settings-input"1219                  type="password"1220                  placeholder="confirm duress password"1221                  autocomplete="new-password"1222                  value={duressConfirm()}1223                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1224                />1225              </Show>12261227              <Show when={duressError()}>1228                <p class="peach-hint peach-hint-error">{duressError()}</p>1229              </Show>12301231              <div class="peach-duress-actions">1232                <button1233                  class="peach-btn peach-btn--subtle peach-btn--sm"1234                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1235                >1236                  cancel1237                </button>1238                <Show when={duressEnabled()}>1239                  <button1240                    class="peach-btn peach-btn--ghost peach-btn--sm"1241                    style="color:var(--peach-breach)"1242                    onClick={() => void handleDisableDuress()}1243                    disabled={duressSaving()}1244                  >1245                    {duressSaving() ? "disabling..." : "disable duress"}1246                  </button>1247                </Show>1248                <Show when={!duressEnabled()}>1249                  <button1250                    class="peach-btn peach-btn--accent peach-btn--sm"1251                    onClick={() => void handleSetupDuress()}1252                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1253                  >1254                    {duressSaving() ? "saving..." : "save"}1255                  </button>1256                </Show>1257              </div>1258            </div>1259          </Show>12601261          <p class="peach-settings-sub-label">1262            CANARY ENTRIES1263            <Show when={!isPro()}>1264              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1265            </Show>1266          </p>1267          <div class="peach-settings-row peach-settings-row--top">1268            <div class="peach-settings-row-main">1269              <span>1270                Canary entries1271                <Show when={!isPro()}>1272                  <span class="peach-pro-badge">Pro</span>1273                </Show>1274              </span>1275              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1276                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1277              </span>1278            </div>1279            <span class="peach-settings-value" style="flex-shrink:0">1280              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1281            </span>1282          </div>1283          <Show when={canaryEntries().length > 0}>1284            <For each={canaryEntries()}>1285              {(entry) => (1286                <div class="peach-settings-row peach-settings-row--meta">1287                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1288                  <button1289                    class="peach-btn peach-btn--subtle peach-btn--sm"1290                    onClick={() => void handleTestCanary(entry)}1291                    disabled={canaryTestingId() === entry.id}1292                    style="flex-shrink:0"1293                  >1294                    {canaryTestingId() === entry.id1295                      ? "sending…"1296                      : canaryTestResult()?.id === entry.id1297                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1298                        : "test"}1299                  </button>1300                </div>1301              )}1302            </For>1303          </Show>13041305          <p class="peach-settings-sub-label">PASSKEYS</p>1306          <div class="peach-settings-row peach-settings-row--top">1307            <div class="peach-settings-row-main">1308              <span>Intercept passkey requests</span>1309              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1310                Peach can handle passkey sign-in and creation on supported Chromium browsers.1311              </span>1312            </div>1313            <button1314              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1315              onClick={() => void handleTogglePasskeys()}1316              aria-label="Toggle passkey interception"1317              disabled={!passkeyState()?.supported || passkeySaving()}1318            >1319              <span class="peach-toggle-knob"></span>1320            </button>1321          </div>1322          <div class="peach-settings-row peach-settings-row--meta">1323            <span>Status</span>1324            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1325          </div>1326          <div class="peach-settings-row peach-settings-row--meta">1327            <span>Browser</span>1328            <span class="peach-settings-value">1329              {passkeyState()?.browserLabel ?? "Unknown"}1330              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1331            </span>1332          </div>1333          <div class="peach-info-notice" style="margin-top:8px">1334            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1335          </div>1336          <Show when={passkeyState()?.conflict}>1337            <div class="peach-settings-row peach-settings-row--meta">1338              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1339            </div>1340          </Show>1341        </div>1342      </div>1343    );1344  }13451346  function renderVault() {1347    return (1348      <div class="peach-settings-body">1349        <div class="peach-settings-group">1350          <p class="peach-settings-sub-label">ENTRIES</p>1351          <div class="peach-settings-row peach-settings-row--meta">1352            <span>Entries</span>1353            <span class="peach-settings-value">{props.entriesCount}</span>1354          </div>1355          <div class="peach-settings-row peach-settings-row--meta">1356            <span>Duplicates</span>1357            <Show when={dedupDone() !== null} fallback={1358              <button1359                class="peach-btn peach-btn--subtle peach-btn--sm"1360                onClick={() => void handleCountDuplicates()}1361                disabled={dedupRunning()}1362              >1363                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1364              </button>1365            }>1366              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1367                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1368              </span>1369            </Show>1370          </div>1371          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1372            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1373              <div class="peach-restore-warning">1374                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1375              </div>1376              <div style="display:flex;gap:6px">1377                <button1378                  class="peach-btn peach-btn--subtle peach-btn--sm"1379                  onClick={() => setDedupCount(null)}1380                >1381                  cancel1382                </button>1383                <button1384                  class="peach-btn peach-btn--accent peach-btn--sm"1385                  onClick={() => void handleDeduplicate()}1386                  disabled={dedupRunning()}1387                >1388                  {dedupRunning() ? "removing..." : "remove duplicates"}1389                </button>1390              </div>1391            </div>1392          </Show>13931394          <p class="peach-settings-sub-label">MAINTENANCE</p>1395          <div class="peach-settings-row peach-settings-row--meta">1396            <span>Trash auto-delete</span>1397            <select1398              class="peach-settings-select"1399              value={String(props.trashRetentionDays)}1400              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1401            >1402              <option value="7">7 days</option>1403              <option value="30">30 days</option>1404              <option value="90">90 days</option>1405              <option value="0">Never</option>1406            </select>1407          </div>1408          <div class="peach-settings-row peach-settings-row--meta">1409            <span>Advanced Vault Health</span>1410            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1411              open1412            </button>1413          </div>1414          <Show when={declinedCardCount() > 0}>1415            <div class="peach-settings-row peach-settings-row--meta">1416              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1417              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1418                Clear1419              </button>1420            </div>1421          </Show>14221423          <p class="peach-settings-sub-label">DATA</p>1424          <div class="peach-settings-row peach-settings-row--meta">1425            <span>Import passwords</span>1426            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1427              open1428            </button>1429          </div>1430          <div class="peach-settings-row peach-settings-row--meta">1431            <span>Export backup</span>1432            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1433              open1434            </button>1435          </div>1436          <div class="peach-settings-row peach-settings-row--meta">1437            <span>1438              Peach Codex1439              <Show when={!isPro()}>1440                <span class="peach-pro-badge">Pro</span>1441              </Show>1442            </span>1443            <button1444              class="peach-btn peach-btn--subtle peach-btn--sm"1445              onClick={() => props.onOpenCodexGenerate?.()}1446            >1447              open1448            </button>1449          </div>1450          {/* Restore from Codex: intentionally never gated — a printed1451              Codex must remain restorable regardless of license state. */}1452          <div class="peach-settings-row peach-settings-row--meta">1453            <span>Restore from Codex</span>1454            <button1455              class="peach-btn peach-btn--subtle peach-btn--sm"1456              onClick={() => props.onOpenCodexRestore?.()}1457            >1458              open1459            </button>1460          </div>14611462          <span class="peach-settings-sub-label">VAULT HISTORY</span>1463          <Show when={historyLoading()}>1464            <p class="peach-settings-hint">Loading…</p>1465          </Show>1466          <Show when={!historyLoading() && historySnapshots().length === 0}>1467            <p class="peach-settings-hint">1468              No snapshots yet. Peach saves automatically before every change —1469              saves, syncs, and imports.1470            </p>1471          </Show>1472          <Show when={!historyLoading() && historySnapshots().length > 0}>1473            <p class="peach-settings-hint">1474              Restoring locks your vault. Unlock with the password that1475              encrypted that snapshot to confirm.1476            </p>1477            <div class="peach-history-list">1478              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1479                {(snap) => (1480                  <div class="peach-history-row">1481                    <div class="peach-history-meta">1482                      <span class="peach-history-time">1483                        {formatSnapshotTime(snap.savedAt)}1484                      </span>1485                      <span class="peach-history-sub">1486                        {[1487                          snap.entryCount !== null1488                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1489                            : null,1490                          reasonLabel(snap.reason),1491                        ]1492                          .filter(Boolean)1493                          .join(" · ")}1494                      </span>1495                    </div>1496                    <button1497                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1498                        historyRestoring() === snap.id ? " is-busy" : ""1499                      }`}1500                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1501                      disabled={historyRestoring() !== null}1502                    >1503                      <Show when={historyRestoring() === snap.id} fallback="Restore">1504                        …1505                      </Show>1506                    </button>1507                  </div>1508                )}1509              </For>1510            </div>1511            <Show when={historySnapshots().length > 3}>1512              <button1513                class="peach-settings-advanced-toggle"1514                onClick={() => setHistoryShowAll((v) => !v)}1515                aria-expanded={historyShowAll()}1516              >1517                <span class="peach-settings-hint" style="margin:0">1518                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1519                </span>1520                <span class="peach-settings-advanced-chevron">1521                  {historyShowAll() ? "▲" : "▼"}1522                </span>1523              </button>1524            </Show>1525            <p class="peach-history-footer">1526              {historySnapshots().length} of 10 snapshots1527            </p>1528          </Show>15291530          <button1531            class="peach-settings-advanced-toggle"1532            onClick={() => setShowVaultAdvanced((v) => !v)}1533            aria-expanded={showVaultAdvanced()}1534          >1535            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1536            <span class="peach-settings-advanced-chevron">1537              {showVaultAdvanced() ? "▲" : "▼"}1538            </span>1539          </button>15401541          <Show when={showVaultAdvanced()}>1542            <div class="peach-danger-card">1543              <div class="peach-danger-card-head">1544                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1545                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1546              </div>1547              <p class="peach-danger-card-copy">1548                Wipes the current vault and restores from a backup file.1549                Recoverable only if your backup is good.1550              </p>1551              <button1552                class="peach-danger-card-button"1553                onClick={() => props.onOpenFileImport?.()}1554              >1555                Choose backup file1556              </button>1557            </div>15581559            <Show when={props.onOpenPanic}>1560              <div class="peach-danger-card peach-danger-card--hazard">1561                <div class="peach-danger-card-head">1562                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1563                  <h3 class="peach-danger-card-title">Destroy vault</h3>1564                </div>1565                <p class="peach-danger-card-copy">1566                  Permanently wipes every entry, setting, and license on this1567                  device. There is no recovery.1568                </p>1569                <button1570                  class="peach-danger-card-button"1571                  onClick={() => props.onOpenPanic?.()}1572                >1573                  Begin destruction1574                </button>1575              </div>1576            </Show>1577          </Show>1578        </div>1579      </div>1580    );1581  }15821583  function renderSync() {1584    return (1585      <div class="peach-settings-body">1586        <div class="peach-settings-group">1587          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1588          <div1589            class="peach-settings-row peach-settings-row--meta"1590            style="align-items:center;gap:8px"1591            role="status"1592            aria-label={syncLightLabel()}1593            title={syncLightLabel()}1594          >1595            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1596            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1597              {syncFlash() ?? lastSyncLine()}1598            </span>1599          </div>1600          <Show when={pairedDevices().length > 0}>1601            <div class="peach-settings-row peach-settings-row--meta">1602              <span class="peach-settings-value" style="color:var(--peach-t2)">1603                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01604                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1605                  : ""}1606              </span>1607              <button1608                class="peach-btn peach-btn--subtle peach-btn--sm"1609                aria-label="Sync this vault now"1610                disabled={syncStatus() === "syncing"}1611                onClick={() => void syncNow()}1612              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1613            </div>1614          </Show>16151616          <p class="peach-settings-sub-label">ADD A DEVICE</p>1617          <button1618            class="peach-sync-pair-option"1619            onClick={() => props.onOpenSyncPairing?.("code")}1620          >1621            <span class="peach-sync-pair-option-icon" aria-hidden="true">1622              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1623                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1624                <path d="M9 15 L11 15" />1625              </svg>1626            </span>1627            <span class="peach-sync-pair-option-main">1628              <span class="peach-sync-pair-option-title">Show a code</span>1629              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1630            </span>1631            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1632          </button>1633          <Show when={!isPro()}>1634            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1635              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1636            </p>1637          </Show>16381639          <Show when={pairedDevices().length > 0}>1640            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1641            <For each={pairedDevices()}>1642              {(d) => (1643                <div class="peach-settings-row peach-settings-row--top">1644                  <div class="peach-settings-row-main">1645                    <Show1646                      when={renameDeviceId() === d.deviceId}1647                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1648                    >1649                      <input1650                        class="peach-settings-input"1651                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1652                        maxlength={64}1653                        value={renameDraft()}1654                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1655                        onKeyDown={(event) => {1656                          if (event.key === "Enter") void renameDevice(d.deviceId);1657                          if (event.key === "Escape") setRenameDeviceId(null);1658                        }}1659                      />1660                    </Show>1661                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1662                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1663                    </span>1664                  </div>1665                  <Show1666                    when={confirmUnpair() === d.deviceId}1667                    fallback={1668                      <div style="display:flex;gap:4px;flex-shrink:0">1669                        <Show1670                          when={renameDeviceId() === d.deviceId}1671                          fallback={1672                            <button1673                              class="peach-btn peach-btn--subtle peach-btn--sm"1674                              onClick={() => {1675                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1676                                setRenameDeviceId(d.deviceId);1677                              }}1678                            >rename</button>1679                          }1680                        >1681                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1682                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1683                        </Show>1684                        <button1685                          class="peach-btn peach-btn--subtle peach-btn--sm"1686                          onClick={() => setConfirmUnpair(d.deviceId)}1687                        >remove</button>1688                      </div>1689                    }1690                  >1691                    {/* HOLE 4: revocation stops this device receiving future1692                        changes. It does NOT secure the vault against it — that1693                        device may already hold the recovery words, which are1694                        master-password-equivalent and restore the whole vault1695                        anywhere, forever, with no sync involved. Say so here,1696                        rather than leaving the user falsely reassured. */}1697                    <div class="peach-unpair-confirm">1698                      <p class="peach-unpair-confirm-note">1699                        Stops this device receiving future changes. It doesn't1700                        lock it out of what it already has — if it's lost or1701                        stolen, also change your master password and generate1702                        new recovery words.1703                      </p>1704                      <div style="display:flex;gap:6px;justify-content:flex-end">1705                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1706                        <button1707                          class="peach-btn peach-btn--sm"1708                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1709                          onClick={() => void unpairDevice(d.deviceId)}1710                        >remove</button>1711                      </div>1712                    </div>1713                  </Show>1714                </div>1715              )}1716            </For>1717          </Show>17181719          <Show when={pendingVouched().length > 0}>1720            <p class="peach-settings-sub-label">1721              PENDING DEVICES1722              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1723            </p>1724            <For each={pendingVouched()}>1725              {(dev) => (1726                <div class="peach-settings-row peach-settings-row--top">1727                  <div class="peach-settings-row-main">1728                    <span>{dev.deviceName}</span>1729                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1730                      Was added by another of your devices. Allow it to sync with this vault?1731                    </span>1732                  </div>1733                  <div style="display:flex;gap:6px;flex-shrink:0">1734                    <button1735                      class="peach-btn peach-btn--sm"1736                      disabled={vouchedBusy() === dev.deviceId}1737                      onClick={() => void confirmVouched(dev.deviceId)}1738                    >Allow</button>1739                    <button1740                      class="peach-btn peach-btn--subtle peach-btn--sm"1741                      disabled={vouchedBusy() === dev.deviceId}1742                      onClick={() => void rejectVouched(dev.deviceId)}1743                    >Reject</button>1744                  </div>1745                </div>1746              )}1747            </For>1748          </Show>17491750          <button1751            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1752            onClick={() => setShowSyncConnection((value) => !value)}1753            aria-expanded={showSyncConnection()}1754            aria-controls="peach-sync-connection"1755          >1756            <span>1757              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1758              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1759            </span>1760            <span class="peach-settings-advanced-chevron">1761              {showSyncConnection() ? "▲" : "▼"}1762            </span>1763          </button>1764          <Show when={showSyncConnection()}>1765            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1766              <SyncModeSelector />1767            </div>1768          </Show>17691770          <button1771            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1772            onClick={() => setShowSyncRecovery((value) => !value)}1773            aria-expanded={showSyncRecovery()}1774            aria-controls="peach-sync-recovery"1775          >1776            <span>1777              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1778              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1779            </span>1780            <span class="peach-settings-advanced-chevron">1781              {showSyncRecovery() ? "▲" : "▼"}1782            </span>1783          </button>1784          <Show when={showSyncRecovery()}>1785            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1786              <Show1787                when={rotateConfirmOpen()}1788                fallback={1789                  <div class="peach-settings-row peach-settings-row--top">1790                    <div class="peach-settings-row-main">1791                      <span>Rotate sync identity</span>1792                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1793                        Disconnects every device and replaces the keys used for sync.1794                      </span>1795                    </div>1796                    <button1797                      class="peach-btn peach-btn--subtle peach-btn--sm"1798                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1799                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1800                    >rotate</button>1801                  </div>1802                }1803              >1804                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1805                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1806                  <Show when={rotateError()}>1807                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1808                  </Show>1809                  <div style="display:flex;gap:6px;margin-top:10px">1810                    <button1811                      class="peach-btn peach-btn--subtle peach-btn--sm"1812                      disabled={rotating()}1813                      onClick={() => setRotateConfirmOpen(false)}1814                    >Cancel</button>1815                    <button1816                      class="peach-btn peach-btn--sm"1817                      style="background:var(--peach-breach);border-color:var(--peach-breach)"1818                      disabled={rotating()}1819                      onClick={() => void doRotateSyncIdentity()}1820                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1821                  </div>1822                </div>1823              </Show>1824            </div>1825          </Show>1826        </div>1827      </div>1828    );1829  }18301831  function renderAppearance() {1832    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1833      { id: "medium", label: "Default" },1834      { id: "large", label: "Large" },1835    ];1836    return (1837      <div class="peach-settings-body">1838        <div class="peach-settings-group">1839          <p class="peach-settings-sub-label">THEME</p>1840          <div class="peach-settings-row peach-settings-row--meta">1841            <span>Theme</span>1842            <button1843              class="peach-btn peach-btn--subtle peach-btn--sm"1844              onClick={() =>1845                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1846              }1847            >1848              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1849            </button>1850          </div>18511852          <p class="peach-settings-sub-label">ACCENT</p>1853          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1854            <span>Accent</span>1855            <div class="peach-accent-picker">1856              <For each={ACCENT_COLORS}>1857                {(color) => (1858                  <button1859                    class="peach-accent-swatch peach-swatch"1860                    classList={{ "is-active": props.accentColor === color.name }}1861                    style={{ background: color.hex }}1862                    onClick={() => void props.onAccentChange(color.name)}1863                    aria-label={color.name}1864                    title={color.name}1865                  />1866                )}1867              </For>1868            </div>1869          </div>18701871          <p class="peach-settings-sub-label">TEXT SIZE</p>1872          <div class="peach-settings-row peach-settings-row--meta">1873            <span>Size</span>1874            <div class="peach-pill-group">1875              <For each={sizeOptions}>1876                {(opt) => (1877                  <button1878                    class="peach-btn peach-btn--ghost peach-btn--sm"1879                    classList={{ "is-active": props.textSize === opt.id }}1880                    onClick={() => void props.onTextSizeChange(opt.id)}1881                  >1882                    {opt.label}1883                  </button>1884                )}1885              </For>1886            </div>1887          </div>18881889          <p class="peach-settings-sub-label">MOTION</p>1890          <div class="peach-settings-row peach-settings-row--meta">1891            <span>Reduce motion</span>1892            <button1893              class="peach-btn peach-btn--subtle peach-btn--sm"1894              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1895            >1896              {props.reduceMotion ? "On" : "Off"}1897            </button>1898          </div>1899        </div>1900      </div>1901    );1902  }19031904  function renderShortcuts() {1905    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1906    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);19071908    function startCapture(action: ShortcutAction) {1909      setConflictMsg(null);1910      setCapturingAction(action);1911    }1912    function cancelCapture() {1913      setCapturingAction(null);1914      setConflictMsg(null);1915    }1916    function commitBinding(action: ShortcutAction, binding: Keybinding) {1917      const conflict = findConflict(props.shortcuts, binding, action);1918      if (conflict) {1919        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1920        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1921        return;1922      }1923      const next = { ...props.shortcuts, [action]: binding };1924      setCapturingAction(null);1925      setConflictMsg(null);1926      void props.onShortcutsChange(next);1927    }1928    function resetAction(action: ShortcutAction) {1929      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1930      void props.onShortcutsChange(next);1931    }1932    function resetAll() {1933      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1934    }19351936    // Capture-mode key handler — wired via ref onto the chip element so it1937    // catches the next non-modifier keypress and ignores other bindings.1938    function handleCaptureKey(e: KeyboardEvent) {1939      e.preventDefault();1940      e.stopPropagation();1941      if (e.key === "Escape") { cancelCapture(); return; }1942      const binding = keybindingFromEvent(e);1943      if (!binding) return; // bare modifier — keep waiting1944      const action = capturingAction();1945      if (action) commitBinding(action, binding);1946    }19471948    return (1949      <div class="peach-settings-body">1950        <div class="peach-settings-group peach-shortcuts-group">1951          <p class="peach-settings-sub-label">ENTRY VIEW</p>1952          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1953            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1954          </For>1955        </div>19561957        <div class="peach-settings-group peach-shortcuts-group">1958          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1959          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1960            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1961          </For>1962        </div>19631964        <Show when={conflictMsg()}>1965          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1966        </Show>19671968        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1969          <div class="peach-settings-row peach-settings-row--meta">1970            <span>Inline autofill on page</span>1971            <a1972              class="peach-settings-link"1973              href="chrome://extensions/shortcuts"1974              onClick={(e) => {1975                e.preventDefault();1976                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1977              }}1978            >1979              ⌘⇧P — customize ↗1980            </a>1981          </div>1982          <button1983            class="peach-btn peach-btn--subtle peach-btn--sm"1984            style="align-self:flex-start"1985            onClick={resetAll}1986          >1987            Reset all to defaults1988          </button>1989        </div>1990      </div>1991    );19921993    function renderRow(1994      desc: typeof SHORTCUT_DESCRIPTORS[number],1995      capturing: () => ShortcutAction | null,1996      onStart: (a: ShortcutAction) => void,1997      onCancel: () => void,1998      onReset: (a: ShortcutAction) => void,1999      onKey: (e: KeyboardEvent) => void,2000    ) {2001      const isActive = () => capturing() === desc.action;2002      const binding = () => props.shortcuts[desc.action];2003      const isCustom = () => {2004        const b = binding();2005        const d = DEFAULT_BINDINGS[desc.action];2006        return b.key !== d.key2007          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2008          || Boolean(b.meta)  !== Boolean(d.meta)2009          || Boolean(b.shift) !== Boolean(d.shift)2010          || Boolean(b.alt)   !== Boolean(d.alt);2011      };2012      return (2013        <div class="peach-shortcut-row">2014          <span class="peach-shortcut-row-label">{desc.label}</span>2015          <button2016            class="peach-shortcut-chip"2017            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2018            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2019            onKeyDown={(e) => { if (isActive()) onKey(e); }}2020            ref={(el) => {2021              // Auto-focus when entering capture mode so the keydown listener catches the next press.2022              if (el) {2023                queueMicrotask(() => { if (isActive()) el.focus(); });2024              }2025            }}2026          >2027            {isActive() ? "Press a key…" : formatKeybinding(binding())}2028          </button>2029          <Show when={isCustom() && !isActive()}>2030            <button2031              class="peach-shortcut-reset"2032              title="Reset to default"2033              onClick={() => onReset(desc.action)}2034            >2035              ↺2036            </button>2037          </Show>2038        </div>2039      );2040    }2041  }20422043  const PRO_FEATURES: Array<{2044    feature: ProFeature;2045    name: string;2046    desc: string;2047  }> = [2048    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2049    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2050    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2051    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2052  ];20532054  const PRO_SECURITY_FEATURES: Array<{2055    feature: ProFeature;2056    name: string;2057    desc: string;2058  }> = [2059    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2060    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2061  ];20622063  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2064  const [showKey, setShowKey] = createSignal(false);20652066  const [deactivating, setDeactivating] = createSignal(false);20672068  async function handleDeactivateLicense() {2069    setDeactivating(true);2070    await sendMessage({ type: "DEACTIVATE_LICENSE" });2071    setLicenseInfo(null);2072    await props.onLicenseChange?.();2073    setDeactivating(false);2074  }20752076  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2077    setCanaryTestingId(entry.id);2078    setCanaryTestResult(null);2079    const res = await sendMessage({2080      type: "FIRE_CANARY_PING",2081      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2082    }) as { ok: boolean };2083    setCanaryTestResult({ id: entry.id, ok: res.ok });2084    setCanaryTestingId(null);2085    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2086  }20872088  function renderLicense() {2089    async function onActivate() {2090      await handleActivateLicense();2091      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2092        await props.onLicenseChange?.();2093      }2094    }20952096    return (2097      <div class="peach-settings-body">2098        <Show2099          when={isPro()}2100          fallback={2101                <div class="peach-pro-upgrade">2102                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>21032104                  <h2 class="peach-pro-headline">2105                    Every<em>thing,</em><br />every<em>where.</em>2106                  </h2>21072108                  <p class="peach-pro-subtitle">2109                    One key unlocks every Pro feature — for the lifetime of the app.2110                  </p>21112112                  <div class="peach-pro-grid">2113                    <For each={PRO_FEATURES}>2114                      {(item, i) => (2115                        <div2116                          class="peach-pro-grid-item"2117                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2118                        >2119                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2120                          <span class="peach-pro-grid-name">{item.name}</span>2121                          <span class="peach-pro-grid-desc">{item.desc}</span>2122                        </div>2123                      )}2124                    </For>2125                  </div>21262127                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2128                  <div class="peach-pro-grid">2129                    <For each={PRO_SECURITY_FEATURES}>2130                      {(item, i) => (2131                        <div2132                          class="peach-pro-grid-item"2133                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2134                        >2135                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2136                          <span class="peach-pro-grid-name">{item.name}</span>2137                          <span class="peach-pro-grid-desc">{item.desc}</span>2138                        </div>2139                      )}2140                    </For>2141                  </div>21422143                  <div class="peach-pro-upgrade-actions">2144                    <p class="peach-pro-pricing">2145                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2146                    </p>2147                    <a2148                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2149                      target="_blank"2150                      rel="noopener noreferrer"2151                      class="peach-pro-upgrade-cta"2152                    >2153                      Get Peach Pro2154                    </a>2155                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2156                    <div class="peach-pro-key-inline">2157                      <input2158                        class="peach-pro-key-inline-input"2159                        type="text"2160                        placeholder="PEACH-XXXX-XXXX-XXXX"2161                        value={licenseKeyInput()}2162                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2163                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2164                        autocomplete="off"2165                        spellcheck={false}2166                      />2167                      <button2168                        class="peach-pro-key-apply"2169                        onClick={() => void onActivate()}2170                        disabled={activatingLicense() || !licenseKeyInput().trim()}2171                      >2172                        {activatingLicense() ? "…" : "APPLY"}2173                      </button>2174                    </div>2175                    <Show when={licenseError()}>2176                      <p class="peach-pro-key-error">{licenseError()}</p>2177                    </Show>2178                  </div>2179                </div>2180          }2181        >2182          <div class="peach-pro-active">2183            <div class="peach-pro-active-hero">2184              <button2185                class="peach-pro-active-mark-wrap"2186                type="button"2187                aria-label="Celebrate"2188                onClick={handleProCelebrationClick}2189              >2190                <PeachMark class="peach-pro-active-mark" />2191                <Show when={justActivated()}>2192                  <div class="peach-pro-sparks" aria-hidden="true">2193                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2194                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2195                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2196                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2197                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2198                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2199                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2200                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2201                  </div>2202                </Show>2203              </button>2204              <h2 class="peach-pro-active-title">Peach Pro</h2>2205              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2206              <Show when={justActivated()}>2207                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2208              </Show>2209            </div>22102211            <Show when={licenseKey()}>2212              <div class="peach-settings-license">2213                <span class="peach-settings-label">LICENSE KEY</span>2214                <div class="peach-settings-license-row">2215                  <Show2216                    when={showKey()}2217                    fallback={2218                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2219                        ••••••••••••••••••••••••••••2220                      </span>2221                    }2222                  >2223                    <span class="peach-settings-license-key">{licenseKey()!}</span>2224                  </Show>2225                  <div class="peach-settings-license-actions">2226                    <button2227                      class="peach-settings-license-copy"2228                      title="Copy license key"2229                      onClick={() => copyLicenseKey(licenseKey()!)}2230                    >2231                      {copied() ? "✓" : "copy"}2232                    </button>2233                    <button2234                      class="peach-settings-license-toggle"2235                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2236                      title={showKey() ? "Hide" : "Reveal"}2237                      onClick={() => setShowKey(v => !v)}2238                    >2239                      <Show2240                        when={showKey()}2241                        fallback={2242                          <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">2243                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2244                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2245                          </svg>2246                        }2247                      >2248                        <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">2249                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2250                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2251                          <line x1="2" y1="2" x2="12" y2="12" />2252                        </svg>2253                      </Show>2254                    </button>2255                  </div>2256                </div>2257              </div>2258            </Show>22592260            <div class="peach-pro-active-features">2261              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2262                {(item) => (2263                  <div class="peach-pro-active-feature">2264                    <span class="peach-pro-active-feature-mark">✦</span>2265                    <span class="peach-pro-active-feature-name">{item.name}</span>2266                  </div>2267                )}2268              </For>2269            </div>22702271            <button2272              class="peach-pro-deactivate"2273              onClick={() => void handleDeactivateLicense()}2274              disabled={deactivating()}2275            >2276              {deactivating() ? "Deactivating…" : "Deactivate"}2277            </button>2278          </div>2279        </Show>2280      </div>2281    );2282  }22832284  function renderFeedback() {2285    return (2286      <div class="peach-settings-body">2287        <div class="peach-settings-group peach-feedback-group">2288          <Show2289            when={feedbackState() === 'sent'}2290            fallback={2291              <>2292                <p class="peach-feedback-intro">2293                  Help us improve Peach. Every report goes directly to our inbox.2294                </p>22952296                <p class="peach-settings-sub-label">TYPE</p>2297                <div class="peach-feedback-chips">2298                  <For each={FEEDBACK_CATEGORIES}>2299                    {(cat) => {2300                      const selected = () => fbCategory() === cat;2301                      return (2302                        <button2303                          type="button"2304                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2305                          aria-pressed={selected()}2306                          onClick={() => setFbCategory(cat)}2307                        >2308                          {cat}2309                        </button>2310                      );2311                    }}2312                  </For>2313                </div>23142315                <p class="peach-settings-sub-label">MESSAGE</p>2316                <textarea2317                  class="peach-feedback-textarea"2318                  placeholder="Describe the issue or idea..."2319                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2320                  value={fbMessage()}2321                  onInput={(e) => setFbMessage(e.currentTarget.value)}2322                />23232324                <p class="peach-feedback-meta">2325                  Version {version} · will be included automatically2326                </p>23272328                <button2329                  type="button"2330                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2331                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2332                  onClick={() => void handleSubmitFeedback()}2333                >2334                  <Switch>2335                    <Match when={feedbackState() === 'idle'}>Send</Match>2336                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2337                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2338                  </Switch>2339                </button>23402341                <p class="peach-feedback-anon">2342                  Sent anonymously · no account required2343                </p>2344              </>2345            }2346          >2347            <div class="peach-feedback-thanks" role="status" aria-live="polite">2348              <p class="peach-feedback-thanks-title">Thank you</p>2349              <p>2350                Your note is in the queue. Someone will process it soon.2351              </p>2352            </div>2353          </Show>2354        </div>2355      </div>2356    );2357  }23582359  const bodies: StackBodies = {2360    protection: renderSecurity(),2361    vault: renderVault(),2362    sync: renderSync(),2363    appearance: renderAppearance(),2364    shortcuts: renderShortcuts(),2365    pro: renderLicense(),2366    feedback: renderFeedback(),2367  };23682369  return (2370    <Show2371      when={showNewRecoveryWords()}2372      fallback={2373        <div2374          class="peach-overlay-view peach-settings-overlay"2375          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2376          style="display:flex;flex-direction:column"2377        >2378          <StackDeck2379            active={activeCard()}2380            onActiveChange={setActiveCard}2381            onBack={props.onBack}2382            cards={cards()}2383            bodies={bodies}2384            version={version}2385            isFanning={isFanning()}2386            celebratingCardId={justActivated() ? "pro" : null}2387            isPro={isPro()}2388          />2389        </div>2390      }2391    >2392      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2393    </Show>2394  );2395}