1import { createEffect, createMemo, createSignal, For, Match, 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 const [dedupCount, setDedupCount] = createSignal<number | null>(null);262 const [dedupRunning, setDedupRunning] = createSignal(false);263 const [dedupDone, setDedupDone] = createSignal<number | null>(null);264265 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);266267 268 269 270 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);271 const [historyLoading, setHistoryLoading] = createSignal(false);272 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);273 const [historyShowAll, setHistoryShowAll] = createSignal(false);274275 async function loadVaultHistory() {276 setHistoryLoading(true);277 try {278 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });279 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];280 setHistorySnapshots(list);281 } finally {282 setHistoryLoading(false);283 }284 }285286 async function handleRestoreVaultSnapshot(id: string) {287 if (historyRestoring()) return;288 setHistoryRestoring(id);289 const res = await sendMessage({290 type: "RESTORE_VAULT_SNAPSHOT",291 payload: { snapshotId: id },292 });293 setHistoryRestoring(null);294 if ((res as { ok: boolean }).ok) {295 296 297 props.onBack();298 }299 }300301 function formatSnapshotTime(savedAt: number): string {302 const diff = Date.now() - savedAt;303 if (diff < 60_000) return "Just now";304 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;305 const d = new Date(savedAt);306 const isToday = new Date().toDateString() === d.toDateString();307 const isYest =308 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();309 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });310 if (isToday) return `Today ${t}`;311 if (isYest) return `Yesterday ${t}`;312 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;313 }314315 function reasonLabel(reason: VaultSnapshot["reason"]): string {316 return (317 {318 local_save: "saved",319 sync_received: "synced",320 import: "imported",321 restore: "restored",322 } as const323 )[reason] ?? reason;324 }325326 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);327328 const [showChangePw, setShowChangePw] = createSignal(false);329 const [changePwCurrent, setChangePwCurrent] = createSignal("");330 const [changePwNew, setChangePwNew] = createSignal("");331 const [changePwConfirm, setChangePwConfirm] = createSignal("");332 const [changePwSaving, setChangePwSaving] = createSignal(false);333 const [changePwError, setChangePwError] = createSignal<string | null>(null);334 const [changePwSuccess] = createSignal(false);335 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);336337 const [duressEnabled, setDuressEnabled] = createSignal(false);338 const [showDuressSetup, setShowDuressSetup] = createSignal(false);339 const [duressPassword, setDuressPassword] = createSignal("");340 const [duressConfirm, setDuressConfirm] = createSignal("");341 const [duressMainPassword, setDuressMainPassword] = createSignal("");342 const [duressSaving, setDuressSaving] = createSignal(false);343 const [duressError, setDuressError] = createSignal<string | null>(null);344 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);345346 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);347348 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);349 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);350351 const [fbCategory, setFbCategory] = createSignal("Bug report");352 const [fbMessage, setFbMessage] = createSignal("");353 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';354 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');355356 createEffect(() => {357 if (props.upgradeHighlight) {358 setActiveCard("pro");359 }360 });361362 onMount(async () => {363 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([364 sendMessage({ type: "GET_LICENSE" }),365 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),366 sendMessage({ type: "IS_DURESS_ENABLED" }),367 sendMessage({ type: "GET_SETTINGS" }),368 sendMessage({ type: "GET_LICENSE_KEY" }),369 ]);370371 372 373 void loadVaultHistory();374375 if ((licenseRes as { ok: boolean }).ok) {376 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);377 }378379 if ((passkeyRes as { ok: boolean }).ok) {380 setPasskeyState(381 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null382 );383 }384385 if ((duressRes as { ok: boolean }).ok) {386 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);387 }388389 if ((settingsRes as { ok: boolean }).ok) {390 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;391 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);392 }393394 if ((keyRes as { ok: boolean }).ok) {395 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);396 }397398 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });399 if ((entriesRes as { ok: boolean }).ok) {400 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];401 setCanaryEntries(402 entries403 .filter((e) => e.canary?.webhookUrl)404 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))405 );406 }407408409 if (isNativePlatform()) {410 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");411 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");412 try {413 if (biometric) {414 const a = await biometric.isAvailable();415 setBiometricSupported(a.available);416 }417 if (keystore) {418 const k = await keystore.hasMasterKey();419 setBiometricStored(k.exists);420 }421 } catch { }422 }423 });424425 async function handleEnableBiometrics() {426 setBiometricBusy(true);427 setBiometricError(null);428 try {429 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {430 ok: boolean;431 data?: { masterKeyHex: string };432 error?: string;433 };434 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {435 setBiometricError(keyRes.error === "vault_locked"436 ? "unlock the vault first"437 : "couldn't read derived key");438 return;439 }440 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");441 if (!keystore) {442 setBiometricError("biometric plugin unavailable");443 return;444 }445 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });446 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });447 setBiometricStored(true);448 } catch (e) {449 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");450 } finally {451 setBiometricBusy(false);452 }453 }454455 async function handleDisableBiometrics() {456 setBiometricBusy(true);457 setBiometricError(null);458 try {459 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");460 if (keystore) await keystore.clearMasterKey();461 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });462 setBiometricStored(false);463 } catch (e) {464 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");465 } finally {466 setBiometricBusy(false);467 }468 }469470 async function handleChangePassword() {471 setChangePwError(null);472 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }473 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }474 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }475 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }476 setChangePwSaving(true);477 const res = await sendMessage({478 type: "CHANGE_PASSWORD",479 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },480 }) as { ok: boolean; error?: string };481 if (res.ok) {482 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");483 setShowChangePw(false);484 setShowNewRecoveryWords(true);485 } else {486 setChangePwError(res.error ?? "failed to change password");487 }488 setChangePwSaving(false);489 }490491 const [copied, setCopied] = createSignal(false);492493 async function copyLicenseKey(key: string) {494 await navigator.clipboard.writeText(key);495 setCopied(true);496 setTimeout(() => setCopied(false), 2000);497 }498499 async function handleActivateLicense() {500 const key = licenseKeyInput().trim();501 if (!key) return;502 setActivatingLicense(true);503 setLicenseError(null);504 setLicenseSuccess(null);505 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });506 setActivatingLicense(false);507 if ((res as { ok: boolean }).ok) {508 const data = (res as { ok: boolean; data?: LicenseInfo }).data;509 if (data?.valid) {510 setLicenseInfo(data);511 setLicenseKey(key);512 setLicenseSuccess("License activated!");513 setLicenseKeyInput("");514 } else {515 setLicenseError("Invalid license key");516 }517 } else {518 setLicenseError("Activation failed");519 }520 }521522 async function handleTogglePasskeys() {523 const current = passkeyState();524 if (!current || !current.supported) return;525 setPasskeySaving(true);526 const res = await sendMessage({527 type: "SET_PASSKEY_PROVIDER_ENABLED",528 payload: { enabled: !current.enabled },529 });530 setPasskeySaving(false);531 if ((res as { ok: boolean }).ok) {532 setPasskeyState(533 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null534 );535 }536 }537538 async function handleCheckAllBreaches() {539 setCheckingBreaches(true);540 setBreachResult(null);541 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });542 setCheckingBreaches(false);543 if ((res as { ok: boolean }).ok) {544 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};545 const breached = Object.values(data).filter((count) => count > 0).length;546 setBreachResult(547 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"548 );549 props.onBreachCheckDone();550 }551 }552553554 async function handleSetupDuress() {555 const mainPw = duressMainPassword().trim();556 const duressPw = duressPassword().trim();557 const confirmPw = duressConfirm().trim();558559 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }560 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }561 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }562 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }563564 setDuressSaving(true);565 setDuressError(null);566567 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };568 if (!verifyRes.ok || !verifyRes.data) {569 setDuressError("incorrect main password");570 setDuressSaving(false);571 return;572 }573574 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };575 setDuressSaving(false);576 if (res.ok) {577 setDuressEnabled(true);578 setShowDuressSetup(false);579 setDuressPassword("");580 setDuressConfirm("");581 setDuressMainPassword("");582 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");583 setTimeout(() => setDuressSuccess(null), 5000);584 } else {585 setDuressError(586 res.error === "session_expired"587 ? "Vault session expired — lock and unlock Peach, then try again"588 : res.error ?? "failed to set duress password"589 );590 }591 }592593 async function handleClearDeclinedCards() {594 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });595 setDeclinedCardCount(0);596 }597598 async function handleCountDuplicates() {599 setDedupRunning(true);600 setDedupDone(null);601 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };602 setDedupRunning(false);603 if (res.ok) setDedupCount(res.data ?? 0);604 }605606 async function handleDeduplicate() {607 setDedupRunning(true);608 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };609 setDedupRunning(false);610 if (res.ok) {611 setDedupDone(res.data ?? 0);612 setDedupCount(0);613 }614 }615616 async function handleDisableDuress() {617 const mainPw = duressMainPassword().trim();618 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }619620 setDuressSaving(true);621 setDuressError(null);622623 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };624 if (!verifyRes.ok || !verifyRes.data) {625 setDuressError("incorrect main password");626 setDuressSaving(false);627 return;628 }629630 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };631 setDuressSaving(false);632 if (res.ok) {633 setDuressEnabled(false);634 setShowDuressSetup(false);635 setDuressMainPassword("");636 setDuressSuccess("Duress password disabled.");637 setTimeout(() => setDuressSuccess(null), 4000);638 } else {639 setDuressError(res.error ?? "failed to disable duress password");640 }641 }642643 async function handleSubmitFeedback() {644 const message = sanitizeFeedbackMessage(fbMessage());645 if (!message) return;646647 setFeedbackState('sending');648649 try {650 const hasDataPermission = await requestFeedbackDataPermission();651 if (!hasDataPermission) {652 setFeedbackState('idle');653 return;654 }655656 const category = FEEDBACK_CATEGORIES.includes(fbCategory())657 ? fbCategory()658 : FEEDBACK_CATEGORIES[0];659 const res = await fetch('https://api.web3forms.com/submit', {660 method: 'POST',661 headers: { 'Content-Type': 'application/json' },662 body: JSON.stringify({663 access_key: WEB3FORMS_KEY,664 subject: `Peach Feedback - ${category}`,665 message,666 from_name: 'Peach User',667 668 botcheck: '',669 }),670 });671672 if (!res.ok) throw new Error(`HTTP ${res.status}`);673 const data = await res.json() as { success: boolean; message?: string };674 if (!data.success) throw new Error(data.message ?? 'unknown error');675676 setFeedbackState('sent');677 setFbMessage('');678 setFbCategory(FEEDBACK_CATEGORIES[0]);679 } catch (err) {680 console.error('[feedback]', err);681 setFeedbackState('error');682 setTimeout(() => setFeedbackState('idle'), 4000);683 }684 }685686 function renderSecurity() {687 return (688 <div class="peach-settings-body">689 <div class="peach-settings-group">690 <p class="peach-settings-sub-label">LOCKING</p>691 <div class="peach-settings-row">692 <span>Lock on close</span>693 <button694 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}695 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}696 aria-label="Toggle lock on close"697 >698 <span class="peach-toggle-knob"></span>699 </button>700 </div>701 <div class="peach-settings-row">702 <span>Auto-lock after</span>703 <select704 class="peach-settings-select"705 value={String(props.autoLockMinutes)}706 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}707 >708 <option value="1">1 minute</option>709 <option value="5">5 minutes</option>710 <option value="15">15 minutes</option>711 <option value="30">30 minutes</option>712 <option value="0">Never</option>713 </select>714 </div>715716 <Show when={isNativePlatform() && biometricSupported()}>717 <p class="peach-settings-sub-label">BIOMETRICS</p>718 <div class="peach-settings-row">719 <span>Unlock with biometrics</span>720 <button721 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}722 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}723 disabled={biometricBusy()}724 aria-label="Toggle biometric unlock"725 >726 <span class="peach-toggle-knob"></span>727 </button>728 </div>729 <Show when={biometricError()}>730 <div class="peach-settings-row peach-settings-row--meta">731 <span class="peach-hint peach-hint-error">{biometricError()}</span>732 </div>733 </Show>734 </Show>735736 <p class="peach-settings-sub-label">BREACH PROTECTION</p>737 <div class="peach-settings-row">738 <span>Breach monitoring</span>739 <button740 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}741 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}742 aria-label="Toggle breach monitoring"743 >744 <span class="peach-toggle-knob"></span>745 </button>746 </div>747 <div class="peach-settings-row peach-settings-row--meta">748 <span>Check all passwords</span>749 <button750 class="peach-btn peach-btn--subtle peach-btn--sm"751 onClick={() => void handleCheckAllBreaches()}752 disabled={checkingBreaches()}753 >754 {checkingBreaches() ? "checking..." : "run"}755 </button>756 </div>757 <Show when={breachResult()}>758 <div class="peach-settings-row peach-settings-row--meta">759 <span>Last check</span>760 <span class="peach-settings-value">{breachResult()}</span>761 </div>762 </Show>763764 <p class="peach-settings-sub-label">MASTER PASSWORD</p>765 <div class="peach-settings-row peach-settings-row--meta">766 <span>Master password</span>767 <button768 class="peach-btn peach-btn--subtle peach-btn--sm"769 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}770 >771 {showChangePw() ? "cancel" : "change"}772 </button>773 </div>774 <Show when={showChangePw()}>775 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">776 <input777 class="peach-settings-input"778 type="password"779 placeholder="current password"780 value={changePwCurrent()}781 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}782 autocomplete="current-password"783 />784 <input785 class="peach-settings-input"786 type="password"787 placeholder="new password (min 12 chars)"788 value={changePwNew()}789 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}790 autocomplete="new-password"791 />792 <input793 class="peach-settings-input"794 type="password"795 placeholder="confirm new password"796 value={changePwConfirm()}797 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}798 autocomplete="new-password"799 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}800 />801 <Show when={changePwError()}>802 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>803 </Show>804 <Show when={changePwSuccess()}>805 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>806 </Show>807 <button808 class="peach-btn peach-btn--accent peach-btn--sm"809 style="align-self:flex-start"810 onClick={() => void handleChangePassword()}811 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}812 >813 {changePwSaving() ? "saving…" : "save new password"}814 </button>815 </div>816 </Show>817818 <p class="peach-settings-sub-label">819 DURESS PASSWORD820 <Show when={!isPro()}>821 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>822 </Show>823 </p>824 <div class="peach-settings-row peach-settings-row--top">825 <div class="peach-settings-row-main">826 <span>827 Duress password828 <Show when={!isPro()}>829 <span class="peach-pro-badge">Pro</span>830 </Show>831 </span>832 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">833 If entered under coercion, shows a decoy empty vault instead of your real one.834 </span>835 </div>836 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>837 {duressEnabled() ? "on" : "off"}838 </div>839 </div>840841 <Show when={duressEnabled() && !showDuressSetup()}>842 <div class="peach-settings-row peach-settings-row--meta">843 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">844 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.845 </span>846 </div>847 </Show>848849 <Show when={duressSuccess()}>850 <div class="peach-settings-row peach-settings-row--meta">851 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>852 </div>853 </Show>854855 <Show when={!showDuressSetup()}>856 <div class="peach-settings-row peach-settings-row--meta">857 <button858 class="peach-btn peach-btn--subtle peach-btn--sm"859 onClick={() => {860 861 862 863 864 865 if (!isPro() && !duressEnabled()) {866 props.onProGate?.("duress_vault");867 setActiveCard("pro");868 return;869 }870 setShowDuressSetup(true);871 setDuressError(null);872 setDuressSuccess(null);873 setDuressPassword("");874 setDuressConfirm("");875 setDuressMainPassword("");876 }}877 >878 {duressEnabled() ? "Change / disable" : "Set up"}879 </button>880 </div>881 </Show>882883 <Show when={showDuressSetup()}>884 <div class="peach-duress-form">885 <label class="peach-label">main password (to confirm)</label>886 <input887 class="peach-settings-input"888 type="password"889 placeholder="your current master password"890 autocomplete="current-password"891 value={duressMainPassword()}892 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}893 />894 <Show when={!duressEnabled()}>895 <label class="peach-label">new duress password</label>896 <input897 class="peach-settings-input"898 type="password"899 placeholder="duress password (min 8 chars)"900 autocomplete="new-password"901 value={duressPassword()}902 onInput={(e) => setDuressPassword(e.currentTarget.value)}903 />904 <label class="peach-label">confirm duress password</label>905 <input906 class="peach-settings-input"907 type="password"908 placeholder="confirm duress password"909 autocomplete="new-password"910 value={duressConfirm()}911 onInput={(e) => setDuressConfirm(e.currentTarget.value)}912 />913 </Show>914915 <Show when={duressError()}>916 <p class="peach-hint peach-hint-error">{duressError()}</p>917 </Show>918919 <div class="peach-duress-actions">920 <button921 class="peach-btn peach-btn--subtle peach-btn--sm"922 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}923 >924 cancel925 </button>926 <Show when={duressEnabled()}>927 <button928 class="peach-btn peach-btn--ghost peach-btn--sm"929 style="color:var(--peach-breach)"930 onClick={() => void handleDisableDuress()}931 disabled={duressSaving()}932 >933 {duressSaving() ? "disabling..." : "disable duress"}934 </button>935 </Show>936 <Show when={!duressEnabled()}>937 <button938 class="peach-btn peach-btn--accent peach-btn--sm"939 onClick={() => void handleSetupDuress()}940 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}941 >942 {duressSaving() ? "saving..." : "save"}943 </button>944 </Show>945 </div>946 </div>947 </Show>948949 <p class="peach-settings-sub-label">950 CANARY ENTRIES951 <Show when={!isPro()}>952 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>953 </Show>954 </p>955 <div class="peach-settings-row peach-settings-row--top">956 <div class="peach-settings-row-main">957 <span>958 Canary entries959 <Show when={!isPro()}>960 <span class="peach-pro-badge">Pro</span>961 </Show>962 </span>963 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">964 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.965 </span>966 </div>967 <span class="peach-settings-value" style="flex-shrink:0">968 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}969 </span>970 </div>971 <Show when={canaryEntries().length > 0}>972 <For each={canaryEntries()}>973 {(entry) => (974 <div class="peach-settings-row peach-settings-row--meta">975 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>976 <button977 class="peach-btn peach-btn--subtle peach-btn--sm"978 onClick={() => void handleTestCanary(entry)}979 disabled={canaryTestingId() === entry.id}980 style="flex-shrink:0"981 >982 {canaryTestingId() === entry.id983 ? "sending…"984 : canaryTestResult()?.id === entry.id985 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")986 : "test"}987 </button>988 </div>989 )}990 </For>991 </Show>992993 <p class="peach-settings-sub-label">PASSKEYS</p>994 <div class="peach-settings-row peach-settings-row--top">995 <div class="peach-settings-row-main">996 <span>Intercept passkey requests</span>997 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">998 Peach can handle passkey sign-in and creation on supported Chromium browsers.999 </span>1000 </div>1001 <button1002 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1003 onClick={() => void handleTogglePasskeys()}1004 aria-label="Toggle passkey interception"1005 disabled={!passkeyState()?.supported || passkeySaving()}1006 >1007 <span class="peach-toggle-knob"></span>1008 </button>1009 </div>1010 <div class="peach-settings-row peach-settings-row--meta">1011 <span>Status</span>1012 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1013 </div>1014 <div class="peach-settings-row peach-settings-row--meta">1015 <span>Browser</span>1016 <span class="peach-settings-value">1017 {passkeyState()?.browserLabel ?? "Unknown"}1018 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1019 </span>1020 </div>1021 <div class="peach-info-notice" style="margin-top:8px">1022 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1023 </div>1024 <Show when={passkeyState()?.conflict}>1025 <div class="peach-settings-row peach-settings-row--meta">1026 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1027 </div>1028 </Show>1029 </div>1030 </div>1031 );1032 }10331034 function renderVault() {1035 return (1036 <div class="peach-settings-body">1037 <div class="peach-settings-group">1038 <p class="peach-settings-sub-label">ENTRIES</p>1039 <div class="peach-settings-row peach-settings-row--meta">1040 <span>Entries</span>1041 <span class="peach-settings-value">{props.entriesCount}</span>1042 </div>1043 <div class="peach-settings-row peach-settings-row--meta">1044 <span>Duplicates</span>1045 <Show when={dedupDone() !== null} fallback={1046 <button1047 class="peach-btn peach-btn--subtle peach-btn--sm"1048 onClick={() => void handleCountDuplicates()}1049 disabled={dedupRunning()}1050 >1051 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1052 </button>1053 }>1054 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1055 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1056 </span>1057 </Show>1058 </div>1059 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1060 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1061 <div class="peach-restore-warning">1062 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1063 </div>1064 <div style="display:flex;gap:6px">1065 <button1066 class="peach-btn peach-btn--subtle peach-btn--sm"1067 onClick={() => setDedupCount(null)}1068 >1069 cancel1070 </button>1071 <button1072 class="peach-btn peach-btn--accent peach-btn--sm"1073 onClick={() => void handleDeduplicate()}1074 disabled={dedupRunning()}1075 >1076 {dedupRunning() ? "removing..." : "remove duplicates"}1077 </button>1078 </div>1079 </div>1080 </Show>10811082 <p class="peach-settings-sub-label">MAINTENANCE</p>1083 <div class="peach-settings-row peach-settings-row--meta">1084 <span>Trash auto-delete</span>1085 <select1086 class="peach-settings-select"1087 value={String(props.trashRetentionDays)}1088 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1089 >1090 <option value="7">7 days</option>1091 <option value="30">30 days</option>1092 <option value="90">90 days</option>1093 <option value="0">Never</option>1094 </select>1095 </div>1096 <div class="peach-settings-row peach-settings-row--meta">1097 <span>Advanced Vault Health</span>1098 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1099 open1100 </button>1101 </div>1102 <Show when={declinedCardCount() > 0}>1103 <div class="peach-settings-row peach-settings-row--meta">1104 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1105 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1106 Clear1107 </button>1108 </div>1109 </Show>11101111 <p class="peach-settings-sub-label">DATA</p>1112 <div class="peach-settings-row peach-settings-row--meta">1113 <span>Import passwords</span>1114 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1115 open1116 </button>1117 </div>1118 <div class="peach-settings-row peach-settings-row--meta">1119 <span>Export backup</span>1120 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1121 open1122 </button>1123 </div>1124 <div class="peach-settings-row peach-settings-row--meta">1125 <span>1126 Peach Codex1127 <Show when={!isPro()}>1128 <span class="peach-pro-badge">Pro</span>1129 </Show>1130 </span>1131 <button1132 class="peach-btn peach-btn--subtle peach-btn--sm"1133 onClick={() => props.onOpenCodexGenerate?.()}1134 >1135 open1136 </button>1137 </div>1138 {1139}1140 <div class="peach-settings-row peach-settings-row--meta">1141 <span>Restore from Codex</span>1142 <button1143 class="peach-btn peach-btn--subtle peach-btn--sm"1144 onClick={() => props.onOpenCodexRestore?.()}1145 >1146 open1147 </button>1148 </div>11491150 <span class="peach-settings-sub-label">VAULT HISTORY</span>1151 <Show when={historyLoading()}>1152 <p class="peach-settings-hint">Loading…</p>1153 </Show>1154 <Show when={!historyLoading() && historySnapshots().length === 0}>1155 <p class="peach-settings-hint">1156 No snapshots yet. Peach saves automatically before every change —1157 saves, syncs, and imports.1158 </p>1159 </Show>1160 <Show when={!historyLoading() && historySnapshots().length > 0}>1161 <p class="peach-settings-hint">1162 Restoring locks your vault. Unlock with the password that1163 encrypted that snapshot to confirm.1164 </p>1165 <div class="peach-history-list">1166 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1167 {(snap) => (1168 <div class="peach-history-row">1169 <div class="peach-history-meta">1170 <span class="peach-history-time">1171 {formatSnapshotTime(snap.savedAt)}1172 </span>1173 <span class="peach-history-sub">1174 {[1175 snap.entryCount !== null1176 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1177 : null,1178 reasonLabel(snap.reason),1179 ]1180 .filter(Boolean)1181 .join(" · ")}1182 </span>1183 </div>1184 <button1185 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1186 historyRestoring() === snap.id ? " is-busy" : ""1187 }`}1188 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1189 disabled={historyRestoring() !== null}1190 >1191 <Show when={historyRestoring() === snap.id} fallback="Restore">1192 …1193 </Show>1194 </button>1195 </div>1196 )}1197 </For>1198 </div>1199 <Show when={historySnapshots().length > 3}>1200 <button1201 class="peach-settings-advanced-toggle"1202 onClick={() => setHistoryShowAll((v) => !v)}1203 aria-expanded={historyShowAll()}1204 >1205 <span class="peach-settings-hint" style="margin:0">1206 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1207 </span>1208 <span class="peach-settings-advanced-chevron">1209 {historyShowAll() ? "▲" : "▼"}1210 </span>1211 </button>1212 </Show>1213 <p class="peach-history-footer">1214 {historySnapshots().length} of 10 snapshots1215 </p>1216 </Show>12171218 <button1219 class="peach-settings-advanced-toggle"1220 onClick={() => setShowVaultAdvanced((v) => !v)}1221 aria-expanded={showVaultAdvanced()}1222 >1223 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1224 <span class="peach-settings-advanced-chevron">1225 {showVaultAdvanced() ? "▲" : "▼"}1226 </span>1227 </button>12281229 <Show when={showVaultAdvanced()}>1230 <div class="peach-danger-card">1231 <div class="peach-danger-card-head">1232 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1233 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1234 </div>1235 <p class="peach-danger-card-copy">1236 Wipes the current vault and restores from a backup file.1237 Recoverable only if your backup is good.1238 </p>1239 <button1240 class="peach-danger-card-button"1241 onClick={() => props.onOpenFileImport?.()}1242 >1243 Choose backup file1244 </button>1245 </div>12461247 <Show when={props.onOpenPanic}>1248 <div class="peach-danger-card peach-danger-card--hazard">1249 <div class="peach-danger-card-head">1250 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1251 <h3 class="peach-danger-card-title">Destroy vault</h3>1252 </div>1253 <p class="peach-danger-card-copy">1254 Permanently wipes every entry, setting, and license on this1255 device. There is no recovery.1256 </p>1257 <button1258 class="peach-danger-card-button"1259 onClick={() => props.onOpenPanic?.()}1260 >1261 Begin destruction1262 </button>1263 </div>1264 </Show>1265 </Show>1266 </div>1267 </div>1268 );1269 }12701271 function renderSync() {1272 return (1273 <div class="peach-settings-body">1274 <div class="peach-settings-group">1275 <p class="peach-settings-sub-label">PAIRING</p>1276 <div class="peach-settings-row peach-settings-row--top">1277 <div class="peach-settings-row-main">1278 <span>1279 Peach Sync1280 <Show when={!isPro()}>1281 <span class="peach-pro-badge">Pro</span>1282 </Show>1283 </span>1284 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1285 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1286 </span>1287 </div>1288 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1289 </div>1290 <div class="peach-settings-row peach-settings-row--meta">1291 <span>Status</span>1292 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1293 </div>1294 <div class="peach-settings-row peach-settings-row--meta">1295 <span>Sync hub</span>1296 <button1297 class="peach-btn peach-btn--subtle peach-btn--sm"1298 onClick={() => props.onOpenSyncPairing?.()}1299 >1300 open1301 </button>1302 </div>13031304 <p class="peach-settings-sub-label">DEVICE FLOW</p>1305 <div class="peach-settings-row peach-settings-row--top">1306 <div class="peach-settings-row-main">1307 <span>Pair new device</span>1308 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1309 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1310 </span>1311 </div>1312 <button1313 class="peach-btn peach-btn--subtle peach-btn--sm"1314 onClick={() => props.onOpenSyncPairing?.()}1315 >1316 pair1317 </button>1318 </div>1319 <div class="peach-info-notice" style="margin-top:8px">1320 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1321 </div>1322 </div>1323 </div>1324 );1325 }13261327 function renderAppearance() {1328 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1329 { id: "medium", label: "Default" },1330 { id: "large", label: "Large" },1331 ];1332 return (1333 <div class="peach-settings-body">1334 <div class="peach-settings-group">1335 <p class="peach-settings-sub-label">THEME</p>1336 <div class="peach-settings-row peach-settings-row--meta">1337 <span>Theme</span>1338 <button1339 class="peach-btn peach-btn--subtle peach-btn--sm"1340 onClick={() =>1341 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1342 }1343 >1344 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1345 </button>1346 </div>13471348 <p class="peach-settings-sub-label">ACCENT</p>1349 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1350 <span>Accent</span>1351 <div class="peach-accent-picker">1352 <For each={ACCENT_COLORS}>1353 {(color) => (1354 <button1355 class="peach-accent-swatch peach-swatch"1356 classList={{ "is-active": props.accentColor === color.name }}1357 style={{ background: color.hex }}1358 onClick={() => void props.onAccentChange(color.name)}1359 aria-label={color.name}1360 title={color.name}1361 />1362 )}1363 </For>1364 </div>1365 </div>13661367 <p class="peach-settings-sub-label">TEXT SIZE</p>1368 <div class="peach-settings-row peach-settings-row--meta">1369 <span>Size</span>1370 <div class="peach-pill-group">1371 <For each={sizeOptions}>1372 {(opt) => (1373 <button1374 class="peach-btn peach-btn--ghost peach-btn--sm"1375 classList={{ "is-active": props.textSize === opt.id }}1376 onClick={() => void props.onTextSizeChange(opt.id)}1377 >1378 {opt.label}1379 </button>1380 )}1381 </For>1382 </div>1383 </div>13841385 <p class="peach-settings-sub-label">MOTION</p>1386 <div class="peach-settings-row peach-settings-row--meta">1387 <span>Reduce motion</span>1388 <button1389 class="peach-btn peach-btn--subtle peach-btn--sm"1390 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1391 >1392 {props.reduceMotion ? "On" : "Off"}1393 </button>1394 </div>1395 </div>1396 </div>1397 );1398 }13991400 function renderShortcuts() {1401 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1402 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);14031404 function startCapture(action: ShortcutAction) {1405 setConflictMsg(null);1406 setCapturingAction(action);1407 }1408 function cancelCapture() {1409 setCapturingAction(null);1410 setConflictMsg(null);1411 }1412 function commitBinding(action: ShortcutAction, binding: Keybinding) {1413 const conflict = findConflict(props.shortcuts, binding, action);1414 if (conflict) {1415 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1416 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1417 return;1418 }1419 const next = { ...props.shortcuts, [action]: binding };1420 setCapturingAction(null);1421 setConflictMsg(null);1422 void props.onShortcutsChange(next);1423 }1424 function resetAction(action: ShortcutAction) {1425 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1426 void props.onShortcutsChange(next);1427 }1428 function resetAll() {1429 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1430 }14311432 1433 1434 function handleCaptureKey(e: KeyboardEvent) {1435 e.preventDefault();1436 e.stopPropagation();1437 if (e.key === "Escape") { cancelCapture(); return; }1438 const binding = keybindingFromEvent(e);1439 if (!binding) return; 1440 const action = capturingAction();1441 if (action) commitBinding(action, binding);1442 }14431444 return (1445 <div class="peach-settings-body">1446 <div class="peach-settings-group peach-shortcuts-group">1447 <p class="peach-settings-sub-label">ENTRY VIEW</p>1448 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1449 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1450 </For>1451 </div>14521453 <div class="peach-settings-group peach-shortcuts-group">1454 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1455 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1456 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1457 </For>1458 </div>14591460 <Show when={conflictMsg()}>1461 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1462 </Show>14631464 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1465 <div class="peach-settings-row peach-settings-row--meta">1466 <span>Inline autofill on page</span>1467 <a1468 class="peach-settings-link"1469 href="chrome://extensions/shortcuts"1470 onClick={(e) => {1471 e.preventDefault();1472 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1473 }}1474 >1475 ⌘⇧P — customize ↗1476 </a>1477 </div>1478 <button1479 class="peach-btn peach-btn--subtle peach-btn--sm"1480 style="align-self:flex-start"1481 onClick={resetAll}1482 >1483 Reset all to defaults1484 </button>1485 </div>1486 </div>1487 );14881489 function renderRow(1490 desc: typeof SHORTCUT_DESCRIPTORS[number],1491 capturing: () => ShortcutAction | null,1492 onStart: (a: ShortcutAction) => void,1493 onCancel: () => void,1494 onReset: (a: ShortcutAction) => void,1495 onKey: (e: KeyboardEvent) => void,1496 ) {1497 const isActive = () => capturing() === desc.action;1498 const binding = () => props.shortcuts[desc.action];1499 const isCustom = () => {1500 const b = binding();1501 const d = DEFAULT_BINDINGS[desc.action];1502 return b.key !== d.key1503 || Boolean(b.ctrl) !== Boolean(d.ctrl)1504 || Boolean(b.meta) !== Boolean(d.meta)1505 || Boolean(b.shift) !== Boolean(d.shift)1506 || Boolean(b.alt) !== Boolean(d.alt);1507 };1508 return (1509 <div class="peach-shortcut-row">1510 <span class="peach-shortcut-row-label">{desc.label}</span>1511 <button1512 class="peach-shortcut-chip"1513 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1514 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1515 onKeyDown={(e) => { if (isActive()) onKey(e); }}1516 ref={(el) => {1517 1518 if (el) {1519 queueMicrotask(() => { if (isActive()) el.focus(); });1520 }1521 }}1522 >1523 {isActive() ? "Press a key…" : formatKeybinding(binding())}1524 </button>1525 <Show when={isCustom() && !isActive()}>1526 <button1527 class="peach-shortcut-reset"1528 title="Reset to default"1529 onClick={() => onReset(desc.action)}1530 >1531 ↺1532 </button>1533 </Show>1534 </div>1535 );1536 }1537 }15381539 const PRO_FEATURES: Array<{1540 feature: ProFeature;1541 name: string;1542 desc: string;1543 }> = [1544 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1545 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1546 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1547 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1548 ];15491550 const PRO_SECURITY_FEATURES: Array<{1551 feature: ProFeature;1552 name: string;1553 desc: string;1554 }> = [1555 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1556 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1557 ];15581559 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1560 const [showKey, setShowKey] = createSignal(false);15611562 const [deactivating, setDeactivating] = createSignal(false);15631564 async function handleDeactivateLicense() {1565 setDeactivating(true);1566 await sendMessage({ type: "DEACTIVATE_LICENSE" });1567 setLicenseInfo(null);1568 await props.onLicenseChange?.();1569 setDeactivating(false);1570 }15711572 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1573 setCanaryTestingId(entry.id);1574 setCanaryTestResult(null);1575 const res = await sendMessage({1576 type: "FIRE_CANARY_PING",1577 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1578 }) as { ok: boolean };1579 setCanaryTestResult({ id: entry.id, ok: res.ok });1580 setCanaryTestingId(null);1581 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1582 }15831584 function renderLicense() {1585 async function onActivate() {1586 await handleActivateLicense();1587 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1588 await props.onLicenseChange?.();1589 }1590 }15911592 return (1593 <div class="peach-settings-body">1594 <Show1595 when={isPro()}1596 fallback={1597 <div class="peach-pro-upgrade">1598 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15991600 <h2 class="peach-pro-headline">1601 Every<em>thing,</em><br />every<em>where.</em>1602 </h2>16031604 <p class="peach-pro-subtitle">1605 One key unlocks every Pro feature — for the lifetime of the app.1606 </p>16071608 <div class="peach-pro-grid">1609 <For each={PRO_FEATURES}>1610 {(item, i) => (1611 <div1612 class="peach-pro-grid-item"1613 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1614 >1615 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1616 <span class="peach-pro-grid-name">{item.name}</span>1617 <span class="peach-pro-grid-desc">{item.desc}</span>1618 </div>1619 )}1620 </For>1621 </div>16221623 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1624 <div class="peach-pro-grid">1625 <For each={PRO_SECURITY_FEATURES}>1626 {(item, i) => (1627 <div1628 class="peach-pro-grid-item"1629 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1630 >1631 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1632 <span class="peach-pro-grid-name">{item.name}</span>1633 <span class="peach-pro-grid-desc">{item.desc}</span>1634 </div>1635 )}1636 </For>1637 </div>16381639 <div class="peach-pro-upgrade-actions">1640 <p class="peach-pro-pricing">1641 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1642 </p>1643 <a1644 href="https://peachpasswords.com/#pricing"1645 target="_blank"1646 rel="noopener noreferrer"1647 class="peach-pro-upgrade-cta"1648 >1649 Get Peach Pro1650 </a>1651 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1652 <div class="peach-pro-key-inline">1653 <input1654 class="peach-pro-key-inline-input"1655 type="text"1656 placeholder="PEACH-XXXX-XXXX-XXXX"1657 value={licenseKeyInput()}1658 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1659 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1660 autocomplete="off"1661 spellcheck={false}1662 />1663 <button1664 class="peach-pro-key-apply"1665 onClick={() => void onActivate()}1666 disabled={activatingLicense() || !licenseKeyInput().trim()}1667 >1668 {activatingLicense() ? "…" : "APPLY"}1669 </button>1670 </div>1671 <Show when={licenseError()}>1672 <p class="peach-pro-key-error">{licenseError()}</p>1673 </Show>1674 </div>1675 </div>1676 }1677 >1678 <div class="peach-pro-active">1679 <div class="peach-pro-active-hero">1680 <button1681 class="peach-pro-active-mark-wrap"1682 type="button"1683 aria-label="Celebrate"1684 onClick={handleProCelebrationClick}1685 >1686 <PeachMark class="peach-pro-active-mark" />1687 <Show when={justActivated()}>1688 <div class="peach-pro-sparks" aria-hidden="true">1689 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>1690 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>1691 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>1692 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>1693 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>1694 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>1695 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>1696 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>1697 </div>1698 </Show>1699 </button>1700 <h2 class="peach-pro-active-title">Peach Pro</h2>1701 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1702 <Show when={justActivated()}>1703 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>1704 </Show>1705 </div>17061707 <Show when={licenseKey()}>1708 <div class="peach-settings-license">1709 <span class="peach-settings-label">LICENSE KEY</span>1710 <div class="peach-settings-license-row">1711 <Show1712 when={showKey()}1713 fallback={1714 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1715 ••••••••••••••••••••••••••••1716 </span>1717 }1718 >1719 <span class="peach-settings-license-key">{licenseKey()!}</span>1720 </Show>1721 <div class="peach-settings-license-actions">1722 <button1723 class="peach-settings-license-copy"1724 title="Copy license key"1725 onClick={() => copyLicenseKey(licenseKey()!)}1726 >1727 {copied() ? "✓" : "copy"}1728 </button>1729 <button1730 class="peach-settings-license-toggle"1731 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1732 title={showKey() ? "Hide" : "Reveal"}1733 onClick={() => setShowKey(v => !v)}1734 >1735 <Show1736 when={showKey()}1737 fallback={1738 <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">1739 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1740 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1741 </svg>1742 }1743 >1744 <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">1745 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1746 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1747 <line x1="2" y1="2" x2="12" y2="12" />1748 </svg>1749 </Show>1750 </button>1751 </div>1752 </div>1753 </div>1754 </Show>17551756 <div class="peach-pro-active-features">1757 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1758 {(item) => (1759 <div class="peach-pro-active-feature">1760 <span class="peach-pro-active-feature-mark">✦</span>1761 <span class="peach-pro-active-feature-name">{item.name}</span>1762 </div>1763 )}1764 </For>1765 </div>17661767 <button1768 class="peach-pro-deactivate"1769 onClick={() => void handleDeactivateLicense()}1770 disabled={deactivating()}1771 >1772 {deactivating() ? "Deactivating…" : "Deactivate"}1773 </button>1774 </div>1775 </Show>1776 </div>1777 );1778 }17791780 function renderFeedback() {1781 return (1782 <div class="peach-settings-body">1783 <div class="peach-settings-group peach-feedback-group">1784 <Show1785 when={feedbackState() === 'sent'}1786 fallback={1787 <>1788 <p class="peach-feedback-intro">1789 Help us improve Peach. Every report goes directly to our inbox.1790 </p>17911792 <p class="peach-settings-sub-label">TYPE</p>1793 <div class="peach-feedback-chips">1794 <For each={FEEDBACK_CATEGORIES}>1795 {(cat) => {1796 const selected = () => fbCategory() === cat;1797 return (1798 <button1799 type="button"1800 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1801 aria-pressed={selected()}1802 onClick={() => setFbCategory(cat)}1803 >1804 {cat}1805 </button>1806 );1807 }}1808 </For>1809 </div>18101811 <p class="peach-settings-sub-label">MESSAGE</p>1812 <textarea1813 class="peach-feedback-textarea"1814 placeholder="Describe the issue or idea..."1815 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1816 value={fbMessage()}1817 onInput={(e) => setFbMessage(e.currentTarget.value)}1818 />18191820 <p class="peach-feedback-meta">1821 Version {version} · will be included automatically1822 </p>18231824 <button1825 type="button"1826 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1827 disabled={!fbMessage().trim() || feedbackState() === 'sending'}1828 onClick={() => void handleSubmitFeedback()}1829 >1830 <Switch>1831 <Match when={feedbackState() === 'idle'}>Send</Match>1832 <Match when={feedbackState() === 'sending'}>Sending...</Match>1833 <Match when={feedbackState() === 'error'}>Failed - try again</Match>1834 </Switch>1835 </button>18361837 <p class="peach-feedback-anon">1838 Sent anonymously · no account required1839 </p>1840 </>1841 }1842 >1843 <div class="peach-feedback-thanks" role="status" aria-live="polite">1844 <p class="peach-feedback-thanks-title">Thank you</p>1845 <p>1846 Your note is in the queue. Someone will process it soon.1847 </p>1848 </div>1849 </Show>1850 </div>1851 </div>1852 );1853 }18541855 const bodies: StackBodies = {1856 protection: renderSecurity(),1857 vault: renderVault(),1858 sync: renderSync(),1859 appearance: renderAppearance(),1860 shortcuts: renderShortcuts(),1861 pro: renderLicense(),1862 feedback: renderFeedback(),1863 };18641865 return (1866 <Show1867 when={showNewRecoveryWords()}1868 fallback={1869 <div1870 class="peach-overlay-view peach-settings-overlay"1871 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1872 style="display:flex;flex-direction:column"1873 >1874 <StackDeck1875 active={activeCard()}1876 onActiveChange={setActiveCard}1877 onBack={props.onBack}1878 cards={cards()}1879 bodies={bodies}1880 version={version}1881 isFanning={isFanning()}1882 celebratingCardId={justActivated() ? "pro" : null}1883 isPro={isPro()}1884 />1885 </div>1886 }1887 >1888 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1889 </Show>1890 );1891}