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 394 const [showViewWords, setShowViewWords] = createSignal(false);395 const [viewWordsPassword, setViewWordsPassword] = createSignal("");396 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);397 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);398 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);399400 async function handleViewRecoveryWords() {401 setViewWordsError(null);402 if (!viewWordsPassword()) {403 setViewWordsError("Enter your master password.");404 return;405 }406 setViewWordsBusy(true);407 try {408 const r = (await sendMessage({409 type: "GET_RECOVERY_WORDS",410 payload: { password: viewWordsPassword() },411 })) as { ok: boolean; data?: string[]; error?: string };412 if (r.ok) {413 setViewedWords(r.data ?? []);414 setViewWordsPassword("");415 return;416 }417 switch (r.error) {418 case "wrong_password":419 setViewWordsError("Incorrect password");420 break;421 case "password_required":422 setViewWordsError("Enter your master password.");423 break;424 case "recovery_not_setup":425 setViewWordsError("Recovery words aren't set up for this vault yet.");426 break;427 default:428 setViewWordsError("Couldn't retrieve recovery words.");429 }430 } finally {431 setViewWordsBusy(false);432 }433 }434 function closeViewWords() {435 setShowViewWords(false);436 setViewWordsPassword("");437 setViewWordsError(null);438 setViewedWords(null);439 }440441 const [duressEnabled, setDuressEnabled] = createSignal(false);442 const [showDuressSetup, setShowDuressSetup] = createSignal(false);443 const [duressPassword, setDuressPassword] = createSignal("");444 const [duressConfirm, setDuressConfirm] = createSignal("");445 const [duressMainPassword, setDuressMainPassword] = createSignal("");446 const [duressSaving, setDuressSaving] = createSignal(false);447 const [duressError, setDuressError] = createSignal<string | null>(null);448 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);449450 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);451452 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);453 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);454455 const [fbCategory, setFbCategory] = createSignal("Bug report");456 const [fbMessage, setFbMessage] = createSignal("");457 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';458 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');459460 createEffect(() => {461 if (props.upgradeHighlight) {462 setActiveCard("pro");463 }464 });465466 onMount(async () => {467 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([468 sendMessage({ type: "GET_LICENSE" }),469 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),470 sendMessage({ type: "IS_DURESS_ENABLED" }),471 sendMessage({ type: "GET_SETTINGS" }),472 sendMessage({ type: "GET_LICENSE_KEY" }),473 ]);474475 476 477 void loadVaultHistory();478479 480 481 void loadPendingVouched();482 const onBroadcast = (msg: unknown) => {483 if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {484 void loadPendingVouched();485 }486 };487 chrome.runtime?.onMessage?.addListener(onBroadcast);488 onCleanup(() => {489 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }490 });491492 if ((licenseRes as { ok: boolean }).ok) {493 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);494 }495496 if ((passkeyRes as { ok: boolean }).ok) {497 setPasskeyState(498 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null499 );500 }501502 if ((duressRes as { ok: boolean }).ok) {503 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);504 }505506 if ((settingsRes as { ok: boolean }).ok) {507 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;508 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);509 }510511 if ((keyRes as { ok: boolean }).ok) {512 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);513 }514515 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });516 if ((entriesRes as { ok: boolean }).ok) {517 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];518 setCanaryEntries(519 entries520 .filter((e) => e.canary?.webhookUrl)521 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))522 );523 }524525526 if (isNativePlatform()) {527 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");528 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");529 try {530 if (biometric) {531 const a = await biometric.isAvailable();532 setBiometricSupported(a.available);533 }534 if (keystore) {535 const k = await keystore.hasMasterKey();536 setBiometricStored(k.exists);537 }538 } catch { }539 }540 });541542 async function handleEnableBiometrics() {543 setBiometricBusy(true);544 setBiometricError(null);545 try {546 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {547 ok: boolean;548 data?: { masterKeyHex: string };549 error?: string;550 };551 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {552 setBiometricError(keyRes.error === "vault_locked"553 ? "unlock the vault first"554 : "couldn't read derived key");555 return;556 }557 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");558 if (!keystore) {559 setBiometricError("biometric plugin unavailable");560 return;561 }562 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });563 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });564 setBiometricStored(true);565 } catch (e) {566 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");567 } finally {568 setBiometricBusy(false);569 }570 }571572 async function handleDisableBiometrics() {573 setBiometricBusy(true);574 setBiometricError(null);575 try {576 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");577 if (keystore) await keystore.clearMasterKey();578 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });579 setBiometricStored(false);580 } catch (e) {581 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");582 } finally {583 setBiometricBusy(false);584 }585 }586587 async function handleChangePassword() {588 setChangePwError(null);589 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }590 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }591 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }592 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }593 setChangePwSaving(true);594 const res = await sendMessage({595 type: "CHANGE_PASSWORD",596 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },597 }) as { ok: boolean; error?: string };598 if (res.ok) {599 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");600 setShowChangePw(false);601 602 603 604 605 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });606 setShowNewRecoveryWords(true);607 } else {608 setChangePwError(res.error ?? "failed to change password");609 }610 setChangePwSaving(false);611 }612613 const [copied, setCopied] = createSignal(false);614615 async function copyLicenseKey(key: string) {616 await navigator.clipboard.writeText(key);617 setCopied(true);618 setTimeout(() => setCopied(false), 2000);619 }620621 async function handleActivateLicense() {622 const key = licenseKeyInput().trim();623 if (!key) return;624 setActivatingLicense(true);625 setLicenseError(null);626 setLicenseSuccess(null);627 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });628 setActivatingLicense(false);629 if ((res as { ok: boolean }).ok) {630 const data = (res as { ok: boolean; data?: LicenseInfo }).data;631 if (data?.valid) {632 setLicenseInfo(data);633 setLicenseKey(key);634 setLicenseSuccess("License activated!");635 setLicenseKeyInput("");636 } else {637 setLicenseError("Invalid license key");638 }639 } else {640 setLicenseError("Activation failed");641 }642 }643644 async function handleTogglePasskeys() {645 const current = passkeyState();646 if (!current || !current.supported) return;647 setPasskeySaving(true);648 const res = await sendMessage({649 type: "SET_PASSKEY_PROVIDER_ENABLED",650 payload: { enabled: !current.enabled },651 });652 setPasskeySaving(false);653 if ((res as { ok: boolean }).ok) {654 setPasskeyState(655 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null656 );657 }658 }659660 async function handleCheckAllBreaches() {661 setCheckingBreaches(true);662 setBreachResult(null);663 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });664 setCheckingBreaches(false);665 if ((res as { ok: boolean }).ok) {666 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};667 const breached = Object.values(data).filter((count) => count > 0).length;668 setBreachResult(669 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"670 );671 props.onBreachCheckDone();672 }673 }674675676 async function handleSetupDuress() {677 const mainPw = duressMainPassword().trim();678 const duressPw = duressPassword().trim();679 const confirmPw = duressConfirm().trim();680681 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }682 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }683 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }684 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }685686 setDuressSaving(true);687 setDuressError(null);688689 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };690 if (!verifyRes.ok || !verifyRes.data) {691 setDuressError("incorrect main password");692 setDuressSaving(false);693 return;694 }695696 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };697 setDuressSaving(false);698 if (res.ok) {699 setDuressEnabled(true);700 setShowDuressSetup(false);701 setDuressPassword("");702 setDuressConfirm("");703 setDuressMainPassword("");704 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");705 setTimeout(() => setDuressSuccess(null), 5000);706 } else {707 setDuressError(708 res.error === "session_expired"709 ? "Vault session expired — lock and unlock Peach, then try again"710 : res.error ?? "failed to set duress password"711 );712 }713 }714715 async function handleClearDeclinedCards() {716 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });717 setDeclinedCardCount(0);718 }719720 async function handleCountDuplicates() {721 setDedupRunning(true);722 setDedupDone(null);723 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };724 setDedupRunning(false);725 if (res.ok) setDedupCount(res.data ?? 0);726 }727728 async function handleDeduplicate() {729 setDedupRunning(true);730 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };731 setDedupRunning(false);732 if (res.ok) {733 setDedupDone(res.data ?? 0);734 setDedupCount(0);735 }736 }737738 async function handleDisableDuress() {739 const mainPw = duressMainPassword().trim();740 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }741742 setDuressSaving(true);743 setDuressError(null);744745 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };746 if (!verifyRes.ok || !verifyRes.data) {747 setDuressError("incorrect main password");748 setDuressSaving(false);749 return;750 }751752 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };753 setDuressSaving(false);754 if (res.ok) {755 setDuressEnabled(false);756 setShowDuressSetup(false);757 setDuressMainPassword("");758 setDuressSuccess("Duress password disabled.");759 setTimeout(() => setDuressSuccess(null), 4000);760 } else {761 setDuressError(res.error ?? "failed to disable duress password");762 }763 }764765 async function handleSubmitFeedback() {766 const message = sanitizeFeedbackMessage(fbMessage());767 if (!message) return;768769 setFeedbackState('sending');770771 try {772 const hasDataPermission = await requestFeedbackDataPermission();773 if (!hasDataPermission) {774 setFeedbackState('idle');775 return;776 }777778 const category = FEEDBACK_CATEGORIES.includes(fbCategory())779 ? fbCategory()780 : FEEDBACK_CATEGORIES[0];781 const res = await fetch('https://api.web3forms.com/submit', {782 method: 'POST',783 headers: { 'Content-Type': 'application/json' },784 body: JSON.stringify({785 access_key: WEB3FORMS_KEY,786 subject: `Peach Feedback - ${category}`,787 message,788 from_name: 'Peach User',789 790 botcheck: '',791 }),792 });793794 if (!res.ok) throw new Error(`HTTP ${res.status}`);795 const data = await res.json() as { success: boolean; message?: string };796 if (!data.success) throw new Error(data.message ?? 'unknown error');797798 setFeedbackState('sent');799 setFbMessage('');800 setFbCategory(FEEDBACK_CATEGORIES[0]);801 } catch (err) {802 console.error('[feedback]', err);803 setFeedbackState('error');804 setTimeout(() => setFeedbackState('idle'), 4000);805 }806 }807808 function renderSecurity() {809 return (810 <div class="peach-settings-body">811 <div class="peach-settings-group">812 <p class="peach-settings-sub-label">LOCKING</p>813 <div class="peach-settings-row">814 <span>Lock on close</span>815 <button816 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}817 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}818 aria-label="Toggle lock on close"819 >820 <span class="peach-toggle-knob"></span>821 </button>822 </div>823 <div class="peach-settings-row">824 <span>Auto-lock after</span>825 <select826 class="peach-settings-select"827 value={String(props.autoLockMinutes)}828 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}829 >830 <option value="1">1 minute</option>831 <option value="5">5 minutes</option>832 <option value="15">15 minutes</option>833 <option value="30">30 minutes</option>834 <option value="0">Never</option>835 </select>836 </div>837838 <Show when={isNativePlatform() && biometricSupported()}>839 <p class="peach-settings-sub-label">BIOMETRICS</p>840 <div class="peach-settings-row">841 <span>Unlock with biometrics</span>842 <button843 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}844 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}845 disabled={biometricBusy()}846 aria-label="Toggle biometric unlock"847 >848 <span class="peach-toggle-knob"></span>849 </button>850 </div>851 <Show when={biometricError()}>852 <div class="peach-settings-row peach-settings-row--meta">853 <span class="peach-hint peach-hint-error">{biometricError()}</span>854 </div>855 </Show>856 </Show>857858 <p class="peach-settings-sub-label">BREACH PROTECTION</p>859 <div class="peach-settings-row">860 <span>Breach monitoring</span>861 <button862 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}863 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}864 aria-label="Toggle breach monitoring"865 >866 <span class="peach-toggle-knob"></span>867 </button>868 </div>869 <div class="peach-settings-row peach-settings-row--meta">870 <span>Check all passwords</span>871 <button872 class="peach-btn peach-btn--subtle peach-btn--sm"873 onClick={() => void handleCheckAllBreaches()}874 disabled={checkingBreaches()}875 >876 {checkingBreaches() ? "checking..." : "run"}877 </button>878 </div>879 <Show when={breachResult()}>880 <div class="peach-settings-row peach-settings-row--meta">881 <span>Last check</span>882 <span class="peach-settings-value">{breachResult()}</span>883 </div>884 </Show>885886 <p class="peach-settings-sub-label">MASTER PASSWORD</p>887 <div class="peach-settings-row peach-settings-row--meta">888 <span>Master password</span>889 <button890 class="peach-btn peach-btn--subtle peach-btn--sm"891 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}892 >893 {showChangePw() ? "cancel" : "change"}894 </button>895 </div>896 <Show when={showChangePw()}>897 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">898 <input899 class="peach-settings-input"900 type="password"901 placeholder="current password"902 value={changePwCurrent()}903 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}904 autocomplete="current-password"905 />906 <input907 class="peach-settings-input"908 type="password"909 placeholder="new password (min 12 chars)"910 value={changePwNew()}911 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}912 autocomplete="new-password"913 />914 <input915 class="peach-settings-input"916 type="password"917 placeholder="confirm new password"918 value={changePwConfirm()}919 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}920 autocomplete="new-password"921 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}922 />923 <Show when={changePwError()}>924 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>925 </Show>926 <Show when={changePwSuccess()}>927 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>928 </Show>929 <button930 class="peach-btn peach-btn--accent peach-btn--sm"931 style="align-self:flex-start"932 onClick={() => void handleChangePassword()}933 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}934 >935 {changePwSaving() ? "saving…" : "save new password"}936 </button>937 </div>938 </Show>939940 {}941 <div class="peach-settings-row peach-settings-row--meta">942 <span>Recovery words</span>943 <button944 class="peach-btn peach-btn--subtle peach-btn--sm"945 onClick={() => {946 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }947 }}948 >949 {showViewWords() ? "cancel" : "view"}950 </button>951 </div>952 <Show when={showViewWords()}>953 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">954 <Show955 when={viewedWords()}956 fallback={957 <>958 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">959 Enter your master password to reveal your recovery words.960 </p>961 <input962 class="peach-settings-input"963 type="password"964 placeholder="master password"965 value={viewWordsPassword()}966 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}967 autocomplete="current-password"968 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}969 />970 <Show when={viewWordsError()}>971 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>972 </Show>973 <button974 class="peach-btn peach-btn--accent peach-btn--sm"975 style="align-self:flex-start"976 onClick={() => void handleViewRecoveryWords()}977 disabled={viewWordsBusy() || !viewWordsPassword()}978 >979 {viewWordsBusy() ? "checking…" : "reveal recovery words"}980 </button>981 </>982 }983 >984 <div class="peach-recovery-words-grid">985 <For each={viewedWords()!}>986 {(word, i) => (987 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>988 )}989 </For>990 </div>991 <button992 class="peach-btn peach-btn--subtle peach-btn--sm"993 style="align-self:flex-start"994 onClick={closeViewWords}995 >done</button>996 </Show>997 </div>998 </Show>9991000 <p class="peach-settings-sub-label">1001 DURESS PASSWORD1002 <Show when={!isPro()}>1003 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1004 </Show>1005 </p>1006 <div class="peach-settings-row peach-settings-row--top">1007 <div class="peach-settings-row-main">1008 <span>1009 Duress password1010 <Show when={!isPro()}>1011 <span class="peach-pro-badge">Pro</span>1012 </Show>1013 </span>1014 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1015 If entered under coercion, shows a decoy empty vault instead of your real one.1016 </span>1017 </div>1018 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1019 {duressEnabled() ? "on" : "off"}1020 </div>1021 </div>10221023 <Show when={duressEnabled() && !showDuressSetup()}>1024 <div class="peach-settings-row peach-settings-row--meta">1025 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1026 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.1027 </span>1028 </div>1029 </Show>10301031 <Show when={duressSuccess()}>1032 <div class="peach-settings-row peach-settings-row--meta">1033 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1034 </div>1035 </Show>10361037 <Show when={!showDuressSetup()}>1038 <div class="peach-settings-row peach-settings-row--meta">1039 <button1040 class="peach-btn peach-btn--subtle peach-btn--sm"1041 onClick={() => {1042 1043 1044 1045 1046 1047 if (!isPro() && !duressEnabled()) {1048 props.onProGate?.("duress_vault");1049 setActiveCard("pro");1050 return;1051 }1052 setShowDuressSetup(true);1053 setDuressError(null);1054 setDuressSuccess(null);1055 setDuressPassword("");1056 setDuressConfirm("");1057 setDuressMainPassword("");1058 }}1059 >1060 {duressEnabled() ? "Change / disable" : "Set up"}1061 </button>1062 </div>1063 </Show>10641065 <Show when={showDuressSetup()}>1066 <div class="peach-duress-form">1067 <label class="peach-label">main password (to confirm)</label>1068 <input1069 class="peach-settings-input"1070 type="password"1071 placeholder="your current master password"1072 autocomplete="current-password"1073 value={duressMainPassword()}1074 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1075 />1076 <Show when={!duressEnabled()}>1077 <label class="peach-label">new duress password</label>1078 <input1079 class="peach-settings-input"1080 type="password"1081 placeholder="duress password (min 8 chars)"1082 autocomplete="new-password"1083 value={duressPassword()}1084 onInput={(e) => setDuressPassword(e.currentTarget.value)}1085 />1086 <label class="peach-label">confirm duress password</label>1087 <input1088 class="peach-settings-input"1089 type="password"1090 placeholder="confirm duress password"1091 autocomplete="new-password"1092 value={duressConfirm()}1093 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1094 />1095 </Show>10961097 <Show when={duressError()}>1098 <p class="peach-hint peach-hint-error">{duressError()}</p>1099 </Show>11001101 <div class="peach-duress-actions">1102 <button1103 class="peach-btn peach-btn--subtle peach-btn--sm"1104 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1105 >1106 cancel1107 </button>1108 <Show when={duressEnabled()}>1109 <button1110 class="peach-btn peach-btn--ghost peach-btn--sm"1111 style="color:var(--peach-breach)"1112 onClick={() => void handleDisableDuress()}1113 disabled={duressSaving()}1114 >1115 {duressSaving() ? "disabling..." : "disable duress"}1116 </button>1117 </Show>1118 <Show when={!duressEnabled()}>1119 <button1120 class="peach-btn peach-btn--accent peach-btn--sm"1121 onClick={() => void handleSetupDuress()}1122 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1123 >1124 {duressSaving() ? "saving..." : "save"}1125 </button>1126 </Show>1127 </div>1128 </div>1129 </Show>11301131 <p class="peach-settings-sub-label">1132 CANARY ENTRIES1133 <Show when={!isPro()}>1134 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1135 </Show>1136 </p>1137 <div class="peach-settings-row peach-settings-row--top">1138 <div class="peach-settings-row-main">1139 <span>1140 Canary entries1141 <Show when={!isPro()}>1142 <span class="peach-pro-badge">Pro</span>1143 </Show>1144 </span>1145 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1146 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1147 </span>1148 </div>1149 <span class="peach-settings-value" style="flex-shrink:0">1150 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1151 </span>1152 </div>1153 <Show when={canaryEntries().length > 0}>1154 <For each={canaryEntries()}>1155 {(entry) => (1156 <div class="peach-settings-row peach-settings-row--meta">1157 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1158 <button1159 class="peach-btn peach-btn--subtle peach-btn--sm"1160 onClick={() => void handleTestCanary(entry)}1161 disabled={canaryTestingId() === entry.id}1162 style="flex-shrink:0"1163 >1164 {canaryTestingId() === entry.id1165 ? "sending…"1166 : canaryTestResult()?.id === entry.id1167 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1168 : "test"}1169 </button>1170 </div>1171 )}1172 </For>1173 </Show>11741175 <p class="peach-settings-sub-label">PASSKEYS</p>1176 <div class="peach-settings-row peach-settings-row--top">1177 <div class="peach-settings-row-main">1178 <span>Intercept passkey requests</span>1179 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1180 Peach can handle passkey sign-in and creation on supported Chromium browsers.1181 </span>1182 </div>1183 <button1184 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1185 onClick={() => void handleTogglePasskeys()}1186 aria-label="Toggle passkey interception"1187 disabled={!passkeyState()?.supported || passkeySaving()}1188 >1189 <span class="peach-toggle-knob"></span>1190 </button>1191 </div>1192 <div class="peach-settings-row peach-settings-row--meta">1193 <span>Status</span>1194 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1195 </div>1196 <div class="peach-settings-row peach-settings-row--meta">1197 <span>Browser</span>1198 <span class="peach-settings-value">1199 {passkeyState()?.browserLabel ?? "Unknown"}1200 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1201 </span>1202 </div>1203 <div class="peach-info-notice" style="margin-top:8px">1204 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1205 </div>1206 <Show when={passkeyState()?.conflict}>1207 <div class="peach-settings-row peach-settings-row--meta">1208 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1209 </div>1210 </Show>1211 </div>1212 </div>1213 );1214 }12151216 function renderVault() {1217 return (1218 <div class="peach-settings-body">1219 <div class="peach-settings-group">1220 <p class="peach-settings-sub-label">ENTRIES</p>1221 <div class="peach-settings-row peach-settings-row--meta">1222 <span>Entries</span>1223 <span class="peach-settings-value">{props.entriesCount}</span>1224 </div>1225 <div class="peach-settings-row peach-settings-row--meta">1226 <span>Duplicates</span>1227 <Show when={dedupDone() !== null} fallback={1228 <button1229 class="peach-btn peach-btn--subtle peach-btn--sm"1230 onClick={() => void handleCountDuplicates()}1231 disabled={dedupRunning()}1232 >1233 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1234 </button>1235 }>1236 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1237 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1238 </span>1239 </Show>1240 </div>1241 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1242 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1243 <div class="peach-restore-warning">1244 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1245 </div>1246 <div style="display:flex;gap:6px">1247 <button1248 class="peach-btn peach-btn--subtle peach-btn--sm"1249 onClick={() => setDedupCount(null)}1250 >1251 cancel1252 </button>1253 <button1254 class="peach-btn peach-btn--accent peach-btn--sm"1255 onClick={() => void handleDeduplicate()}1256 disabled={dedupRunning()}1257 >1258 {dedupRunning() ? "removing..." : "remove duplicates"}1259 </button>1260 </div>1261 </div>1262 </Show>12631264 <p class="peach-settings-sub-label">MAINTENANCE</p>1265 <div class="peach-settings-row peach-settings-row--meta">1266 <span>Trash auto-delete</span>1267 <select1268 class="peach-settings-select"1269 value={String(props.trashRetentionDays)}1270 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1271 >1272 <option value="7">7 days</option>1273 <option value="30">30 days</option>1274 <option value="90">90 days</option>1275 <option value="0">Never</option>1276 </select>1277 </div>1278 <div class="peach-settings-row peach-settings-row--meta">1279 <span>Advanced Vault Health</span>1280 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1281 open1282 </button>1283 </div>1284 <Show when={declinedCardCount() > 0}>1285 <div class="peach-settings-row peach-settings-row--meta">1286 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1287 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1288 Clear1289 </button>1290 </div>1291 </Show>12921293 <p class="peach-settings-sub-label">DATA</p>1294 <div class="peach-settings-row peach-settings-row--meta">1295 <span>Import passwords</span>1296 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1297 open1298 </button>1299 </div>1300 <div class="peach-settings-row peach-settings-row--meta">1301 <span>Export backup</span>1302 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1303 open1304 </button>1305 </div>1306 <div class="peach-settings-row peach-settings-row--meta">1307 <span>1308 Peach Codex1309 <Show when={!isPro()}>1310 <span class="peach-pro-badge">Pro</span>1311 </Show>1312 </span>1313 <button1314 class="peach-btn peach-btn--subtle peach-btn--sm"1315 onClick={() => props.onOpenCodexGenerate?.()}1316 >1317 open1318 </button>1319 </div>1320 {1321}1322 <div class="peach-settings-row peach-settings-row--meta">1323 <span>Restore from Codex</span>1324 <button1325 class="peach-btn peach-btn--subtle peach-btn--sm"1326 onClick={() => props.onOpenCodexRestore?.()}1327 >1328 open1329 </button>1330 </div>13311332 <span class="peach-settings-sub-label">VAULT HISTORY</span>1333 <Show when={historyLoading()}>1334 <p class="peach-settings-hint">Loading…</p>1335 </Show>1336 <Show when={!historyLoading() && historySnapshots().length === 0}>1337 <p class="peach-settings-hint">1338 No snapshots yet. Peach saves automatically before every change —1339 saves, syncs, and imports.1340 </p>1341 </Show>1342 <Show when={!historyLoading() && historySnapshots().length > 0}>1343 <p class="peach-settings-hint">1344 Restoring locks your vault. Unlock with the password that1345 encrypted that snapshot to confirm.1346 </p>1347 <div class="peach-history-list">1348 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1349 {(snap) => (1350 <div class="peach-history-row">1351 <div class="peach-history-meta">1352 <span class="peach-history-time">1353 {formatSnapshotTime(snap.savedAt)}1354 </span>1355 <span class="peach-history-sub">1356 {[1357 snap.entryCount !== null1358 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1359 : null,1360 reasonLabel(snap.reason),1361 ]1362 .filter(Boolean)1363 .join(" · ")}1364 </span>1365 </div>1366 <button1367 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1368 historyRestoring() === snap.id ? " is-busy" : ""1369 }`}1370 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1371 disabled={historyRestoring() !== null}1372 >1373 <Show when={historyRestoring() === snap.id} fallback="Restore">1374 …1375 </Show>1376 </button>1377 </div>1378 )}1379 </For>1380 </div>1381 <Show when={historySnapshots().length > 3}>1382 <button1383 class="peach-settings-advanced-toggle"1384 onClick={() => setHistoryShowAll((v) => !v)}1385 aria-expanded={historyShowAll()}1386 >1387 <span class="peach-settings-hint" style="margin:0">1388 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1389 </span>1390 <span class="peach-settings-advanced-chevron">1391 {historyShowAll() ? "▲" : "▼"}1392 </span>1393 </button>1394 </Show>1395 <p class="peach-history-footer">1396 {historySnapshots().length} of 10 snapshots1397 </p>1398 </Show>13991400 <button1401 class="peach-settings-advanced-toggle"1402 onClick={() => setShowVaultAdvanced((v) => !v)}1403 aria-expanded={showVaultAdvanced()}1404 >1405 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1406 <span class="peach-settings-advanced-chevron">1407 {showVaultAdvanced() ? "▲" : "▼"}1408 </span>1409 </button>14101411 <Show when={showVaultAdvanced()}>1412 <div class="peach-danger-card">1413 <div class="peach-danger-card-head">1414 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1415 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1416 </div>1417 <p class="peach-danger-card-copy">1418 Wipes the current vault and restores from a backup file.1419 Recoverable only if your backup is good.1420 </p>1421 <button1422 class="peach-danger-card-button"1423 onClick={() => props.onOpenFileImport?.()}1424 >1425 Choose backup file1426 </button>1427 </div>14281429 <Show when={props.onOpenPanic}>1430 <div class="peach-danger-card peach-danger-card--hazard">1431 <div class="peach-danger-card-head">1432 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1433 <h3 class="peach-danger-card-title">Destroy vault</h3>1434 </div>1435 <p class="peach-danger-card-copy">1436 Permanently wipes every entry, setting, and license on this1437 device. There is no recovery.1438 </p>1439 <button1440 class="peach-danger-card-button"1441 onClick={() => props.onOpenPanic?.()}1442 >1443 Begin destruction1444 </button>1445 </div>1446 </Show>1447 </Show>1448 </div>1449 </div>1450 );1451 }14521453 function renderSync() {1454 return (1455 <div class="peach-settings-body">1456 <div class="peach-settings-group">1457 <p class="peach-settings-sub-label">PAIRING</p>1458 <div class="peach-settings-row peach-settings-row--top">1459 <div class="peach-settings-row-main">1460 <span>1461 Peach Sync1462 <Show when={!isPro()}>1463 <span class="peach-pro-badge">Pro</span>1464 </Show>1465 </span>1466 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1467 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1468 </span>1469 </div>1470 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1471 </div>1472 <div class="peach-settings-row peach-settings-row--meta">1473 <span>Status</span>1474 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1475 </div>1476 <div class="peach-settings-row peach-settings-row--meta">1477 <span>Sync hub</span>1478 <button1479 class="peach-btn peach-btn--subtle peach-btn--sm"1480 onClick={() => props.onOpenSyncPairing?.()}1481 >1482 open1483 </button>1484 </div>14851486 <p class="peach-settings-sub-label">DEVICE FLOW</p>1487 <div class="peach-settings-row peach-settings-row--top">1488 <div class="peach-settings-row-main">1489 <span>Pair new device</span>1490 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1491 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1492 </span>1493 </div>1494 <button1495 class="peach-btn peach-btn--subtle peach-btn--sm"1496 onClick={() => props.onOpenSyncPairing?.()}1497 >1498 pair1499 </button>1500 </div>1501 {}1502 <Show when={pendingVouched().length > 0}>1503 <p class="peach-settings-sub-label">1504 PENDING DEVICES1505 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1506 </p>1507 <For each={pendingVouched()}>1508 {(dev) => (1509 <div class="peach-settings-row peach-settings-row--top">1510 <div class="peach-settings-row-main">1511 <span>{dev.deviceName}</span>1512 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1513 Was added by another of your devices. Allow it to sync with this vault?1514 </span>1515 </div>1516 <div style="display:flex;gap:6px;flex-shrink:0">1517 <button1518 class="peach-btn peach-btn--sm"1519 disabled={vouchedBusy() === dev.deviceId}1520 onClick={() => void confirmVouched(dev.deviceId)}1521 >Allow</button>1522 <button1523 class="peach-btn peach-btn--subtle peach-btn--sm"1524 disabled={vouchedBusy() === dev.deviceId}1525 onClick={() => void rejectVouched(dev.deviceId)}1526 >Reject</button>1527 </div>1528 </div>1529 )}1530 </For>1531 </Show>15321533 <div class="peach-info-notice" style="margin-top:8px">1534 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1535 </div>15361537 {}1538 <p class="peach-settings-sub-label">RECOVERY</p>1539 <Show1540 when={rotateConfirmOpen()}1541 fallback={1542 <div class="peach-settings-row peach-settings-row--top">1543 <div class="peach-settings-row-main">1544 <span>Rotate sync identity</span>1545 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1546 Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1547 </span>1548 </div>1549 <button1550 class="peach-btn peach-btn--subtle peach-btn--sm"1551 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1552 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1553 >rotate</button>1554 </div>1555 }1556 >1557 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1558 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.1559 <Show when={rotateError()}>1560 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1561 </Show>1562 <div style="display:flex;gap:6px;margin-top:10px">1563 <button1564 class="peach-btn peach-btn--subtle peach-btn--sm"1565 disabled={rotating()}1566 onClick={() => setRotateConfirmOpen(false)}1567 >Cancel</button>1568 <button1569 class="peach-btn peach-btn--sm"1570 style="background:var(--peach-breach);border-color:var(--peach-breach)"1571 disabled={rotating()}1572 onClick={() => void doRotateSyncIdentity()}1573 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1574 </div>1575 </div>1576 </Show>1577 </div>1578 </div>1579 );1580 }15811582 function renderAppearance() {1583 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1584 { id: "medium", label: "Default" },1585 { id: "large", label: "Large" },1586 ];1587 return (1588 <div class="peach-settings-body">1589 <div class="peach-settings-group">1590 <p class="peach-settings-sub-label">THEME</p>1591 <div class="peach-settings-row peach-settings-row--meta">1592 <span>Theme</span>1593 <button1594 class="peach-btn peach-btn--subtle peach-btn--sm"1595 onClick={() =>1596 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1597 }1598 >1599 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1600 </button>1601 </div>16021603 <p class="peach-settings-sub-label">ACCENT</p>1604 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1605 <span>Accent</span>1606 <div class="peach-accent-picker">1607 <For each={ACCENT_COLORS}>1608 {(color) => (1609 <button1610 class="peach-accent-swatch peach-swatch"1611 classList={{ "is-active": props.accentColor === color.name }}1612 style={{ background: color.hex }}1613 onClick={() => void props.onAccentChange(color.name)}1614 aria-label={color.name}1615 title={color.name}1616 />1617 )}1618 </For>1619 </div>1620 </div>16211622 <p class="peach-settings-sub-label">TEXT SIZE</p>1623 <div class="peach-settings-row peach-settings-row--meta">1624 <span>Size</span>1625 <div class="peach-pill-group">1626 <For each={sizeOptions}>1627 {(opt) => (1628 <button1629 class="peach-btn peach-btn--ghost peach-btn--sm"1630 classList={{ "is-active": props.textSize === opt.id }}1631 onClick={() => void props.onTextSizeChange(opt.id)}1632 >1633 {opt.label}1634 </button>1635 )}1636 </For>1637 </div>1638 </div>16391640 <p class="peach-settings-sub-label">MOTION</p>1641 <div class="peach-settings-row peach-settings-row--meta">1642 <span>Reduce motion</span>1643 <button1644 class="peach-btn peach-btn--subtle peach-btn--sm"1645 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1646 >1647 {props.reduceMotion ? "On" : "Off"}1648 </button>1649 </div>1650 </div>1651 </div>1652 );1653 }16541655 function renderShortcuts() {1656 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1657 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);16581659 function startCapture(action: ShortcutAction) {1660 setConflictMsg(null);1661 setCapturingAction(action);1662 }1663 function cancelCapture() {1664 setCapturingAction(null);1665 setConflictMsg(null);1666 }1667 function commitBinding(action: ShortcutAction, binding: Keybinding) {1668 const conflict = findConflict(props.shortcuts, binding, action);1669 if (conflict) {1670 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1671 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1672 return;1673 }1674 const next = { ...props.shortcuts, [action]: binding };1675 setCapturingAction(null);1676 setConflictMsg(null);1677 void props.onShortcutsChange(next);1678 }1679 function resetAction(action: ShortcutAction) {1680 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1681 void props.onShortcutsChange(next);1682 }1683 function resetAll() {1684 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1685 }16861687 1688 1689 function handleCaptureKey(e: KeyboardEvent) {1690 e.preventDefault();1691 e.stopPropagation();1692 if (e.key === "Escape") { cancelCapture(); return; }1693 const binding = keybindingFromEvent(e);1694 if (!binding) return; 1695 const action = capturingAction();1696 if (action) commitBinding(action, binding);1697 }16981699 return (1700 <div class="peach-settings-body">1701 <div class="peach-settings-group peach-shortcuts-group">1702 <p class="peach-settings-sub-label">ENTRY VIEW</p>1703 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1704 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1705 </For>1706 </div>17071708 <div class="peach-settings-group peach-shortcuts-group">1709 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1710 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1711 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1712 </For>1713 </div>17141715 <Show when={conflictMsg()}>1716 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1717 </Show>17181719 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1720 <div class="peach-settings-row peach-settings-row--meta">1721 <span>Inline autofill on page</span>1722 <a1723 class="peach-settings-link"1724 href="chrome://extensions/shortcuts"1725 onClick={(e) => {1726 e.preventDefault();1727 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1728 }}1729 >1730 ⌘⇧P — customize ↗1731 </a>1732 </div>1733 <button1734 class="peach-btn peach-btn--subtle peach-btn--sm"1735 style="align-self:flex-start"1736 onClick={resetAll}1737 >1738 Reset all to defaults1739 </button>1740 </div>1741 </div>1742 );17431744 function renderRow(1745 desc: typeof SHORTCUT_DESCRIPTORS[number],1746 capturing: () => ShortcutAction | null,1747 onStart: (a: ShortcutAction) => void,1748 onCancel: () => void,1749 onReset: (a: ShortcutAction) => void,1750 onKey: (e: KeyboardEvent) => void,1751 ) {1752 const isActive = () => capturing() === desc.action;1753 const binding = () => props.shortcuts[desc.action];1754 const isCustom = () => {1755 const b = binding();1756 const d = DEFAULT_BINDINGS[desc.action];1757 return b.key !== d.key1758 || Boolean(b.ctrl) !== Boolean(d.ctrl)1759 || Boolean(b.meta) !== Boolean(d.meta)1760 || Boolean(b.shift) !== Boolean(d.shift)1761 || Boolean(b.alt) !== Boolean(d.alt);1762 };1763 return (1764 <div class="peach-shortcut-row">1765 <span class="peach-shortcut-row-label">{desc.label}</span>1766 <button1767 class="peach-shortcut-chip"1768 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1769 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1770 onKeyDown={(e) => { if (isActive()) onKey(e); }}1771 ref={(el) => {1772 1773 if (el) {1774 queueMicrotask(() => { if (isActive()) el.focus(); });1775 }1776 }}1777 >1778 {isActive() ? "Press a key…" : formatKeybinding(binding())}1779 </button>1780 <Show when={isCustom() && !isActive()}>1781 <button1782 class="peach-shortcut-reset"1783 title="Reset to default"1784 onClick={() => onReset(desc.action)}1785 >1786 ↺1787 </button>1788 </Show>1789 </div>1790 );1791 }1792 }17931794 const PRO_FEATURES: Array<{1795 feature: ProFeature;1796 name: string;1797 desc: string;1798 }> = [1799 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1800 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1801 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1802 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1803 ];18041805 const PRO_SECURITY_FEATURES: Array<{1806 feature: ProFeature;1807 name: string;1808 desc: string;1809 }> = [1810 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1811 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1812 ];18131814 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1815 const [showKey, setShowKey] = createSignal(false);18161817 const [deactivating, setDeactivating] = createSignal(false);18181819 async function handleDeactivateLicense() {1820 setDeactivating(true);1821 await sendMessage({ type: "DEACTIVATE_LICENSE" });1822 setLicenseInfo(null);1823 await props.onLicenseChange?.();1824 setDeactivating(false);1825 }18261827 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1828 setCanaryTestingId(entry.id);1829 setCanaryTestResult(null);1830 const res = await sendMessage({1831 type: "FIRE_CANARY_PING",1832 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1833 }) as { ok: boolean };1834 setCanaryTestResult({ id: entry.id, ok: res.ok });1835 setCanaryTestingId(null);1836 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1837 }18381839 function renderLicense() {1840 async function onActivate() {1841 await handleActivateLicense();1842 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1843 await props.onLicenseChange?.();1844 }1845 }18461847 return (1848 <div class="peach-settings-body">1849 <Show1850 when={isPro()}1851 fallback={1852 <div class="peach-pro-upgrade">1853 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>18541855 <h2 class="peach-pro-headline">1856 Every<em>thing,</em><br />every<em>where.</em>1857 </h2>18581859 <p class="peach-pro-subtitle">1860 One key unlocks every Pro feature — for the lifetime of the app.1861 </p>18621863 <div class="peach-pro-grid">1864 <For each={PRO_FEATURES}>1865 {(item, i) => (1866 <div1867 class="peach-pro-grid-item"1868 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1869 >1870 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1871 <span class="peach-pro-grid-name">{item.name}</span>1872 <span class="peach-pro-grid-desc">{item.desc}</span>1873 </div>1874 )}1875 </For>1876 </div>18771878 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1879 <div class="peach-pro-grid">1880 <For each={PRO_SECURITY_FEATURES}>1881 {(item, i) => (1882 <div1883 class="peach-pro-grid-item"1884 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1885 >1886 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1887 <span class="peach-pro-grid-name">{item.name}</span>1888 <span class="peach-pro-grid-desc">{item.desc}</span>1889 </div>1890 )}1891 </For>1892 </div>18931894 <div class="peach-pro-upgrade-actions">1895 <p class="peach-pro-pricing">1896 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1897 </p>1898 <a1899 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"1900 target="_blank"1901 rel="noopener noreferrer"1902 class="peach-pro-upgrade-cta"1903 >1904 Get Peach Pro1905 </a>1906 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1907 <div class="peach-pro-key-inline">1908 <input1909 class="peach-pro-key-inline-input"1910 type="text"1911 placeholder="PEACH-XXXX-XXXX-XXXX"1912 value={licenseKeyInput()}1913 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1914 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1915 autocomplete="off"1916 spellcheck={false}1917 />1918 <button1919 class="peach-pro-key-apply"1920 onClick={() => void onActivate()}1921 disabled={activatingLicense() || !licenseKeyInput().trim()}1922 >1923 {activatingLicense() ? "…" : "APPLY"}1924 </button>1925 </div>1926 <Show when={licenseError()}>1927 <p class="peach-pro-key-error">{licenseError()}</p>1928 </Show>1929 </div>1930 </div>1931 }1932 >1933 <div class="peach-pro-active">1934 <div class="peach-pro-active-hero">1935 <button1936 class="peach-pro-active-mark-wrap"1937 type="button"1938 aria-label="Celebrate"1939 onClick={handleProCelebrationClick}1940 >1941 <PeachMark class="peach-pro-active-mark" />1942 <Show when={justActivated()}>1943 <div class="peach-pro-sparks" aria-hidden="true">1944 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>1945 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>1946 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>1947 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>1948 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>1949 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>1950 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>1951 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>1952 </div>1953 </Show>1954 </button>1955 <h2 class="peach-pro-active-title">Peach Pro</h2>1956 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1957 <Show when={justActivated()}>1958 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>1959 </Show>1960 </div>19611962 <Show when={licenseKey()}>1963 <div class="peach-settings-license">1964 <span class="peach-settings-label">LICENSE KEY</span>1965 <div class="peach-settings-license-row">1966 <Show1967 when={showKey()}1968 fallback={1969 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1970 ••••••••••••••••••••••••••••1971 </span>1972 }1973 >1974 <span class="peach-settings-license-key">{licenseKey()!}</span>1975 </Show>1976 <div class="peach-settings-license-actions">1977 <button1978 class="peach-settings-license-copy"1979 title="Copy license key"1980 onClick={() => copyLicenseKey(licenseKey()!)}1981 >1982 {copied() ? "✓" : "copy"}1983 </button>1984 <button1985 class="peach-settings-license-toggle"1986 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1987 title={showKey() ? "Hide" : "Reveal"}1988 onClick={() => setShowKey(v => !v)}1989 >1990 <Show1991 when={showKey()}1992 fallback={1993 <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">1994 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1995 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1996 </svg>1997 }1998 >1999 <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">2000 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2001 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2002 <line x1="2" y1="2" x2="12" y2="12" />2003 </svg>2004 </Show>2005 </button>2006 </div>2007 </div>2008 </div>2009 </Show>20102011 <div class="peach-pro-active-features">2012 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2013 {(item) => (2014 <div class="peach-pro-active-feature">2015 <span class="peach-pro-active-feature-mark">✦</span>2016 <span class="peach-pro-active-feature-name">{item.name}</span>2017 </div>2018 )}2019 </For>2020 </div>20212022 <button2023 class="peach-pro-deactivate"2024 onClick={() => void handleDeactivateLicense()}2025 disabled={deactivating()}2026 >2027 {deactivating() ? "Deactivating…" : "Deactivate"}2028 </button>2029 </div>2030 </Show>2031 </div>2032 );2033 }20342035 function renderFeedback() {2036 return (2037 <div class="peach-settings-body">2038 <div class="peach-settings-group peach-feedback-group">2039 <Show2040 when={feedbackState() === 'sent'}2041 fallback={2042 <>2043 <p class="peach-feedback-intro">2044 Help us improve Peach. Every report goes directly to our inbox.2045 </p>20462047 <p class="peach-settings-sub-label">TYPE</p>2048 <div class="peach-feedback-chips">2049 <For each={FEEDBACK_CATEGORIES}>2050 {(cat) => {2051 const selected = () => fbCategory() === cat;2052 return (2053 <button2054 type="button"2055 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2056 aria-pressed={selected()}2057 onClick={() => setFbCategory(cat)}2058 >2059 {cat}2060 </button>2061 );2062 }}2063 </For>2064 </div>20652066 <p class="peach-settings-sub-label">MESSAGE</p>2067 <textarea2068 class="peach-feedback-textarea"2069 placeholder="Describe the issue or idea..."2070 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2071 value={fbMessage()}2072 onInput={(e) => setFbMessage(e.currentTarget.value)}2073 />20742075 <p class="peach-feedback-meta">2076 Version {version} · will be included automatically2077 </p>20782079 <button2080 type="button"2081 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2082 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2083 onClick={() => void handleSubmitFeedback()}2084 >2085 <Switch>2086 <Match when={feedbackState() === 'idle'}>Send</Match>2087 <Match when={feedbackState() === 'sending'}>Sending...</Match>2088 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2089 </Switch>2090 </button>20912092 <p class="peach-feedback-anon">2093 Sent anonymously · no account required2094 </p>2095 </>2096 }2097 >2098 <div class="peach-feedback-thanks" role="status" aria-live="polite">2099 <p class="peach-feedback-thanks-title">Thank you</p>2100 <p>2101 Your note is in the queue. Someone will process it soon.2102 </p>2103 </div>2104 </Show>2105 </div>2106 </div>2107 );2108 }21092110 const bodies: StackBodies = {2111 protection: renderSecurity(),2112 vault: renderVault(),2113 sync: renderSync(),2114 appearance: renderAppearance(),2115 shortcuts: renderShortcuts(),2116 pro: renderLicense(),2117 feedback: renderFeedback(),2118 };21192120 return (2121 <Show2122 when={showNewRecoveryWords()}2123 fallback={2124 <div2125 class="peach-overlay-view peach-settings-overlay"2126 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2127 style="display:flex;flex-direction:column"2128 >2129 <StackDeck2130 active={activeCard()}2131 onActiveChange={setActiveCard}2132 onBack={props.onBack}2133 cards={cards()}2134 bodies={bodies}2135 version={version}2136 isFanning={isFanning()}2137 celebratingCardId={justActivated() ? "pro" : null}2138 isPro={isPro()}2139 />2140 </div>2141 }2142 >2143 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2144 </Show>2145 );2146}