1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10 AccentColorName,11 PasskeyProviderState,12 ProFeature,13 VaultSnapshot,14} from "../../shared/types";15import {16 DEFAULT_BINDINGS,17 findConflict,18 formatKeybinding,19 keybindingFromEvent,20 SHORTCUT_DESCRIPTORS,21 type Keybinding,22 type ShortcutAction,23} from "../../shared/shortcuts";24import { isNativePlatform, nativePlugin } from "../../shared/native";25import { WEB3FORMS_KEY } from "../../shared/config";2627interface LicenseInfo {28 valid: boolean;29 plan: "free" | "pro";30 issued?: string;31}3233interface SettingsProps {34 entriesCount: number;35 breachMonitoring: boolean;36 lockOnClose: boolean;37 autoLockMinutes: number;38 trashRetentionDays: number;39 theme: "dark" | "light";40 accentColor: AccentColorName;41 textSize: "small" | "medium" | "large";42 reduceMotion: boolean;43 isPro?: boolean;44 initialSection?: string | null;45 upgradeHighlight?: ProFeature | null;46 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;47 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;48 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;49 onTrashRetentionChange: (days: number) => void | Promise<void>;50 onThemeChange: (theme: "dark" | "light") => void;51 onAccentChange: (name: AccentColorName) => void | Promise<void>;52 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;53 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;54 shortcuts: Record<ShortcutAction, Keybinding>;55 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;56 onLicenseChange?: () => void | Promise<void>;57 isExiting?: boolean;58 onBack: () => void;59 onImport: () => void;60 onExport: () => void;61 onBreachCheckDone: () => void;62 onOpenHealth?: () => void;63 onOpenSyncPairing?: () => void;64 onOpenFileImport?: () => void;65 onOpenPanic?: () => void;66 onOpenCodexGenerate?: () => void;67 onOpenCodexRestore?: () => void;68 697071 onProGate?: (feature: ProFeature) => void;72 onCardChange?: (id: CardId) => void;73}7475const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];76const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";77const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;78const PRO_CELEBRATION_CARD_STEP_MS = 150;79const PRO_CELEBRATION_THANK_YOU_MS = 3200;8081function sanitizeFeedbackMessage(value: string): string {82 return value83 .trim()84 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)85 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")86 .replace(/&/g, "&")87 .replace(/</g, "<")88 .replace(/>/g, ">")89 .replace(/"/g, """)90 .replace(/'/g, "'");91}9293type DataCollectionPermissionApi = {94 permissions?: {95 getAll?: () => Promise<{ data_collection?: string[] }>;96 request?: (permissions: { data_collection: string[] }) => Promise<boolean>;97 };98};99100async function requestFeedbackDataPermission(): Promise<boolean> {101 const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??102 (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;103 if (!api?.permissions?.getAll) return true;104105 try {106 const permissions = await api.permissions.getAll();107 if (!("data_collection" in permissions)) return true;108 if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;109 return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));110 } catch {111 return false;112 }113}114115function formatActivatedSince(issued: string | undefined): string {116 if (!issued) return "Active";117 try {118 const d = new Date(issued);119 if (Number.isNaN(d.getTime())) return "Active";120 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;121 } catch {122 return "Active";123 }124}125126function initialSectionToCard(value: string | null | undefined): CardId | null {127 if (!value) return null;128 if (value === "security") return "protection";129 if (value === "license") return "pro";130 if (131 value === "protection" ||132 value === "vault" ||133 value === "sync" ||134 value === "appearance" ||135 value === "shortcuts" ||136 value === "pro" ||137 value === "feedback"138 ) {139 return value;140 }141 return null;142}143144export function SettingsView(props: SettingsProps) {145 const manifest = chrome.runtime.getManifest();146 const version = manifest.version;147 const initial = props.initialSection;148 const [activeCard, setActiveCard] = createSignal<CardId>(149 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"150 );151152 createEffect(() => { props.onCardChange?.(activeCard()); });153154 const [justActivated, setJustActivated] = createSignal(false);155 const [isFanning, setIsFanning] = createSignal(false);156157 function triggerCelebration() {158 if (isFanning()) return;159 setJustActivated(false);160 setIsFanning(true);161 setActiveCard("pro");162 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];163 fanOrder.forEach((id, i) => {164 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);165 });166 window.setTimeout(() => {167 setActiveCard("pro");168 setIsFanning(false);169 setJustActivated(true);170 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);171 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);172 }173174 function handleProCelebrationClick(e: MouseEvent) {175 e.stopPropagation();176 triggerCelebration();177 }178179 const [checkingBreaches, setCheckingBreaches] = createSignal(false);180 const [breachResult, setBreachResult] = createSignal<string | null>(null);181 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);182 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");183 const [licenseError, setLicenseError] = createSignal<string | null>(null);184 const [, setLicenseSuccess] = createSignal<string | null>(null);185 const [activatingLicense, setActivatingLicense] = createSignal(false);186187 const isPro = () => props.isPro ?? false;188 const cards = createMemo<StackCardSpec[]>(() => [189 {190 id: "pro",191 num: "I · FOREVER",192 title: "Peach Pro",193 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",194 bgVar: "--stack-pro-bg",195 fgVar: "--stack-pro-fg",196 icon: "spark",197 onIconClick: isPro() ? triggerCelebration : undefined,198 },199 {200 id: "protection",201 num: "II · PROTECT",202 title: "Protection",203 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,204 bgVar: "--stack-protect-bg",205 fgVar: "--stack-protect-fg",206 icon: "shield",207 },208 {209 id: "vault",210 num: "III · KEEP",211 title: "Vault",212 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,213 bgVar: "--stack-vault-bg",214 fgVar: "--stack-vault-fg",215 icon: "lock",216 },217 {218 id: "sync",219 num: "IV · ECHO",220 title: "Sync",221 lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",222 bgVar: "--stack-sync-bg",223 fgVar: "--stack-sync-fg",224 icon: "sync",225 },226 {227 id: "appearance",228 num: "V · DRESS",229 title: "Appearance",230 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,231 bgVar: "--stack-appear-bg",232 fgVar: "--stack-appear-fg",233 icon: "sun",234 },235 {236 id: "shortcuts",237 num: "VI · KEYS",238 title: "Shortcuts",239 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,240 bgVar: "--stack-shortcut-bg",241 fgVar: "--stack-shortcut-fg",242 icon: "keys",243 },244 {245 id: "feedback",246 num: "VII · ECHO",247 title: "Feedback",248 lede: `version ${version} · tell us what would make it better`,249 bgVar: "--stack-feedback-bg",250 fgVar: "--stack-feedback-fg",251 icon: "bubble",252 },253 ]);254 const [biometricSupported, setBiometricSupported] = createSignal(false);255 const [biometricStored, setBiometricStored] = createSignal(false);256 const [biometricBusy, setBiometricBusy] = createSignal(false);257 const [biometricError, setBiometricError] = createSignal<string | null>(null);258 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);259 const [passkeySaving, setPasskeySaving] = createSignal(false);260261 262 263 interface PendingVouched { deviceId: string; deviceName: string }264 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);265 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);266 267 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);268 const [rotating, setRotating] = createSignal(false);269 const [rotateError, setRotateError] = createSignal<string | null>(null);270271 async function loadPendingVouched() {272 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {273 ok: boolean;274 data?: PendingVouched[];275 };276 if (r.ok) setPendingVouched(r.data ?? []);277 }278 async function confirmVouched(deviceId: string) {279 setVouchedBusy(deviceId);280 try {281 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });282 await loadPendingVouched();283 } finally {284 setVouchedBusy(null);285 }286 }287 async function rejectVouched(deviceId: string) {288 setVouchedBusy(deviceId);289 try {290 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });291 await loadPendingVouched();292 } finally {293 setVouchedBusy(null);294 }295 }296 async function doRotateSyncIdentity() {297 setRotateError(null);298 setRotating(true);299 try {300 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {301 ok: boolean;302 error?: string;303 };304 if (!r.ok) {305 setRotateError(r.error ?? "Rotation failed.");306 return;307 }308 setRotateConfirmOpen(false);309 310 311 setShowNewRecoveryWords(true);312 } finally {313 setRotating(false);314 }315 }316317 const [dedupCount, setDedupCount] = createSignal<number | null>(null);318 const [dedupRunning, setDedupRunning] = createSignal(false);319 const [dedupDone, setDedupDone] = createSignal<number | null>(null);320321 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);322323 324 325 326 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);327 const [historyLoading, setHistoryLoading] = createSignal(false);328 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);329 const [historyShowAll, setHistoryShowAll] = createSignal(false);330331 async function loadVaultHistory() {332 setHistoryLoading(true);333 try {334 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });335 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];336 setHistorySnapshots(list);337 } finally {338 setHistoryLoading(false);339 }340 }341342 async function handleRestoreVaultSnapshot(id: string) {343 if (historyRestoring()) return;344 setHistoryRestoring(id);345 const res = await sendMessage({346 type: "RESTORE_VAULT_SNAPSHOT",347 payload: { snapshotId: id },348 });349 setHistoryRestoring(null);350 if ((res as { ok: boolean }).ok) {351 352 353 props.onBack();354 }355 }356357 function formatSnapshotTime(savedAt: number): string {358 const diff = Date.now() - savedAt;359 if (diff < 60_000) return "Just now";360 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;361 const d = new Date(savedAt);362 const isToday = new Date().toDateString() === d.toDateString();363 const isYest =364 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();365 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });366 if (isToday) return `Today ${t}`;367 if (isYest) return `Yesterday ${t}`;368 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;369 }370371 function reasonLabel(reason: VaultSnapshot["reason"]): string {372 return (373 {374 local_save: "saved",375 sync_received: "synced",376 import: "imported",377 restore: "restored",378 } as const379 )[reason] ?? reason;380 }381382 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);383384 const [showChangePw, setShowChangePw] = createSignal(false);385 const [changePwCurrent, setChangePwCurrent] = createSignal("");386 const [changePwNew, setChangePwNew] = createSignal("");387 const [changePwConfirm, setChangePwConfirm] = createSignal("");388 const [changePwSaving, setChangePwSaving] = createSignal(false);389 const [changePwError, setChangePwError] = createSignal<string | null>(null);390 const [changePwSuccess] = createSignal(false);391 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);392393 const [duressEnabled, setDuressEnabled] = createSignal(false);394 const [showDuressSetup, setShowDuressSetup] = createSignal(false);395 const [duressPassword, setDuressPassword] = createSignal("");396 const [duressConfirm, setDuressConfirm] = createSignal("");397 const [duressMainPassword, setDuressMainPassword] = createSignal("");398 const [duressSaving, setDuressSaving] = createSignal(false);399 const [duressError, setDuressError] = createSignal<string | null>(null);400 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);401402 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);403404 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);405 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);406407 const [fbCategory, setFbCategory] = createSignal("Bug report");408 const [fbMessage, setFbMessage] = createSignal("");409 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';410 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');411412 createEffect(() => {413 if (props.upgradeHighlight) {414 setActiveCard("pro");415 }416 });417418 onMount(async () => {419 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([420 sendMessage({ type: "GET_LICENSE" }),421 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),422 sendMessage({ type: "IS_DURESS_ENABLED" }),423 sendMessage({ type: "GET_SETTINGS" }),424 sendMessage({ type: "GET_LICENSE_KEY" }),425 ]);426427 428 429 void loadVaultHistory();430431 432 433 void loadPendingVouched();434 const onBroadcast = (msg: unknown) => {435 if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {436 void loadPendingVouched();437 }438 };439 chrome.runtime?.onMessage?.addListener(onBroadcast);440 onCleanup(() => {441 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }442 });443444 if ((licenseRes as { ok: boolean }).ok) {445 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);446 }447448 if ((passkeyRes as { ok: boolean }).ok) {449 setPasskeyState(450 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null451 );452 }453454 if ((duressRes as { ok: boolean }).ok) {455 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);456 }457458 if ((settingsRes as { ok: boolean }).ok) {459 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;460 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);461 }462463 if ((keyRes as { ok: boolean }).ok) {464 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);465 }466467 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });468 if ((entriesRes as { ok: boolean }).ok) {469 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];470 setCanaryEntries(471 entries472 .filter((e) => e.canary?.webhookUrl)473 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))474 );475 }476477478 if (isNativePlatform()) {479 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");480 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");481 try {482 if (biometric) {483 const a = await biometric.isAvailable();484 setBiometricSupported(a.available);485 }486 if (keystore) {487 const k = await keystore.hasMasterKey();488 setBiometricStored(k.exists);489 }490 } catch { }491 }492 });493494 async function handleEnableBiometrics() {495 setBiometricBusy(true);496 setBiometricError(null);497 try {498 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {499 ok: boolean;500 data?: { masterKeyHex: string };501 error?: string;502 };503 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {504 setBiometricError(keyRes.error === "vault_locked"505 ? "unlock the vault first"506 : "couldn't read derived key");507 return;508 }509 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");510 if (!keystore) {511 setBiometricError("biometric plugin unavailable");512 return;513 }514 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });515 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });516 setBiometricStored(true);517 } catch (e) {518 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");519 } finally {520 setBiometricBusy(false);521 }522 }523524 async function handleDisableBiometrics() {525 setBiometricBusy(true);526 setBiometricError(null);527 try {528 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");529 if (keystore) await keystore.clearMasterKey();530 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });531 setBiometricStored(false);532 } catch (e) {533 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");534 } finally {535 setBiometricBusy(false);536 }537 }538539 async function handleChangePassword() {540 setChangePwError(null);541 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }542 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }543 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }544 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }545 setChangePwSaving(true);546 const res = await sendMessage({547 type: "CHANGE_PASSWORD",548 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },549 }) as { ok: boolean; error?: string };550 if (res.ok) {551 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");552 setShowChangePw(false);553 554 555 556 557 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });558 setShowNewRecoveryWords(true);559 } else {560 setChangePwError(res.error ?? "failed to change password");561 }562 setChangePwSaving(false);563 }564565 const [copied, setCopied] = createSignal(false);566567 async function copyLicenseKey(key: string) {568 await navigator.clipboard.writeText(key);569 setCopied(true);570 setTimeout(() => setCopied(false), 2000);571 }572573 async function handleActivateLicense() {574 const key = licenseKeyInput().trim();575 if (!key) return;576 setActivatingLicense(true);577 setLicenseError(null);578 setLicenseSuccess(null);579 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });580 setActivatingLicense(false);581 if ((res as { ok: boolean }).ok) {582 const data = (res as { ok: boolean; data?: LicenseInfo }).data;583 if (data?.valid) {584 setLicenseInfo(data);585 setLicenseKey(key);586 setLicenseSuccess("License activated!");587 setLicenseKeyInput("");588 } else {589 setLicenseError("Invalid license key");590 }591 } else {592 setLicenseError("Activation failed");593 }594 }595596 async function handleTogglePasskeys() {597 const current = passkeyState();598 if (!current || !current.supported) return;599 setPasskeySaving(true);600 const res = await sendMessage({601 type: "SET_PASSKEY_PROVIDER_ENABLED",602 payload: { enabled: !current.enabled },603 });604 setPasskeySaving(false);605 if ((res as { ok: boolean }).ok) {606 setPasskeyState(607 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null608 );609 }610 }611612 async function handleCheckAllBreaches() {613 setCheckingBreaches(true);614 setBreachResult(null);615 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });616 setCheckingBreaches(false);617 if ((res as { ok: boolean }).ok) {618 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};619 const breached = Object.values(data).filter((count) => count > 0).length;620 setBreachResult(621 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"622 );623 props.onBreachCheckDone();624 }625 }626627628 async function handleSetupDuress() {629 const mainPw = duressMainPassword().trim();630 const duressPw = duressPassword().trim();631 const confirmPw = duressConfirm().trim();632633 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }634 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }635 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }636 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }637638 setDuressSaving(true);639 setDuressError(null);640641 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };642 if (!verifyRes.ok || !verifyRes.data) {643 setDuressError("incorrect main password");644 setDuressSaving(false);645 return;646 }647648 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };649 setDuressSaving(false);650 if (res.ok) {651 setDuressEnabled(true);652 setShowDuressSetup(false);653 setDuressPassword("");654 setDuressConfirm("");655 setDuressMainPassword("");656 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");657 setTimeout(() => setDuressSuccess(null), 5000);658 } else {659 setDuressError(660 res.error === "session_expired"661 ? "Vault session expired — lock and unlock Peach, then try again"662 : res.error ?? "failed to set duress password"663 );664 }665 }666667 async function handleClearDeclinedCards() {668 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });669 setDeclinedCardCount(0);670 }671672 async function handleCountDuplicates() {673 setDedupRunning(true);674 setDedupDone(null);675 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };676 setDedupRunning(false);677 if (res.ok) setDedupCount(res.data ?? 0);678 }679680 async function handleDeduplicate() {681 setDedupRunning(true);682 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };683 setDedupRunning(false);684 if (res.ok) {685 setDedupDone(res.data ?? 0);686 setDedupCount(0);687 }688 }689690 async function handleDisableDuress() {691 const mainPw = duressMainPassword().trim();692 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }693694 setDuressSaving(true);695 setDuressError(null);696697 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };698 if (!verifyRes.ok || !verifyRes.data) {699 setDuressError("incorrect main password");700 setDuressSaving(false);701 return;702 }703704 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };705 setDuressSaving(false);706 if (res.ok) {707 setDuressEnabled(false);708 setShowDuressSetup(false);709 setDuressMainPassword("");710 setDuressSuccess("Duress password disabled.");711 setTimeout(() => setDuressSuccess(null), 4000);712 } else {713 setDuressError(res.error ?? "failed to disable duress password");714 }715 }716717 async function handleSubmitFeedback() {718 const message = sanitizeFeedbackMessage(fbMessage());719 if (!message) return;720721 setFeedbackState('sending');722723 try {724 const hasDataPermission = await requestFeedbackDataPermission();725 if (!hasDataPermission) {726 setFeedbackState('idle');727 return;728 }729730 const category = FEEDBACK_CATEGORIES.includes(fbCategory())731 ? fbCategory()732 : FEEDBACK_CATEGORIES[0];733 const res = await fetch('https://api.web3forms.com/submit', {734 method: 'POST',735 headers: { 'Content-Type': 'application/json' },736 body: JSON.stringify({737 access_key: WEB3FORMS_KEY,738 subject: `Peach Feedback - ${category}`,739 message,740 from_name: 'Peach User',741 742 botcheck: '',743 }),744 });745746 if (!res.ok) throw new Error(`HTTP ${res.status}`);747 const data = await res.json() as { success: boolean; message?: string };748 if (!data.success) throw new Error(data.message ?? 'unknown error');749750 setFeedbackState('sent');751 setFbMessage('');752 setFbCategory(FEEDBACK_CATEGORIES[0]);753 } catch (err) {754 console.error('[feedback]', err);755 setFeedbackState('error');756 setTimeout(() => setFeedbackState('idle'), 4000);757 }758 }759760 function renderSecurity() {761 return (762 <div class="peach-settings-body">763 <div class="peach-settings-group">764 <p class="peach-settings-sub-label">LOCKING</p>765 <div class="peach-settings-row">766 <span>Lock on close</span>767 <button768 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}769 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}770 aria-label="Toggle lock on close"771 >772 <span class="peach-toggle-knob"></span>773 </button>774 </div>775 <div class="peach-settings-row">776 <span>Auto-lock after</span>777 <select778 class="peach-settings-select"779 value={String(props.autoLockMinutes)}780 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}781 >782 <option value="1">1 minute</option>783 <option value="5">5 minutes</option>784 <option value="15">15 minutes</option>785 <option value="30">30 minutes</option>786 <option value="0">Never</option>787 </select>788 </div>789790 <Show when={isNativePlatform() && biometricSupported()}>791 <p class="peach-settings-sub-label">BIOMETRICS</p>792 <div class="peach-settings-row">793 <span>Unlock with biometrics</span>794 <button795 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}796 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}797 disabled={biometricBusy()}798 aria-label="Toggle biometric unlock"799 >800 <span class="peach-toggle-knob"></span>801 </button>802 </div>803 <Show when={biometricError()}>804 <div class="peach-settings-row peach-settings-row--meta">805 <span class="peach-hint peach-hint-error">{biometricError()}</span>806 </div>807 </Show>808 </Show>809810 <p class="peach-settings-sub-label">BREACH PROTECTION</p>811 <div class="peach-settings-row">812 <span>Breach monitoring</span>813 <button814 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}815 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}816 aria-label="Toggle breach monitoring"817 >818 <span class="peach-toggle-knob"></span>819 </button>820 </div>821 <div class="peach-settings-row peach-settings-row--meta">822 <span>Check all passwords</span>823 <button824 class="peach-btn peach-btn--subtle peach-btn--sm"825 onClick={() => void handleCheckAllBreaches()}826 disabled={checkingBreaches()}827 >828 {checkingBreaches() ? "checking..." : "run"}829 </button>830 </div>831 <Show when={breachResult()}>832 <div class="peach-settings-row peach-settings-row--meta">833 <span>Last check</span>834 <span class="peach-settings-value">{breachResult()}</span>835 </div>836 </Show>837838 <p class="peach-settings-sub-label">MASTER PASSWORD</p>839 <div class="peach-settings-row peach-settings-row--meta">840 <span>Master password</span>841 <button842 class="peach-btn peach-btn--subtle peach-btn--sm"843 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}844 >845 {showChangePw() ? "cancel" : "change"}846 </button>847 </div>848 <Show when={showChangePw()}>849 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">850 <input851 class="peach-settings-input"852 type="password"853 placeholder="current password"854 value={changePwCurrent()}855 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}856 autocomplete="current-password"857 />858 <input859 class="peach-settings-input"860 type="password"861 placeholder="new password (min 12 chars)"862 value={changePwNew()}863 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}864 autocomplete="new-password"865 />866 <input867 class="peach-settings-input"868 type="password"869 placeholder="confirm new password"870 value={changePwConfirm()}871 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}872 autocomplete="new-password"873 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}874 />875 <Show when={changePwError()}>876 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>877 </Show>878 <Show when={changePwSuccess()}>879 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>880 </Show>881 <button882 class="peach-btn peach-btn--accent peach-btn--sm"883 style="align-self:flex-start"884 onClick={() => void handleChangePassword()}885 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}886 >887 {changePwSaving() ? "saving…" : "save new password"}888 </button>889 </div>890 </Show>891892 <p class="peach-settings-sub-label">893 DURESS PASSWORD894 <Show when={!isPro()}>895 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>896 </Show>897 </p>898 <div class="peach-settings-row peach-settings-row--top">899 <div class="peach-settings-row-main">900 <span>901 Duress password902 <Show when={!isPro()}>903 <span class="peach-pro-badge">Pro</span>904 </Show>905 </span>906 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">907 If entered under coercion, shows a decoy empty vault instead of your real one.908 </span>909 </div>910 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>911 {duressEnabled() ? "on" : "off"}912 </div>913 </div>914915 <Show when={duressEnabled() && !showDuressSetup()}>916 <div class="peach-settings-row peach-settings-row--meta">917 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">918 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.919 </span>920 </div>921 </Show>922923 <Show when={duressSuccess()}>924 <div class="peach-settings-row peach-settings-row--meta">925 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>926 </div>927 </Show>928929 <Show when={!showDuressSetup()}>930 <div class="peach-settings-row peach-settings-row--meta">931 <button932 class="peach-btn peach-btn--subtle peach-btn--sm"933 onClick={() => {934 935 936 937 938 939 if (!isPro() && !duressEnabled()) {940 props.onProGate?.("duress_vault");941 setActiveCard("pro");942 return;943 }944 setShowDuressSetup(true);945 setDuressError(null);946 setDuressSuccess(null);947 setDuressPassword("");948 setDuressConfirm("");949 setDuressMainPassword("");950 }}951 >952 {duressEnabled() ? "Change / disable" : "Set up"}953 </button>954 </div>955 </Show>956957 <Show when={showDuressSetup()}>958 <div class="peach-duress-form">959 <label class="peach-label">main password (to confirm)</label>960 <input961 class="peach-settings-input"962 type="password"963 placeholder="your current master password"964 autocomplete="current-password"965 value={duressMainPassword()}966 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}967 />968 <Show when={!duressEnabled()}>969 <label class="peach-label">new duress password</label>970 <input971 class="peach-settings-input"972 type="password"973 placeholder="duress password (min 8 chars)"974 autocomplete="new-password"975 value={duressPassword()}976 onInput={(e) => setDuressPassword(e.currentTarget.value)}977 />978 <label class="peach-label">confirm duress password</label>979 <input980 class="peach-settings-input"981 type="password"982 placeholder="confirm duress password"983 autocomplete="new-password"984 value={duressConfirm()}985 onInput={(e) => setDuressConfirm(e.currentTarget.value)}986 />987 </Show>988989 <Show when={duressError()}>990 <p class="peach-hint peach-hint-error">{duressError()}</p>991 </Show>992993 <div class="peach-duress-actions">994 <button995 class="peach-btn peach-btn--subtle peach-btn--sm"996 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}997 >998 cancel999 </button>1000 <Show when={duressEnabled()}>1001 <button1002 class="peach-btn peach-btn--ghost peach-btn--sm"1003 style="color:var(--peach-breach)"1004 onClick={() => void handleDisableDuress()}1005 disabled={duressSaving()}1006 >1007 {duressSaving() ? "disabling..." : "disable duress"}1008 </button>1009 </Show>1010 <Show when={!duressEnabled()}>1011 <button1012 class="peach-btn peach-btn--accent peach-btn--sm"1013 onClick={() => void handleSetupDuress()}1014 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1015 >1016 {duressSaving() ? "saving..." : "save"}1017 </button>1018 </Show>1019 </div>1020 </div>1021 </Show>10221023 <p class="peach-settings-sub-label">1024 CANARY ENTRIES1025 <Show when={!isPro()}>1026 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1027 </Show>1028 </p>1029 <div class="peach-settings-row peach-settings-row--top">1030 <div class="peach-settings-row-main">1031 <span>1032 Canary entries1033 <Show when={!isPro()}>1034 <span class="peach-pro-badge">Pro</span>1035 </Show>1036 </span>1037 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1038 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1039 </span>1040 </div>1041 <span class="peach-settings-value" style="flex-shrink:0">1042 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1043 </span>1044 </div>1045 <Show when={canaryEntries().length > 0}>1046 <For each={canaryEntries()}>1047 {(entry) => (1048 <div class="peach-settings-row peach-settings-row--meta">1049 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1050 <button1051 class="peach-btn peach-btn--subtle peach-btn--sm"1052 onClick={() => void handleTestCanary(entry)}1053 disabled={canaryTestingId() === entry.id}1054 style="flex-shrink:0"1055 >1056 {canaryTestingId() === entry.id1057 ? "sending…"1058 : canaryTestResult()?.id === entry.id1059 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1060 : "test"}1061 </button>1062 </div>1063 )}1064 </For>1065 </Show>10661067 <p class="peach-settings-sub-label">PASSKEYS</p>1068 <div class="peach-settings-row peach-settings-row--top">1069 <div class="peach-settings-row-main">1070 <span>Intercept passkey requests</span>1071 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1072 Peach can handle passkey sign-in and creation on supported Chromium browsers.1073 </span>1074 </div>1075 <button1076 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1077 onClick={() => void handleTogglePasskeys()}1078 aria-label="Toggle passkey interception"1079 disabled={!passkeyState()?.supported || passkeySaving()}1080 >1081 <span class="peach-toggle-knob"></span>1082 </button>1083 </div>1084 <div class="peach-settings-row peach-settings-row--meta">1085 <span>Status</span>1086 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1087 </div>1088 <div class="peach-settings-row peach-settings-row--meta">1089 <span>Browser</span>1090 <span class="peach-settings-value">1091 {passkeyState()?.browserLabel ?? "Unknown"}1092 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1093 </span>1094 </div>1095 <div class="peach-info-notice" style="margin-top:8px">1096 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1097 </div>1098 <Show when={passkeyState()?.conflict}>1099 <div class="peach-settings-row peach-settings-row--meta">1100 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1101 </div>1102 </Show>1103 </div>1104 </div>1105 );1106 }11071108 function renderVault() {1109 return (1110 <div class="peach-settings-body">1111 <div class="peach-settings-group">1112 <p class="peach-settings-sub-label">ENTRIES</p>1113 <div class="peach-settings-row peach-settings-row--meta">1114 <span>Entries</span>1115 <span class="peach-settings-value">{props.entriesCount}</span>1116 </div>1117 <div class="peach-settings-row peach-settings-row--meta">1118 <span>Duplicates</span>1119 <Show when={dedupDone() !== null} fallback={1120 <button1121 class="peach-btn peach-btn--subtle peach-btn--sm"1122 onClick={() => void handleCountDuplicates()}1123 disabled={dedupRunning()}1124 >1125 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1126 </button>1127 }>1128 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1129 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1130 </span>1131 </Show>1132 </div>1133 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1134 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1135 <div class="peach-restore-warning">1136 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1137 </div>1138 <div style="display:flex;gap:6px">1139 <button1140 class="peach-btn peach-btn--subtle peach-btn--sm"1141 onClick={() => setDedupCount(null)}1142 >1143 cancel1144 </button>1145 <button1146 class="peach-btn peach-btn--accent peach-btn--sm"1147 onClick={() => void handleDeduplicate()}1148 disabled={dedupRunning()}1149 >1150 {dedupRunning() ? "removing..." : "remove duplicates"}1151 </button>1152 </div>1153 </div>1154 </Show>11551156 <p class="peach-settings-sub-label">MAINTENANCE</p>1157 <div class="peach-settings-row peach-settings-row--meta">1158 <span>Trash auto-delete</span>1159 <select1160 class="peach-settings-select"1161 value={String(props.trashRetentionDays)}1162 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1163 >1164 <option value="7">7 days</option>1165 <option value="30">30 days</option>1166 <option value="90">90 days</option>1167 <option value="0">Never</option>1168 </select>1169 </div>1170 <div class="peach-settings-row peach-settings-row--meta">1171 <span>Advanced Vault Health</span>1172 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1173 open1174 </button>1175 </div>1176 <Show when={declinedCardCount() > 0}>1177 <div class="peach-settings-row peach-settings-row--meta">1178 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1179 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1180 Clear1181 </button>1182 </div>1183 </Show>11841185 <p class="peach-settings-sub-label">DATA</p>1186 <div class="peach-settings-row peach-settings-row--meta">1187 <span>Import passwords</span>1188 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1189 open1190 </button>1191 </div>1192 <div class="peach-settings-row peach-settings-row--meta">1193 <span>Export backup</span>1194 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1195 open1196 </button>1197 </div>1198 <div class="peach-settings-row peach-settings-row--meta">1199 <span>1200 Peach Codex1201 <Show when={!isPro()}>1202 <span class="peach-pro-badge">Pro</span>1203 </Show>1204 </span>1205 <button1206 class="peach-btn peach-btn--subtle peach-btn--sm"1207 onClick={() => props.onOpenCodexGenerate?.()}1208 >1209 open1210 </button>1211 </div>1212 {1213}1214 <div class="peach-settings-row peach-settings-row--meta">1215 <span>Restore from Codex</span>1216 <button1217 class="peach-btn peach-btn--subtle peach-btn--sm"1218 onClick={() => props.onOpenCodexRestore?.()}1219 >1220 open1221 </button>1222 </div>12231224 <span class="peach-settings-sub-label">VAULT HISTORY</span>1225 <Show when={historyLoading()}>1226 <p class="peach-settings-hint">Loading…</p>1227 </Show>1228 <Show when={!historyLoading() && historySnapshots().length === 0}>1229 <p class="peach-settings-hint">1230 No snapshots yet. Peach saves automatically before every change —1231 saves, syncs, and imports.1232 </p>1233 </Show>1234 <Show when={!historyLoading() && historySnapshots().length > 0}>1235 <p class="peach-settings-hint">1236 Restoring locks your vault. Unlock with the password that1237 encrypted that snapshot to confirm.1238 </p>1239 <div class="peach-history-list">1240 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1241 {(snap) => (1242 <div class="peach-history-row">1243 <div class="peach-history-meta">1244 <span class="peach-history-time">1245 {formatSnapshotTime(snap.savedAt)}1246 </span>1247 <span class="peach-history-sub">1248 {[1249 snap.entryCount !== null1250 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1251 : null,1252 reasonLabel(snap.reason),1253 ]1254 .filter(Boolean)1255 .join(" · ")}1256 </span>1257 </div>1258 <button1259 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1260 historyRestoring() === snap.id ? " is-busy" : ""1261 }`}1262 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1263 disabled={historyRestoring() !== null}1264 >1265 <Show when={historyRestoring() === snap.id} fallback="Restore">1266 …1267 </Show>1268 </button>1269 </div>1270 )}1271 </For>1272 </div>1273 <Show when={historySnapshots().length > 3}>1274 <button1275 class="peach-settings-advanced-toggle"1276 onClick={() => setHistoryShowAll((v) => !v)}1277 aria-expanded={historyShowAll()}1278 >1279 <span class="peach-settings-hint" style="margin:0">1280 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1281 </span>1282 <span class="peach-settings-advanced-chevron">1283 {historyShowAll() ? "▲" : "▼"}1284 </span>1285 </button>1286 </Show>1287 <p class="peach-history-footer">1288 {historySnapshots().length} of 10 snapshots1289 </p>1290 </Show>12911292 <button1293 class="peach-settings-advanced-toggle"1294 onClick={() => setShowVaultAdvanced((v) => !v)}1295 aria-expanded={showVaultAdvanced()}1296 >1297 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1298 <span class="peach-settings-advanced-chevron">1299 {showVaultAdvanced() ? "▲" : "▼"}1300 </span>1301 </button>13021303 <Show when={showVaultAdvanced()}>1304 <div class="peach-danger-card">1305 <div class="peach-danger-card-head">1306 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1307 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1308 </div>1309 <p class="peach-danger-card-copy">1310 Wipes the current vault and restores from a backup file.1311 Recoverable only if your backup is good.1312 </p>1313 <button1314 class="peach-danger-card-button"1315 onClick={() => props.onOpenFileImport?.()}1316 >1317 Choose backup file1318 </button>1319 </div>13201321 <Show when={props.onOpenPanic}>1322 <div class="peach-danger-card peach-danger-card--hazard">1323 <div class="peach-danger-card-head">1324 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1325 <h3 class="peach-danger-card-title">Destroy vault</h3>1326 </div>1327 <p class="peach-danger-card-copy">1328 Permanently wipes every entry, setting, and license on this1329 device. There is no recovery.1330 </p>1331 <button1332 class="peach-danger-card-button"1333 onClick={() => props.onOpenPanic?.()}1334 >1335 Begin destruction1336 </button>1337 </div>1338 </Show>1339 </Show>1340 </div>1341 </div>1342 );1343 }13441345 function renderSync() {1346 return (1347 <div class="peach-settings-body">1348 <div class="peach-settings-group">1349 <p class="peach-settings-sub-label">PAIRING</p>1350 <div class="peach-settings-row peach-settings-row--top">1351 <div class="peach-settings-row-main">1352 <span>1353 Peach Sync1354 <Show when={!isPro()}>1355 <span class="peach-pro-badge">Pro</span>1356 </Show>1357 </span>1358 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1359 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1360 </span>1361 </div>1362 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1363 </div>1364 <div class="peach-settings-row peach-settings-row--meta">1365 <span>Status</span>1366 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1367 </div>1368 <div class="peach-settings-row peach-settings-row--meta">1369 <span>Sync hub</span>1370 <button1371 class="peach-btn peach-btn--subtle peach-btn--sm"1372 onClick={() => props.onOpenSyncPairing?.()}1373 >1374 open1375 </button>1376 </div>13771378 <p class="peach-settings-sub-label">DEVICE FLOW</p>1379 <div class="peach-settings-row peach-settings-row--top">1380 <div class="peach-settings-row-main">1381 <span>Pair new device</span>1382 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1383 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1384 </span>1385 </div>1386 <button1387 class="peach-btn peach-btn--subtle peach-btn--sm"1388 onClick={() => props.onOpenSyncPairing?.()}1389 >1390 pair1391 </button>1392 </div>1393 {}1394 <Show when={pendingVouched().length > 0}>1395 <p class="peach-settings-sub-label">1396 PENDING DEVICES1397 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1398 </p>1399 <For each={pendingVouched()}>1400 {(dev) => (1401 <div class="peach-settings-row peach-settings-row--top">1402 <div class="peach-settings-row-main">1403 <span>{dev.deviceName}</span>1404 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1405 Was added by another of your devices. Allow it to sync with this vault?1406 </span>1407 </div>1408 <div style="display:flex;gap:6px;flex-shrink:0">1409 <button1410 class="peach-btn peach-btn--sm"1411 disabled={vouchedBusy() === dev.deviceId}1412 onClick={() => void confirmVouched(dev.deviceId)}1413 >Allow</button>1414 <button1415 class="peach-btn peach-btn--subtle peach-btn--sm"1416 disabled={vouchedBusy() === dev.deviceId}1417 onClick={() => void rejectVouched(dev.deviceId)}1418 >Reject</button>1419 </div>1420 </div>1421 )}1422 </For>1423 </Show>14241425 <div class="peach-info-notice" style="margin-top:8px">1426 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1427 </div>14281429 {}1430 <p class="peach-settings-sub-label">RECOVERY</p>1431 <Show1432 when={rotateConfirmOpen()}1433 fallback={1434 <div class="peach-settings-row peach-settings-row--top">1435 <div class="peach-settings-row-main">1436 <span>Rotate sync identity</span>1437 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1438 Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1439 </span>1440 </div>1441 <button1442 class="peach-btn peach-btn--subtle peach-btn--sm"1443 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1444 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1445 >rotate</button>1446 </div>1447 }1448 >1449 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1450 This will disconnect all paired devices — they will need to pair again — and your 12 recovery words will change. You'll be shown the new words next.1451 <Show when={rotateError()}>1452 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1453 </Show>1454 <div style="display:flex;gap:6px;margin-top:10px">1455 <button1456 class="peach-btn peach-btn--subtle peach-btn--sm"1457 disabled={rotating()}1458 onClick={() => setRotateConfirmOpen(false)}1459 >Cancel</button>1460 <button1461 class="peach-btn peach-btn--sm"1462 style="background:var(--peach-breach);border-color:var(--peach-breach)"1463 disabled={rotating()}1464 onClick={() => void doRotateSyncIdentity()}1465 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1466 </div>1467 </div>1468 </Show>1469 </div>1470 </div>1471 );1472 }14731474 function renderAppearance() {1475 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1476 { id: "medium", label: "Default" },1477 { id: "large", label: "Large" },1478 ];1479 return (1480 <div class="peach-settings-body">1481 <div class="peach-settings-group">1482 <p class="peach-settings-sub-label">THEME</p>1483 <div class="peach-settings-row peach-settings-row--meta">1484 <span>Theme</span>1485 <button1486 class="peach-btn peach-btn--subtle peach-btn--sm"1487 onClick={() =>1488 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1489 }1490 >1491 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1492 </button>1493 </div>14941495 <p class="peach-settings-sub-label">ACCENT</p>1496 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1497 <span>Accent</span>1498 <div class="peach-accent-picker">1499 <For each={ACCENT_COLORS}>1500 {(color) => (1501 <button1502 class="peach-accent-swatch peach-swatch"1503 classList={{ "is-active": props.accentColor === color.name }}1504 style={{ background: color.hex }}1505 onClick={() => void props.onAccentChange(color.name)}1506 aria-label={color.name}1507 title={color.name}1508 />1509 )}1510 </For>1511 </div>1512 </div>15131514 <p class="peach-settings-sub-label">TEXT SIZE</p>1515 <div class="peach-settings-row peach-settings-row--meta">1516 <span>Size</span>1517 <div class="peach-pill-group">1518 <For each={sizeOptions}>1519 {(opt) => (1520 <button1521 class="peach-btn peach-btn--ghost peach-btn--sm"1522 classList={{ "is-active": props.textSize === opt.id }}1523 onClick={() => void props.onTextSizeChange(opt.id)}1524 >1525 {opt.label}1526 </button>1527 )}1528 </For>1529 </div>1530 </div>15311532 <p class="peach-settings-sub-label">MOTION</p>1533 <div class="peach-settings-row peach-settings-row--meta">1534 <span>Reduce motion</span>1535 <button1536 class="peach-btn peach-btn--subtle peach-btn--sm"1537 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1538 >1539 {props.reduceMotion ? "On" : "Off"}1540 </button>1541 </div>1542 </div>1543 </div>1544 );1545 }15461547 function renderShortcuts() {1548 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1549 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);15501551 function startCapture(action: ShortcutAction) {1552 setConflictMsg(null);1553 setCapturingAction(action);1554 }1555 function cancelCapture() {1556 setCapturingAction(null);1557 setConflictMsg(null);1558 }1559 function commitBinding(action: ShortcutAction, binding: Keybinding) {1560 const conflict = findConflict(props.shortcuts, binding, action);1561 if (conflict) {1562 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1563 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1564 return;1565 }1566 const next = { ...props.shortcuts, [action]: binding };1567 setCapturingAction(null);1568 setConflictMsg(null);1569 void props.onShortcutsChange(next);1570 }1571 function resetAction(action: ShortcutAction) {1572 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1573 void props.onShortcutsChange(next);1574 }1575 function resetAll() {1576 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1577 }15781579 1580 1581 function handleCaptureKey(e: KeyboardEvent) {1582 e.preventDefault();1583 e.stopPropagation();1584 if (e.key === "Escape") { cancelCapture(); return; }1585 const binding = keybindingFromEvent(e);1586 if (!binding) return; 1587 const action = capturingAction();1588 if (action) commitBinding(action, binding);1589 }15901591 return (1592 <div class="peach-settings-body">1593 <div class="peach-settings-group peach-shortcuts-group">1594 <p class="peach-settings-sub-label">ENTRY VIEW</p>1595 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1596 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1597 </For>1598 </div>15991600 <div class="peach-settings-group peach-shortcuts-group">1601 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1602 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1603 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1604 </For>1605 </div>16061607 <Show when={conflictMsg()}>1608 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1609 </Show>16101611 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1612 <div class="peach-settings-row peach-settings-row--meta">1613 <span>Inline autofill on page</span>1614 <a1615 class="peach-settings-link"1616 href="chrome://extensions/shortcuts"1617 onClick={(e) => {1618 e.preventDefault();1619 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1620 }}1621 >1622 ⌘⇧P — customize ↗1623 </a>1624 </div>1625 <button1626 class="peach-btn peach-btn--subtle peach-btn--sm"1627 style="align-self:flex-start"1628 onClick={resetAll}1629 >1630 Reset all to defaults1631 </button>1632 </div>1633 </div>1634 );16351636 function renderRow(1637 desc: typeof SHORTCUT_DESCRIPTORS[number],1638 capturing: () => ShortcutAction | null,1639 onStart: (a: ShortcutAction) => void,1640 onCancel: () => void,1641 onReset: (a: ShortcutAction) => void,1642 onKey: (e: KeyboardEvent) => void,1643 ) {1644 const isActive = () => capturing() === desc.action;1645 const binding = () => props.shortcuts[desc.action];1646 const isCustom = () => {1647 const b = binding();1648 const d = DEFAULT_BINDINGS[desc.action];1649 return b.key !== d.key1650 || Boolean(b.ctrl) !== Boolean(d.ctrl)1651 || Boolean(b.meta) !== Boolean(d.meta)1652 || Boolean(b.shift) !== Boolean(d.shift)1653 || Boolean(b.alt) !== Boolean(d.alt);1654 };1655 return (1656 <div class="peach-shortcut-row">1657 <span class="peach-shortcut-row-label">{desc.label}</span>1658 <button1659 class="peach-shortcut-chip"1660 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1661 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1662 onKeyDown={(e) => { if (isActive()) onKey(e); }}1663 ref={(el) => {1664 1665 if (el) {1666 queueMicrotask(() => { if (isActive()) el.focus(); });1667 }1668 }}1669 >1670 {isActive() ? "Press a key…" : formatKeybinding(binding())}1671 </button>1672 <Show when={isCustom() && !isActive()}>1673 <button1674 class="peach-shortcut-reset"1675 title="Reset to default"1676 onClick={() => onReset(desc.action)}1677 >1678 ↺1679 </button>1680 </Show>1681 </div>1682 );1683 }1684 }16851686 const PRO_FEATURES: Array<{1687 feature: ProFeature;1688 name: string;1689 desc: string;1690 }> = [1691 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1692 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1693 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1694 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1695 ];16961697 const PRO_SECURITY_FEATURES: Array<{1698 feature: ProFeature;1699 name: string;1700 desc: string;1701 }> = [1702 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1703 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1704 ];17051706 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1707 const [showKey, setShowKey] = createSignal(false);17081709 const [deactivating, setDeactivating] = createSignal(false);17101711 async function handleDeactivateLicense() {1712 setDeactivating(true);1713 await sendMessage({ type: "DEACTIVATE_LICENSE" });1714 setLicenseInfo(null);1715 await props.onLicenseChange?.();1716 setDeactivating(false);1717 }17181719 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1720 setCanaryTestingId(entry.id);1721 setCanaryTestResult(null);1722 const res = await sendMessage({1723 type: "FIRE_CANARY_PING",1724 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1725 }) as { ok: boolean };1726 setCanaryTestResult({ id: entry.id, ok: res.ok });1727 setCanaryTestingId(null);1728 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1729 }17301731 function renderLicense() {1732 async function onActivate() {1733 await handleActivateLicense();1734 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1735 await props.onLicenseChange?.();1736 }1737 }17381739 return (1740 <div class="peach-settings-body">1741 <Show1742 when={isPro()}1743 fallback={1744 <div class="peach-pro-upgrade">1745 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>17461747 <h2 class="peach-pro-headline">1748 Every<em>thing,</em><br />every<em>where.</em>1749 </h2>17501751 <p class="peach-pro-subtitle">1752 One key unlocks every Pro feature — for the lifetime of the app.1753 </p>17541755 <div class="peach-pro-grid">1756 <For each={PRO_FEATURES}>1757 {(item, i) => (1758 <div1759 class="peach-pro-grid-item"1760 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1761 >1762 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1763 <span class="peach-pro-grid-name">{item.name}</span>1764 <span class="peach-pro-grid-desc">{item.desc}</span>1765 </div>1766 )}1767 </For>1768 </div>17691770 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1771 <div class="peach-pro-grid">1772 <For each={PRO_SECURITY_FEATURES}>1773 {(item, i) => (1774 <div1775 class="peach-pro-grid-item"1776 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1777 >1778 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1779 <span class="peach-pro-grid-name">{item.name}</span>1780 <span class="peach-pro-grid-desc">{item.desc}</span>1781 </div>1782 )}1783 </For>1784 </div>17851786 <div class="peach-pro-upgrade-actions">1787 <p class="peach-pro-pricing">1788 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1789 </p>1790 <a1791 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"1792 target="_blank"1793 rel="noopener noreferrer"1794 class="peach-pro-upgrade-cta"1795 >1796 Get Peach Pro1797 </a>1798 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1799 <div class="peach-pro-key-inline">1800 <input1801 class="peach-pro-key-inline-input"1802 type="text"1803 placeholder="PEACH-XXXX-XXXX-XXXX"1804 value={licenseKeyInput()}1805 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1806 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1807 autocomplete="off"1808 spellcheck={false}1809 />1810 <button1811 class="peach-pro-key-apply"1812 onClick={() => void onActivate()}1813 disabled={activatingLicense() || !licenseKeyInput().trim()}1814 >1815 {activatingLicense() ? "…" : "APPLY"}1816 </button>1817 </div>1818 <Show when={licenseError()}>1819 <p class="peach-pro-key-error">{licenseError()}</p>1820 </Show>1821 </div>1822 </div>1823 }1824 >1825 <div class="peach-pro-active">1826 <div class="peach-pro-active-hero">1827 <button1828 class="peach-pro-active-mark-wrap"1829 type="button"1830 aria-label="Celebrate"1831 onClick={handleProCelebrationClick}1832 >1833 <PeachMark class="peach-pro-active-mark" />1834 <Show when={justActivated()}>1835 <div class="peach-pro-sparks" aria-hidden="true">1836 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>1837 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>1838 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>1839 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>1840 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>1841 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>1842 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>1843 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>1844 </div>1845 </Show>1846 </button>1847 <h2 class="peach-pro-active-title">Peach Pro</h2>1848 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1849 <Show when={justActivated()}>1850 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>1851 </Show>1852 </div>18531854 <Show when={licenseKey()}>1855 <div class="peach-settings-license">1856 <span class="peach-settings-label">LICENSE KEY</span>1857 <div class="peach-settings-license-row">1858 <Show1859 when={showKey()}1860 fallback={1861 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1862 ••••••••••••••••••••••••••••1863 </span>1864 }1865 >1866 <span class="peach-settings-license-key">{licenseKey()!}</span>1867 </Show>1868 <div class="peach-settings-license-actions">1869 <button1870 class="peach-settings-license-copy"1871 title="Copy license key"1872 onClick={() => copyLicenseKey(licenseKey()!)}1873 >1874 {copied() ? "✓" : "copy"}1875 </button>1876 <button1877 class="peach-settings-license-toggle"1878 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1879 title={showKey() ? "Hide" : "Reveal"}1880 onClick={() => setShowKey(v => !v)}1881 >1882 <Show1883 when={showKey()}1884 fallback={1885 <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">1886 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1887 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1888 </svg>1889 }1890 >1891 <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">1892 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1893 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1894 <line x1="2" y1="2" x2="12" y2="12" />1895 </svg>1896 </Show>1897 </button>1898 </div>1899 </div>1900 </div>1901 </Show>19021903 <div class="peach-pro-active-features">1904 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1905 {(item) => (1906 <div class="peach-pro-active-feature">1907 <span class="peach-pro-active-feature-mark">✦</span>1908 <span class="peach-pro-active-feature-name">{item.name}</span>1909 </div>1910 )}1911 </For>1912 </div>19131914 <button1915 class="peach-pro-deactivate"1916 onClick={() => void handleDeactivateLicense()}1917 disabled={deactivating()}1918 >1919 {deactivating() ? "Deactivating…" : "Deactivate"}1920 </button>1921 </div>1922 </Show>1923 </div>1924 );1925 }19261927 function renderFeedback() {1928 return (1929 <div class="peach-settings-body">1930 <div class="peach-settings-group peach-feedback-group">1931 <Show1932 when={feedbackState() === 'sent'}1933 fallback={1934 <>1935 <p class="peach-feedback-intro">1936 Help us improve Peach. Every report goes directly to our inbox.1937 </p>19381939 <p class="peach-settings-sub-label">TYPE</p>1940 <div class="peach-feedback-chips">1941 <For each={FEEDBACK_CATEGORIES}>1942 {(cat) => {1943 const selected = () => fbCategory() === cat;1944 return (1945 <button1946 type="button"1947 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1948 aria-pressed={selected()}1949 onClick={() => setFbCategory(cat)}1950 >1951 {cat}1952 </button>1953 );1954 }}1955 </For>1956 </div>19571958 <p class="peach-settings-sub-label">MESSAGE</p>1959 <textarea1960 class="peach-feedback-textarea"1961 placeholder="Describe the issue or idea..."1962 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1963 value={fbMessage()}1964 onInput={(e) => setFbMessage(e.currentTarget.value)}1965 />19661967 <p class="peach-feedback-meta">1968 Version {version} · will be included automatically1969 </p>19701971 <button1972 type="button"1973 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1974 disabled={!fbMessage().trim() || feedbackState() === 'sending'}1975 onClick={() => void handleSubmitFeedback()}1976 >1977 <Switch>1978 <Match when={feedbackState() === 'idle'}>Send</Match>1979 <Match when={feedbackState() === 'sending'}>Sending...</Match>1980 <Match when={feedbackState() === 'error'}>Failed - try again</Match>1981 </Switch>1982 </button>19831984 <p class="peach-feedback-anon">1985 Sent anonymously · no account required1986 </p>1987 </>1988 }1989 >1990 <div class="peach-feedback-thanks" role="status" aria-live="polite">1991 <p class="peach-feedback-thanks-title">Thank you</p>1992 <p>1993 Your note is in the queue. Someone will process it soon.1994 </p>1995 </div>1996 </Show>1997 </div>1998 </div>1999 );2000 }20012002 const bodies: StackBodies = {2003 protection: renderSecurity(),2004 vault: renderVault(),2005 sync: renderSync(),2006 appearance: renderAppearance(),2007 shortcuts: renderShortcuts(),2008 pro: renderLicense(),2009 feedback: renderFeedback(),2010 };20112012 return (2013 <Show2014 when={showNewRecoveryWords()}2015 fallback={2016 <div2017 class="peach-overlay-view peach-settings-overlay"2018 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2019 style="display:flex;flex-direction:column"2020 >2021 <StackDeck2022 active={activeCard()}2023 onActiveChange={setActiveCard}2024 onBack={props.onBack}2025 cards={cards()}2026 bodies={bodies}2027 version={version}2028 isFanning={isFanning()}2029 celebratingCardId={justActivated() ? "pro" : null}2030 isPro={isPro()}2031 />2032 </div>2033 }2034 >2035 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2036 </Show>2037 );2038}