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;7879function sanitizeFeedbackMessage(value: string): string {80 return value81 .trim()82 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)83 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")84 .replace(/&/g, "&")85 .replace(/</g, "<")86 .replace(/>/g, ">")87 .replace(/"/g, """)88 .replace(/'/g, "'");89}9091type DataCollectionPermissionApi = {92 permissions?: {93 getAll?: () => Promise<{ data_collection?: string[] }>;94 request?: (permissions: { data_collection: string[] }) => Promise<boolean>;95 };96};9798async function requestFeedbackDataPermission(): Promise<boolean> {99 const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??100 (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;101 if (!api?.permissions?.getAll) return true;102103 try {104 const permissions = await api.permissions.getAll();105 if (!("data_collection" in permissions)) return true;106 if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;107 return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));108 } catch {109 return false;110 }111}112113function formatActivatedSince(issued: string | undefined): string {114 if (!issued) return "Active";115 try {116 const d = new Date(issued);117 if (Number.isNaN(d.getTime())) return "Active";118 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;119 } catch {120 return "Active";121 }122}123124function initialSectionToCard(value: string | null | undefined): CardId | null {125 if (!value) return null;126 if (value === "security") return "protection";127 if (value === "license") return "pro";128 if (129 value === "protection" ||130 value === "vault" ||131 value === "sync" ||132 value === "appearance" ||133 value === "shortcuts" ||134 value === "pro" ||135 value === "feedback"136 ) {137 return value;138 }139 return null;140}141142export function SettingsView(props: SettingsProps) {143 const manifest = chrome.runtime.getManifest();144 const version = manifest.version;145 const initial = props.initialSection;146 const [activeCard, setActiveCard] = createSignal<CardId>(147 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"148 );149150 createEffect(() => { props.onCardChange?.(activeCard()); });151152 const [checkingBreaches, setCheckingBreaches] = createSignal(false);153 const [breachResult, setBreachResult] = createSignal<string | null>(null);154 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);155 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");156 const [licenseError, setLicenseError] = createSignal<string | null>(null);157 const [, setLicenseSuccess] = createSignal<string | null>(null);158 const [activatingLicense, setActivatingLicense] = createSignal(false);159160 const isPro = () => props.isPro ?? false;161 const cards = createMemo<StackCardSpec[]>(() => [162 {163 id: "protection",164 num: "I · PROTECT",165 title: "Protection",166 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,167 bgVar: "--stack-protect-bg",168 fgVar: "--stack-protect-fg",169 icon: "shield",170 },171 {172 id: "vault",173 num: "II · KEEP",174 title: "Vault",175 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,176 bgVar: "--stack-vault-bg",177 fgVar: "--stack-vault-fg",178 icon: "lock",179 },180 {181 id: "sync",182 num: "III · ECHO",183 title: "Sync",184 lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",185 bgVar: "--stack-sync-bg",186 fgVar: "--stack-sync-fg",187 icon: "sync",188 },189 {190 id: "appearance",191 num: "IV · DRESS",192 title: "Appearance",193 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,194 bgVar: "--stack-appear-bg",195 fgVar: "--stack-appear-fg",196 icon: "sun",197 },198 {199 id: "shortcuts",200 num: "V · KEYS",201 title: "Shortcuts",202 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,203 bgVar: "--stack-shortcut-bg",204 fgVar: "--stack-shortcut-fg",205 icon: "keys",206 },207 {208 id: "pro",209 num: "VI · FOREVER",210 title: "Peach Pro",211 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",212 bgVar: "--stack-pro-bg",213 fgVar: "--stack-pro-fg",214 icon: "spark",215 },216 {217 id: "feedback",218 num: "VII · ECHO",219 title: "Feedback",220 lede: `version ${version} · tell us what would make it better`,221 bgVar: "--stack-feedback-bg",222 fgVar: "--stack-feedback-fg",223 icon: "bubble",224 },225 ]);226 const [biometricSupported, setBiometricSupported] = createSignal(false);227 const [biometricStored, setBiometricStored] = createSignal(false);228 const [biometricBusy, setBiometricBusy] = createSignal(false);229 const [biometricError, setBiometricError] = createSignal<string | null>(null);230 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);231 const [passkeySaving, setPasskeySaving] = createSignal(false);232233 const [dedupCount, setDedupCount] = createSignal<number | null>(null);234 const [dedupRunning, setDedupRunning] = createSignal(false);235 const [dedupDone, setDedupDone] = createSignal<number | null>(null);236237 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);238239 240 241 242 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);243 const [historyLoading, setHistoryLoading] = createSignal(false);244 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);245 const [historyShowAll, setHistoryShowAll] = createSignal(false);246247 async function loadVaultHistory() {248 setHistoryLoading(true);249 try {250 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });251 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];252 setHistorySnapshots(list);253 } finally {254 setHistoryLoading(false);255 }256 }257258 async function handleRestoreVaultSnapshot(id: string) {259 if (historyRestoring()) return;260 setHistoryRestoring(id);261 const res = await sendMessage({262 type: "RESTORE_VAULT_SNAPSHOT",263 payload: { snapshotId: id },264 });265 setHistoryRestoring(null);266 if ((res as { ok: boolean }).ok) {267 268 269 props.onBack();270 }271 }272273 function formatSnapshotTime(savedAt: number): string {274 const diff = Date.now() - savedAt;275 if (diff < 60_000) return "Just now";276 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;277 const d = new Date(savedAt);278 const isToday = new Date().toDateString() === d.toDateString();279 const isYest =280 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();281 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });282 if (isToday) return `Today ${t}`;283 if (isYest) return `Yesterday ${t}`;284 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;285 }286287 function reasonLabel(reason: VaultSnapshot["reason"]): string {288 return (289 {290 local_save: "saved",291 sync_received: "synced",292 import: "imported",293 restore: "restored",294 } as const295 )[reason] ?? reason;296 }297298 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);299300 const [showChangePw, setShowChangePw] = createSignal(false);301 const [changePwCurrent, setChangePwCurrent] = createSignal("");302 const [changePwNew, setChangePwNew] = createSignal("");303 const [changePwConfirm, setChangePwConfirm] = createSignal("");304 const [changePwSaving, setChangePwSaving] = createSignal(false);305 const [changePwError, setChangePwError] = createSignal<string | null>(null);306 const [changePwSuccess] = createSignal(false);307 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);308309 const [duressEnabled, setDuressEnabled] = createSignal(false);310 const [showDuressSetup, setShowDuressSetup] = createSignal(false);311 const [duressPassword, setDuressPassword] = createSignal("");312 const [duressConfirm, setDuressConfirm] = createSignal("");313 const [duressMainPassword, setDuressMainPassword] = createSignal("");314 const [duressSaving, setDuressSaving] = createSignal(false);315 const [duressError, setDuressError] = createSignal<string | null>(null);316 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);317318 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);319320 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);321 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);322323 const [fbCategory, setFbCategory] = createSignal("Bug report");324 const [fbMessage, setFbMessage] = createSignal("");325 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';326 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');327328 createEffect(() => {329 if (props.upgradeHighlight) {330 setActiveCard("pro");331 }332 });333334 onMount(async () => {335 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([336 sendMessage({ type: "GET_LICENSE" }),337 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),338 sendMessage({ type: "IS_DURESS_ENABLED" }),339 sendMessage({ type: "GET_SETTINGS" }),340 sendMessage({ type: "GET_LICENSE_KEY" }),341 ]);342343 344 345 void loadVaultHistory();346347 if ((licenseRes as { ok: boolean }).ok) {348 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);349 }350351 if ((passkeyRes as { ok: boolean }).ok) {352 setPasskeyState(353 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null354 );355 }356357 if ((duressRes as { ok: boolean }).ok) {358 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);359 }360361 if ((settingsRes as { ok: boolean }).ok) {362 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;363 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);364 }365366 if ((keyRes as { ok: boolean }).ok) {367 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);368 }369370 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });371 if ((entriesRes as { ok: boolean }).ok) {372 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];373 setCanaryEntries(374 entries375 .filter((e) => e.canary?.webhookUrl)376 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))377 );378 }379380381 if (isNativePlatform()) {382 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");383 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");384 try {385 if (biometric) {386 const a = await biometric.isAvailable();387 setBiometricSupported(a.available);388 }389 if (keystore) {390 const k = await keystore.hasMasterKey();391 setBiometricStored(k.exists);392 }393 } catch { }394 }395 });396397 async function handleEnableBiometrics() {398 setBiometricBusy(true);399 setBiometricError(null);400 try {401 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {402 ok: boolean;403 data?: { masterKeyHex: string };404 error?: string;405 };406 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {407 setBiometricError(keyRes.error === "vault_locked"408 ? "unlock the vault first"409 : "couldn't read derived key");410 return;411 }412 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");413 if (!keystore) {414 setBiometricError("biometric plugin unavailable");415 return;416 }417 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });418 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });419 setBiometricStored(true);420 } catch (e) {421 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");422 } finally {423 setBiometricBusy(false);424 }425 }426427 async function handleDisableBiometrics() {428 setBiometricBusy(true);429 setBiometricError(null);430 try {431 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");432 if (keystore) await keystore.clearMasterKey();433 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });434 setBiometricStored(false);435 } catch (e) {436 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");437 } finally {438 setBiometricBusy(false);439 }440 }441442 async function handleChangePassword() {443 setChangePwError(null);444 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }445 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }446 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }447 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }448 setChangePwSaving(true);449 const res = await sendMessage({450 type: "CHANGE_PASSWORD",451 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },452 }) as { ok: boolean; error?: string };453 if (res.ok) {454 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");455 setShowChangePw(false);456 setShowNewRecoveryWords(true);457 } else {458 setChangePwError(res.error ?? "failed to change password");459 }460 setChangePwSaving(false);461 }462463 const [copied, setCopied] = createSignal(false);464465 async function copyLicenseKey(key: string) {466 await navigator.clipboard.writeText(key);467 setCopied(true);468 setTimeout(() => setCopied(false), 2000);469 }470471 async function handleActivateLicense() {472 const key = licenseKeyInput().trim();473 if (!key) return;474 setActivatingLicense(true);475 setLicenseError(null);476 setLicenseSuccess(null);477 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });478 setActivatingLicense(false);479 if ((res as { ok: boolean }).ok) {480 const data = (res as { ok: boolean; data?: LicenseInfo }).data;481 if (data?.valid) {482 setLicenseInfo(data);483 setLicenseKey(key);484 setLicenseSuccess("License activated!");485 setLicenseKeyInput("");486 } else {487 setLicenseError("Invalid license key");488 }489 } else {490 setLicenseError("Activation failed");491 }492 }493494 async function handleTogglePasskeys() {495 const current = passkeyState();496 if (!current || !current.supported) return;497 setPasskeySaving(true);498 const res = await sendMessage({499 type: "SET_PASSKEY_PROVIDER_ENABLED",500 payload: { enabled: !current.enabled },501 });502 setPasskeySaving(false);503 if ((res as { ok: boolean }).ok) {504 setPasskeyState(505 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null506 );507 }508 }509510 async function handleCheckAllBreaches() {511 setCheckingBreaches(true);512 setBreachResult(null);513 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });514 setCheckingBreaches(false);515 if ((res as { ok: boolean }).ok) {516 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};517 const breached = Object.values(data).filter((count) => count > 0).length;518 setBreachResult(519 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"520 );521 props.onBreachCheckDone();522 }523 }524525526 async function handleSetupDuress() {527 const mainPw = duressMainPassword().trim();528 const duressPw = duressPassword().trim();529 const confirmPw = duressConfirm().trim();530531 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }532 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }533 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }534 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }535536 setDuressSaving(true);537 setDuressError(null);538539 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };540 if (!verifyRes.ok || !verifyRes.data) {541 setDuressError("incorrect main password");542 setDuressSaving(false);543 return;544 }545546 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };547 setDuressSaving(false);548 if (res.ok) {549 setDuressEnabled(true);550 setShowDuressSetup(false);551 setDuressPassword("");552 setDuressConfirm("");553 setDuressMainPassword("");554 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");555 setTimeout(() => setDuressSuccess(null), 5000);556 } else {557 setDuressError(558 res.error === "session_expired"559 ? "Vault session expired — lock and unlock Peach, then try again"560 : res.error ?? "failed to set duress password"561 );562 }563 }564565 async function handleClearDeclinedCards() {566 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });567 setDeclinedCardCount(0);568 }569570 async function handleCountDuplicates() {571 setDedupRunning(true);572 setDedupDone(null);573 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };574 setDedupRunning(false);575 if (res.ok) setDedupCount(res.data ?? 0);576 }577578 async function handleDeduplicate() {579 setDedupRunning(true);580 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };581 setDedupRunning(false);582 if (res.ok) {583 setDedupDone(res.data ?? 0);584 setDedupCount(0);585 }586 }587588 async function handleDisableDuress() {589 const mainPw = duressMainPassword().trim();590 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }591592 setDuressSaving(true);593 setDuressError(null);594595 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };596 if (!verifyRes.ok || !verifyRes.data) {597 setDuressError("incorrect main password");598 setDuressSaving(false);599 return;600 }601602 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };603 setDuressSaving(false);604 if (res.ok) {605 setDuressEnabled(false);606 setShowDuressSetup(false);607 setDuressMainPassword("");608 setDuressSuccess("Duress password disabled.");609 setTimeout(() => setDuressSuccess(null), 4000);610 } else {611 setDuressError(res.error ?? "failed to disable duress password");612 }613 }614615 async function handleSubmitFeedback() {616 const message = sanitizeFeedbackMessage(fbMessage());617 if (!message) return;618619 setFeedbackState('sending');620621 try {622 const hasDataPermission = await requestFeedbackDataPermission();623 if (!hasDataPermission) {624 setFeedbackState('idle');625 return;626 }627628 const category = FEEDBACK_CATEGORIES.includes(fbCategory())629 ? fbCategory()630 : FEEDBACK_CATEGORIES[0];631 const res = await fetch('https://api.web3forms.com/submit', {632 method: 'POST',633 headers: { 'Content-Type': 'application/json' },634 body: JSON.stringify({635 access_key: WEB3FORMS_KEY,636 subject: `Peach Feedback - ${category}`,637 message,638 from_name: 'Peach User',639 640 botcheck: '',641 }),642 });643644 if (!res.ok) throw new Error(`HTTP ${res.status}`);645 const data = await res.json() as { success: boolean; message?: string };646 if (!data.success) throw new Error(data.message ?? 'unknown error');647648 setFeedbackState('sent');649 setFbMessage('');650 setFbCategory(FEEDBACK_CATEGORIES[0]);651 } catch (err) {652 console.error('[feedback]', err);653 setFeedbackState('error');654 setTimeout(() => setFeedbackState('idle'), 4000);655 }656 }657658 function renderSecurity() {659 return (660 <div class="peach-settings-body">661 <div class="peach-settings-group">662 <p class="peach-settings-sub-label">LOCKING</p>663 <div class="peach-settings-row">664 <span>Lock on close</span>665 <button666 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}667 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}668 aria-label="Toggle lock on close"669 >670 <span class="peach-toggle-knob"></span>671 </button>672 </div>673 <div class="peach-settings-row">674 <span>Auto-lock after</span>675 <select676 class="peach-settings-select"677 value={String(props.autoLockMinutes)}678 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}679 >680 <option value="1">1 minute</option>681 <option value="5">5 minutes</option>682 <option value="15">15 minutes</option>683 <option value="30">30 minutes</option>684 <option value="0">Never</option>685 </select>686 </div>687688 <Show when={isNativePlatform() && biometricSupported()}>689 <p class="peach-settings-sub-label">BIOMETRICS</p>690 <div class="peach-settings-row">691 <span>Unlock with biometrics</span>692 <button693 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}694 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}695 disabled={biometricBusy()}696 aria-label="Toggle biometric unlock"697 >698 <span class="peach-toggle-knob"></span>699 </button>700 </div>701 <Show when={biometricError()}>702 <div class="peach-settings-row peach-settings-row--meta">703 <span class="peach-hint peach-hint-error">{biometricError()}</span>704 </div>705 </Show>706 </Show>707708 <p class="peach-settings-sub-label">BREACH PROTECTION</p>709 <div class="peach-settings-row">710 <span>Breach monitoring</span>711 <button712 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}713 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}714 aria-label="Toggle breach monitoring"715 >716 <span class="peach-toggle-knob"></span>717 </button>718 </div>719 <div class="peach-settings-row peach-settings-row--meta">720 <span>Check all passwords</span>721 <button722 class="peach-btn peach-btn--subtle peach-btn--sm"723 onClick={() => void handleCheckAllBreaches()}724 disabled={checkingBreaches()}725 >726 {checkingBreaches() ? "checking..." : "run"}727 </button>728 </div>729 <Show when={breachResult()}>730 <div class="peach-settings-row peach-settings-row--meta">731 <span>Last check</span>732 <span class="peach-settings-value">{breachResult()}</span>733 </div>734 </Show>735736 <p class="peach-settings-sub-label">MASTER PASSWORD</p>737 <div class="peach-settings-row peach-settings-row--meta">738 <span>Master password</span>739 <button740 class="peach-btn peach-btn--subtle peach-btn--sm"741 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}742 >743 {showChangePw() ? "cancel" : "change"}744 </button>745 </div>746 <Show when={showChangePw()}>747 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">748 <input749 class="peach-settings-input"750 type="password"751 placeholder="current password"752 value={changePwCurrent()}753 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}754 autocomplete="current-password"755 />756 <input757 class="peach-settings-input"758 type="password"759 placeholder="new password (min 12 chars)"760 value={changePwNew()}761 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}762 autocomplete="new-password"763 />764 <input765 class="peach-settings-input"766 type="password"767 placeholder="confirm new password"768 value={changePwConfirm()}769 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}770 autocomplete="new-password"771 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}772 />773 <Show when={changePwError()}>774 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>775 </Show>776 <Show when={changePwSuccess()}>777 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>778 </Show>779 <button780 class="peach-btn peach-btn--accent peach-btn--sm"781 style="align-self:flex-start"782 onClick={() => void handleChangePassword()}783 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}784 >785 {changePwSaving() ? "saving…" : "save new password"}786 </button>787 </div>788 </Show>789790 <p class="peach-settings-sub-label">791 DURESS PASSWORD792 <Show when={!isPro()}>793 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>794 </Show>795 </p>796 <div class="peach-settings-row peach-settings-row--top">797 <div class="peach-settings-row-main">798 <span>799 Duress password800 <Show when={!isPro()}>801 <span class="peach-pro-badge">Pro</span>802 </Show>803 </span>804 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">805 If entered under coercion, shows a decoy empty vault instead of your real one.806 </span>807 </div>808 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>809 {duressEnabled() ? "on" : "off"}810 </div>811 </div>812813 <Show when={duressEnabled() && !showDuressSetup()}>814 <div class="peach-settings-row peach-settings-row--meta">815 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">816 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.817 </span>818 </div>819 </Show>820821 <Show when={duressSuccess()}>822 <div class="peach-settings-row peach-settings-row--meta">823 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>824 </div>825 </Show>826827 <Show when={!showDuressSetup()}>828 <div class="peach-settings-row peach-settings-row--meta">829 <button830 class="peach-btn peach-btn--subtle peach-btn--sm"831 onClick={() => {832 833 834 835 836 837 if (!isPro() && !duressEnabled()) {838 props.onProGate?.("duress_vault");839 setActiveCard("pro");840 return;841 }842 setShowDuressSetup(true);843 setDuressError(null);844 setDuressSuccess(null);845 setDuressPassword("");846 setDuressConfirm("");847 setDuressMainPassword("");848 }}849 >850 {duressEnabled() ? "Change / disable" : "Set up"}851 </button>852 </div>853 </Show>854855 <Show when={showDuressSetup()}>856 <div class="peach-duress-form">857 <label class="peach-label">main password (to confirm)</label>858 <input859 class="peach-settings-input"860 type="password"861 placeholder="your current master password"862 autocomplete="current-password"863 value={duressMainPassword()}864 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}865 />866 <Show when={!duressEnabled()}>867 <label class="peach-label">new duress password</label>868 <input869 class="peach-settings-input"870 type="password"871 placeholder="duress password (min 8 chars)"872 autocomplete="new-password"873 value={duressPassword()}874 onInput={(e) => setDuressPassword(e.currentTarget.value)}875 />876 <label class="peach-label">confirm duress password</label>877 <input878 class="peach-settings-input"879 type="password"880 placeholder="confirm duress password"881 autocomplete="new-password"882 value={duressConfirm()}883 onInput={(e) => setDuressConfirm(e.currentTarget.value)}884 />885 </Show>886887 <Show when={duressError()}>888 <p class="peach-hint peach-hint-error">{duressError()}</p>889 </Show>890891 <div class="peach-duress-actions">892 <button893 class="peach-btn peach-btn--subtle peach-btn--sm"894 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}895 >896 cancel897 </button>898 <Show when={duressEnabled()}>899 <button900 class="peach-btn peach-btn--ghost peach-btn--sm"901 style="color:var(--peach-breach)"902 onClick={() => void handleDisableDuress()}903 disabled={duressSaving()}904 >905 {duressSaving() ? "disabling..." : "disable duress"}906 </button>907 </Show>908 <Show when={!duressEnabled()}>909 <button910 class="peach-btn peach-btn--accent peach-btn--sm"911 onClick={() => void handleSetupDuress()}912 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}913 >914 {duressSaving() ? "saving..." : "save"}915 </button>916 </Show>917 </div>918 </div>919 </Show>920921 <p class="peach-settings-sub-label">922 CANARY ENTRIES923 <Show when={!isPro()}>924 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>925 </Show>926 </p>927 <div class="peach-settings-row peach-settings-row--top">928 <div class="peach-settings-row-main">929 <span>930 Canary entries931 <Show when={!isPro()}>932 <span class="peach-pro-badge">Pro</span>933 </Show>934 </span>935 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">936 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.937 </span>938 </div>939 <span class="peach-settings-value" style="flex-shrink:0">940 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}941 </span>942 </div>943 <Show when={canaryEntries().length > 0}>944 <For each={canaryEntries()}>945 {(entry) => (946 <div class="peach-settings-row peach-settings-row--meta">947 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>948 <button949 class="peach-btn peach-btn--subtle peach-btn--sm"950 onClick={() => void handleTestCanary(entry)}951 disabled={canaryTestingId() === entry.id}952 style="flex-shrink:0"953 >954 {canaryTestingId() === entry.id955 ? "sending…"956 : canaryTestResult()?.id === entry.id957 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")958 : "test"}959 </button>960 </div>961 )}962 </For>963 </Show>964965 <p class="peach-settings-sub-label">PASSKEYS</p>966 <div class="peach-settings-row peach-settings-row--top">967 <div class="peach-settings-row-main">968 <span>Intercept passkey requests</span>969 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">970 Peach can handle passkey sign-in and creation on supported Chromium browsers.971 </span>972 </div>973 <button974 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}975 onClick={() => void handleTogglePasskeys()}976 aria-label="Toggle passkey interception"977 disabled={!passkeyState()?.supported || passkeySaving()}978 >979 <span class="peach-toggle-knob"></span>980 </button>981 </div>982 <div class="peach-settings-row peach-settings-row--meta">983 <span>Status</span>984 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>985 </div>986 <div class="peach-settings-row peach-settings-row--meta">987 <span>Browser</span>988 <span class="peach-settings-value">989 {passkeyState()?.browserLabel ?? "Unknown"}990 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}991 </span>992 </div>993 <div class="peach-info-notice" style="margin-top:8px">994 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.995 </div>996 <Show when={passkeyState()?.conflict}>997 <div class="peach-settings-row peach-settings-row--meta">998 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>999 </div>1000 </Show>1001 </div>1002 </div>1003 );1004 }10051006 function renderVault() {1007 return (1008 <div class="peach-settings-body">1009 <div class="peach-settings-group">1010 <p class="peach-settings-sub-label">ENTRIES</p>1011 <div class="peach-settings-row peach-settings-row--meta">1012 <span>Entries</span>1013 <span class="peach-settings-value">{props.entriesCount}</span>1014 </div>1015 <div class="peach-settings-row peach-settings-row--meta">1016 <span>Duplicates</span>1017 <Show when={dedupDone() !== null} fallback={1018 <button1019 class="peach-btn peach-btn--subtle peach-btn--sm"1020 onClick={() => void handleCountDuplicates()}1021 disabled={dedupRunning()}1022 >1023 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1024 </button>1025 }>1026 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1027 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1028 </span>1029 </Show>1030 </div>1031 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1032 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1033 <div class="peach-restore-warning">1034 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1035 </div>1036 <div style="display:flex;gap:6px">1037 <button1038 class="peach-btn peach-btn--subtle peach-btn--sm"1039 onClick={() => setDedupCount(null)}1040 >1041 cancel1042 </button>1043 <button1044 class="peach-btn peach-btn--accent peach-btn--sm"1045 onClick={() => void handleDeduplicate()}1046 disabled={dedupRunning()}1047 >1048 {dedupRunning() ? "removing..." : "remove duplicates"}1049 </button>1050 </div>1051 </div>1052 </Show>10531054 <p class="peach-settings-sub-label">MAINTENANCE</p>1055 <div class="peach-settings-row peach-settings-row--meta">1056 <span>Trash auto-delete</span>1057 <select1058 class="peach-settings-select"1059 value={String(props.trashRetentionDays)}1060 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1061 >1062 <option value="7">7 days</option>1063 <option value="30">30 days</option>1064 <option value="90">90 days</option>1065 <option value="0">Never</option>1066 </select>1067 </div>1068 <div class="peach-settings-row peach-settings-row--meta">1069 <span>Vault Health</span>1070 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1071 open1072 </button>1073 </div>1074 <Show when={declinedCardCount() > 0}>1075 <div class="peach-settings-row peach-settings-row--meta">1076 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1077 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1078 Clear1079 </button>1080 </div>1081 </Show>10821083 <p class="peach-settings-sub-label">DATA</p>1084 <div class="peach-settings-row peach-settings-row--meta">1085 <span>Import passwords</span>1086 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1087 open1088 </button>1089 </div>1090 <div class="peach-settings-row peach-settings-row--meta">1091 <span>Export backup</span>1092 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1093 open1094 </button>1095 </div>1096 <div class="peach-settings-row peach-settings-row--meta">1097 <span>1098 Peach Codex1099 <Show when={!isPro()}>1100 <span class="peach-pro-badge">Pro</span>1101 </Show>1102 </span>1103 <button1104 class="peach-btn peach-btn--subtle peach-btn--sm"1105 onClick={() => props.onOpenCodexGenerate?.()}1106 >1107 open1108 </button>1109 </div>1110 {1111}1112 <div class="peach-settings-row peach-settings-row--meta">1113 <span>Restore from Codex</span>1114 <button1115 class="peach-btn peach-btn--subtle peach-btn--sm"1116 onClick={() => props.onOpenCodexRestore?.()}1117 >1118 open1119 </button>1120 </div>11211122 <span class="peach-settings-sub-label">VAULT HISTORY</span>1123 <Show when={historyLoading()}>1124 <p class="peach-settings-hint">Loading…</p>1125 </Show>1126 <Show when={!historyLoading() && historySnapshots().length === 0}>1127 <p class="peach-settings-hint">1128 No snapshots yet. Peach saves automatically before every change —1129 saves, syncs, and imports.1130 </p>1131 </Show>1132 <Show when={!historyLoading() && historySnapshots().length > 0}>1133 <p class="peach-settings-hint">1134 Restoring locks your vault. Unlock with the password that1135 encrypted that snapshot to confirm.1136 </p>1137 <div class="peach-history-list">1138 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1139 {(snap) => (1140 <div class="peach-history-row">1141 <div class="peach-history-meta">1142 <span class="peach-history-time">1143 {formatSnapshotTime(snap.savedAt)}1144 </span>1145 <span class="peach-history-sub">1146 {[1147 snap.entryCount !== null1148 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1149 : null,1150 reasonLabel(snap.reason),1151 ]1152 .filter(Boolean)1153 .join(" · ")}1154 </span>1155 </div>1156 <button1157 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1158 historyRestoring() === snap.id ? " is-busy" : ""1159 }`}1160 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1161 disabled={historyRestoring() !== null}1162 >1163 <Show when={historyRestoring() === snap.id} fallback="Restore">1164 …1165 </Show>1166 </button>1167 </div>1168 )}1169 </For>1170 </div>1171 <Show when={historySnapshots().length > 3}>1172 <button1173 class="peach-settings-advanced-toggle"1174 onClick={() => setHistoryShowAll((v) => !v)}1175 aria-expanded={historyShowAll()}1176 >1177 <span class="peach-settings-hint" style="margin:0">1178 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1179 </span>1180 <span class="peach-settings-advanced-chevron">1181 {historyShowAll() ? "▲" : "▼"}1182 </span>1183 </button>1184 </Show>1185 <p class="peach-history-footer">1186 {historySnapshots().length} of 10 snapshots1187 </p>1188 </Show>11891190 <button1191 class="peach-settings-advanced-toggle"1192 onClick={() => setShowVaultAdvanced((v) => !v)}1193 aria-expanded={showVaultAdvanced()}1194 >1195 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1196 <span class="peach-settings-advanced-chevron">1197 {showVaultAdvanced() ? "▲" : "▼"}1198 </span>1199 </button>12001201 <Show when={showVaultAdvanced()}>1202 <div class="peach-danger-card">1203 <div class="peach-danger-card-head">1204 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1205 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1206 </div>1207 <p class="peach-danger-card-copy">1208 Wipes the current vault and restores from a backup file.1209 Recoverable only if your backup is good.1210 </p>1211 <button1212 class="peach-danger-card-button"1213 onClick={() => props.onOpenFileImport?.()}1214 >1215 Choose backup file1216 </button>1217 </div>12181219 <Show when={props.onOpenPanic}>1220 <div class="peach-danger-card peach-danger-card--hazard">1221 <div class="peach-danger-card-head">1222 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1223 <h3 class="peach-danger-card-title">Destroy vault</h3>1224 </div>1225 <p class="peach-danger-card-copy">1226 Permanently wipes every entry, setting, and license on this1227 device. There is no recovery.1228 </p>1229 <button1230 class="peach-danger-card-button"1231 onClick={() => props.onOpenPanic?.()}1232 >1233 Begin destruction1234 </button>1235 </div>1236 </Show>1237 </Show>1238 </div>1239 </div>1240 );1241 }12421243 function renderSync() {1244 return (1245 <div class="peach-settings-body">1246 <div class="peach-settings-group">1247 <p class="peach-settings-sub-label">PAIRING</p>1248 <div class="peach-settings-row peach-settings-row--top">1249 <div class="peach-settings-row-main">1250 <span>1251 Peach Sync1252 <Show when={!isPro()}>1253 <span class="peach-pro-badge">Pro</span>1254 </Show>1255 </span>1256 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1257 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1258 </span>1259 </div>1260 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1261 </div>1262 <div class="peach-settings-row peach-settings-row--meta">1263 <span>Status</span>1264 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1265 </div>1266 <div class="peach-settings-row peach-settings-row--meta">1267 <span>Sync hub</span>1268 <button1269 class="peach-btn peach-btn--subtle peach-btn--sm"1270 onClick={() => props.onOpenSyncPairing?.()}1271 >1272 open1273 </button>1274 </div>12751276 <p class="peach-settings-sub-label">DEVICE FLOW</p>1277 <div class="peach-settings-row peach-settings-row--top">1278 <div class="peach-settings-row-main">1279 <span>Pair new device</span>1280 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1281 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1282 </span>1283 </div>1284 <button1285 class="peach-btn peach-btn--subtle peach-btn--sm"1286 onClick={() => props.onOpenSyncPairing?.()}1287 >1288 pair1289 </button>1290 </div>1291 <div class="peach-info-notice" style="margin-top:8px">1292 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1293 </div>1294 </div>1295 </div>1296 );1297 }12981299 function renderAppearance() {1300 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1301 { id: "medium", label: "Default" },1302 { id: "large", label: "Large" },1303 ];1304 return (1305 <div class="peach-settings-body">1306 <div class="peach-settings-group">1307 <p class="peach-settings-sub-label">THEME</p>1308 <div class="peach-settings-row peach-settings-row--meta">1309 <span>Theme</span>1310 <button1311 class="peach-btn peach-btn--subtle peach-btn--sm"1312 onClick={() =>1313 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1314 }1315 >1316 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1317 </button>1318 </div>13191320 <p class="peach-settings-sub-label">ACCENT</p>1321 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1322 <span>Accent</span>1323 <div class="peach-accent-picker">1324 <For each={ACCENT_COLORS}>1325 {(color) => (1326 <button1327 class="peach-accent-swatch peach-swatch"1328 classList={{ "is-active": props.accentColor === color.name }}1329 style={{ background: color.hex }}1330 onClick={() => void props.onAccentChange(color.name)}1331 aria-label={color.name}1332 title={color.name}1333 />1334 )}1335 </For>1336 </div>1337 </div>13381339 <p class="peach-settings-sub-label">TEXT SIZE</p>1340 <div class="peach-settings-row peach-settings-row--meta">1341 <span>Size</span>1342 <div class="peach-pill-group">1343 <For each={sizeOptions}>1344 {(opt) => (1345 <button1346 class="peach-btn peach-btn--ghost peach-btn--sm"1347 classList={{ "is-active": props.textSize === opt.id }}1348 onClick={() => void props.onTextSizeChange(opt.id)}1349 >1350 {opt.label}1351 </button>1352 )}1353 </For>1354 </div>1355 </div>13561357 <p class="peach-settings-sub-label">MOTION</p>1358 <div class="peach-settings-row peach-settings-row--meta">1359 <span>Reduce motion</span>1360 <button1361 class="peach-btn peach-btn--subtle peach-btn--sm"1362 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1363 >1364 {props.reduceMotion ? "On" : "Off"}1365 </button>1366 </div>1367 </div>1368 </div>1369 );1370 }13711372 function renderShortcuts() {1373 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1374 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13751376 function startCapture(action: ShortcutAction) {1377 setConflictMsg(null);1378 setCapturingAction(action);1379 }1380 function cancelCapture() {1381 setCapturingAction(null);1382 setConflictMsg(null);1383 }1384 function commitBinding(action: ShortcutAction, binding: Keybinding) {1385 const conflict = findConflict(props.shortcuts, binding, action);1386 if (conflict) {1387 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1388 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1389 return;1390 }1391 const next = { ...props.shortcuts, [action]: binding };1392 setCapturingAction(null);1393 setConflictMsg(null);1394 void props.onShortcutsChange(next);1395 }1396 function resetAction(action: ShortcutAction) {1397 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1398 void props.onShortcutsChange(next);1399 }1400 function resetAll() {1401 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1402 }14031404 1405 1406 function handleCaptureKey(e: KeyboardEvent) {1407 e.preventDefault();1408 e.stopPropagation();1409 if (e.key === "Escape") { cancelCapture(); return; }1410 const binding = keybindingFromEvent(e);1411 if (!binding) return; 1412 const action = capturingAction();1413 if (action) commitBinding(action, binding);1414 }14151416 return (1417 <div class="peach-settings-body">1418 <div class="peach-settings-group peach-shortcuts-group">1419 <p class="peach-settings-sub-label">ENTRY VIEW</p>1420 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1421 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1422 </For>1423 </div>14241425 <div class="peach-settings-group peach-shortcuts-group">1426 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1427 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1428 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1429 </For>1430 </div>14311432 <Show when={conflictMsg()}>1433 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1434 </Show>14351436 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1437 <div class="peach-settings-row peach-settings-row--meta">1438 <span>Inline autofill on page</span>1439 <a1440 class="peach-settings-link"1441 href="chrome://extensions/shortcuts"1442 onClick={(e) => {1443 e.preventDefault();1444 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1445 }}1446 >1447 ⌘⇧P — customize ↗1448 </a>1449 </div>1450 <button1451 class="peach-btn peach-btn--subtle peach-btn--sm"1452 style="align-self:flex-start"1453 onClick={resetAll}1454 >1455 Reset all to defaults1456 </button>1457 </div>1458 </div>1459 );14601461 function renderRow(1462 desc: typeof SHORTCUT_DESCRIPTORS[number],1463 capturing: () => ShortcutAction | null,1464 onStart: (a: ShortcutAction) => void,1465 onCancel: () => void,1466 onReset: (a: ShortcutAction) => void,1467 onKey: (e: KeyboardEvent) => void,1468 ) {1469 const isActive = () => capturing() === desc.action;1470 const binding = () => props.shortcuts[desc.action];1471 const isCustom = () => {1472 const b = binding();1473 const d = DEFAULT_BINDINGS[desc.action];1474 return b.key !== d.key1475 || Boolean(b.ctrl) !== Boolean(d.ctrl)1476 || Boolean(b.meta) !== Boolean(d.meta)1477 || Boolean(b.shift) !== Boolean(d.shift)1478 || Boolean(b.alt) !== Boolean(d.alt);1479 };1480 return (1481 <div class="peach-shortcut-row">1482 <span class="peach-shortcut-row-label">{desc.label}</span>1483 <button1484 class="peach-shortcut-chip"1485 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1486 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1487 onKeyDown={(e) => { if (isActive()) onKey(e); }}1488 ref={(el) => {1489 1490 if (el) {1491 queueMicrotask(() => { if (isActive()) el.focus(); });1492 }1493 }}1494 >1495 {isActive() ? "Press a key…" : formatKeybinding(binding())}1496 </button>1497 <Show when={isCustom() && !isActive()}>1498 <button1499 class="peach-shortcut-reset"1500 title="Reset to default"1501 onClick={() => onReset(desc.action)}1502 >1503 ↺1504 </button>1505 </Show>1506 </div>1507 );1508 }1509 }15101511 const PRO_FEATURES: Array<{1512 feature: ProFeature;1513 name: string;1514 desc: string;1515 }> = [1516 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1517 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1518 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1519 { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1520 { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1521 ];15221523 const PRO_SECURITY_FEATURES: Array<{1524 feature: ProFeature;1525 name: string;1526 desc: string;1527 }> = [1528 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1529 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1530 ];15311532 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1533 const [showKey, setShowKey] = createSignal(false);15341535 const [deactivating, setDeactivating] = createSignal(false);15361537 async function handleDeactivateLicense() {1538 setDeactivating(true);1539 await sendMessage({ type: "DEACTIVATE_LICENSE" });1540 setLicenseInfo(null);1541 await props.onLicenseChange?.();1542 setDeactivating(false);1543 }15441545 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1546 setCanaryTestingId(entry.id);1547 setCanaryTestResult(null);1548 const res = await sendMessage({1549 type: "FIRE_CANARY_PING",1550 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1551 }) as { ok: boolean };1552 setCanaryTestResult({ id: entry.id, ok: res.ok });1553 setCanaryTestingId(null);1554 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1555 }15561557 function renderLicense() {1558 async function onActivate() {1559 await handleActivateLicense();1560 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1561 await props.onLicenseChange?.();1562 }1563 }15641565 return (1566 <div class="peach-settings-body">1567 <Show1568 when={isPro()}1569 fallback={1570 <div class="peach-pro-upgrade">1571 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15721573 <h2 class="peach-pro-headline">1574 Every<em>thing,</em><br />every<em>where.</em>1575 </h2>15761577 <p class="peach-pro-subtitle">1578 One key unlocks every Pro feature — for the lifetime of the app.1579 </p>15801581 <div class="peach-pro-grid">1582 <For each={PRO_FEATURES}>1583 {(item, i) => (1584 <div1585 class="peach-pro-grid-item"1586 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1587 >1588 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1589 <span class="peach-pro-grid-name">{item.name}</span>1590 <span class="peach-pro-grid-desc">{item.desc}</span>1591 </div>1592 )}1593 </For>1594 </div>15951596 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1597 <div class="peach-pro-grid">1598 <For each={PRO_SECURITY_FEATURES}>1599 {(item, i) => (1600 <div1601 class="peach-pro-grid-item"1602 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1603 >1604 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1605 <span class="peach-pro-grid-name">{item.name}</span>1606 <span class="peach-pro-grid-desc">{item.desc}</span>1607 </div>1608 )}1609 </For>1610 </div>16111612 <div class="peach-pro-upgrade-actions">1613 <p class="peach-pro-pricing">1614 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1615 </p>1616 <a1617 href="https://peachpasswords.com/#pricing"1618 target="_blank"1619 rel="noopener noreferrer"1620 class="peach-pro-upgrade-cta"1621 >1622 Get Peach Pro1623 </a>1624 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1625 <div class="peach-pro-key-inline">1626 <input1627 class="peach-pro-key-inline-input"1628 type="text"1629 placeholder="PEACH-XXXX-XXXX-XXXX"1630 value={licenseKeyInput()}1631 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1632 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1633 autocomplete="off"1634 spellcheck={false}1635 />1636 <button1637 class="peach-pro-key-apply"1638 onClick={() => void onActivate()}1639 disabled={activatingLicense() || !licenseKeyInput().trim()}1640 >1641 {activatingLicense() ? "…" : "APPLY"}1642 </button>1643 </div>1644 <Show when={licenseError()}>1645 <p class="peach-pro-key-error">{licenseError()}</p>1646 </Show>1647 </div>1648 </div>1649 }1650 >1651 <div class="peach-pro-active">1652 <div class="peach-pro-active-hero">1653 <div class="peach-pro-active-mark-wrap">1654 <PeachMark class="peach-pro-active-mark" />1655 </div>1656 <h2 class="peach-pro-active-title">Peach Pro</h2>1657 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1658 </div>16591660 <Show when={licenseKey()}>1661 <div class="peach-settings-license">1662 <span class="peach-settings-label">LICENSE KEY</span>1663 <div class="peach-settings-license-row">1664 <Show1665 when={showKey()}1666 fallback={1667 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1668 ••••••••••••••••••••••••••••1669 </span>1670 }1671 >1672 <span class="peach-settings-license-key">{licenseKey()!}</span>1673 </Show>1674 <div class="peach-settings-license-actions">1675 <button1676 class="peach-settings-license-copy"1677 title="Copy license key"1678 onClick={() => copyLicenseKey(licenseKey()!)}1679 >1680 {copied() ? "✓" : "copy"}1681 </button>1682 <button1683 class="peach-settings-license-toggle"1684 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1685 title={showKey() ? "Hide" : "Reveal"}1686 onClick={() => setShowKey(v => !v)}1687 >1688 <Show1689 when={showKey()}1690 fallback={1691 <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">1692 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1693 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1694 </svg>1695 }1696 >1697 <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">1698 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1699 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1700 <line x1="2" y1="2" x2="12" y2="12" />1701 </svg>1702 </Show>1703 </button>1704 </div>1705 </div>1706 </div>1707 </Show>17081709 <div class="peach-pro-active-features">1710 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1711 {(item) => (1712 <div class="peach-pro-active-feature">1713 <span class="peach-pro-active-feature-mark">✦</span>1714 <span class="peach-pro-active-feature-name">{item.name}</span>1715 </div>1716 )}1717 </For>1718 </div>17191720 <button1721 class="peach-pro-deactivate"1722 onClick={() => void handleDeactivateLicense()}1723 disabled={deactivating()}1724 >1725 {deactivating() ? "Deactivating…" : "Deactivate"}1726 </button>1727 </div>1728 </Show>1729 </div>1730 );1731 }17321733 function renderFeedback() {1734 return (1735 <div class="peach-settings-body">1736 <div class="peach-settings-group peach-feedback-group">1737 <Show1738 when={feedbackState() === 'sent'}1739 fallback={1740 <>1741 <p class="peach-feedback-intro">1742 Help us improve Peach. Every report goes directly to our inbox.1743 </p>17441745 <p class="peach-settings-sub-label">TYPE</p>1746 <div class="peach-feedback-chips">1747 <For each={FEEDBACK_CATEGORIES}>1748 {(cat) => {1749 const selected = () => fbCategory() === cat;1750 return (1751 <button1752 type="button"1753 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1754 aria-pressed={selected()}1755 onClick={() => setFbCategory(cat)}1756 >1757 {cat}1758 </button>1759 );1760 }}1761 </For>1762 </div>17631764 <p class="peach-settings-sub-label">MESSAGE</p>1765 <textarea1766 class="peach-feedback-textarea"1767 placeholder="Describe the issue or idea..."1768 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1769 value={fbMessage()}1770 onInput={(e) => setFbMessage(e.currentTarget.value)}1771 />17721773 <p class="peach-feedback-meta">1774 Version {version} · will be included automatically1775 </p>17761777 <button1778 type="button"1779 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1780 disabled={!fbMessage().trim() || feedbackState() === 'sending'}1781 onClick={() => void handleSubmitFeedback()}1782 >1783 <Switch>1784 <Match when={feedbackState() === 'idle'}>Send</Match>1785 <Match when={feedbackState() === 'sending'}>Sending...</Match>1786 <Match when={feedbackState() === 'error'}>Failed - try again</Match>1787 </Switch>1788 </button>17891790 <p class="peach-feedback-anon">1791 Sent anonymously · no account required1792 </p>1793 </>1794 }1795 >1796 <div class="peach-feedback-thanks" role="status" aria-live="polite">1797 <p class="peach-feedback-thanks-title">Thank you</p>1798 <p>1799 Your note is in the queue. Someone will process it soon.1800 </p>1801 </div>1802 </Show>1803 </div>1804 </div>1805 );1806 }18071808 const bodies: StackBodies = {1809 protection: renderSecurity(),1810 vault: renderVault(),1811 sync: renderSync(),1812 appearance: renderAppearance(),1813 shortcuts: renderShortcuts(),1814 pro: renderLicense(),1815 feedback: renderFeedback(),1816 };18171818 return (1819 <Show1820 when={showNewRecoveryWords()}1821 fallback={1822 <div1823 class="peach-overlay-view peach-settings-overlay"1824 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1825 style="display:flex;flex-direction:column"1826 >1827 <StackDeck1828 active={activeCard()}1829 onActiveChange={setActiveCard}1830 onBack={props.onBack}1831 cards={cards()}1832 bodies={bodies}1833 version={version}1834 />1835 </div>1836 }1837 >1838 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1839 </Show>1840 );1841}