73260e9b28a1

rewrite Settings.tsx: two-level navigation, seven sub-views, feedback form

time
2026-04-26 13:30 UTC · 6 months ago
machine
koh2
parent
a205e1e3d989
2 files src/popup/views/Settings.tsx+530 −292src/popup/views/contracts.test.tsx+27 −6
src/popup/views/Settings.tsx+530 −292
@@ -1,4 +1,4 @@1-import { createSignal, For, onMount, Show } from "solid-js";1+import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";22 import { sendMessage } from "../App";33 import { ACCENT_COLORS } from "../../shared/types";44 import type { AccentColorName, PasskeyProviderState } from "../../shared/types";@@ -34,8 +34,21 @@3434   onOpenFileImport?: () => void;3535 }3636 37+type SectionName =38+  | "security"39+  | "vault"40+  | "sync"41+  | "appearance"42+  | "shortcuts"43+  | "license"44+  | "feedback";45+46+const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];47+3748 export function SettingsView(props: SettingsProps) {3849   const version = chrome.runtime.getManifest().version;50+  const [activeSection, setActiveSection] = createSignal<SectionName | null>(null);51+3952   const [checkingBreaches, setCheckingBreaches] = createSignal(false);4053   const [breachResult, setBreachResult] = createSignal<string | null>(null);4154   const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);@@ -53,15 +66,12 @@5366   const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);5467   const [passkeySaving, setPasskeySaving] = createSignal(false);5568 56-  // Deduplication state5769   const [dedupCount, setDedupCount] = createSignal<number | null>(null);5870   const [dedupRunning, setDedupRunning] = createSignal(false);5971   const [dedupDone, setDedupDone] = createSignal<number | null>(null);6072 61-  // Declined card fingerprints6273   const [declinedCardCount, setDeclinedCardCount] = createSignal(0);6374 64-  // Change master password state6575   const [showChangePw, setShowChangePw] = createSignal(false);6676   const [changePwCurrent, setChangePwCurrent] = createSignal("");6777   const [changePwNew, setChangePwNew] = createSignal("");@@ -70,28 +80,6 @@7080   const [changePwError, setChangePwError] = createSignal<string | null>(null);7181   const [changePwSuccess, setChangePwSuccess] = createSignal(false);7282 73-  async function handleChangePassword() {74-    setChangePwError(null);75-    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }76-    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }77-    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }78-    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }79-    setChangePwSaving(true);80-    const res = await sendMessage({81-      type: "CHANGE_PASSWORD",82-      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },83-    }) as { ok: boolean; error?: string };84-    if (res.ok) {85-      setChangePwSuccess(true);86-      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");87-      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);88-    } else {89-      setChangePwError(res.error ?? "failed to change password");90-    }91-    setChangePwSaving(false);92-  }93-94-  // Duress password state9583   const [duressEnabled, setDuressEnabled] = createSignal(false);9684   const [showDuressSetup, setShowDuressSetup] = createSignal(false);9785   const [duressPassword, setDuressPassword] = createSignal("");@@ -101,6 +89,11 @@10189   const [duressError, setDuressError] = createSignal<string | null>(null);10290   const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);10391 92+  const [fbCategory, setFbCategory] = createSignal("Bug report");93+  const [fbMessage, setFbMessage] = createSignal("");94+  const [fbSubmitting, setFbSubmitting] = createSignal(false);95+  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);96+10497   onMount(async () => {10598     const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([10699       sendMessage({ type: "GET_LICENSE" }),@@ -157,6 +150,27 @@157150     });158151   });159152 153+  async function handleChangePassword() {154+    setChangePwError(null);155+    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }156+    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }157+    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }158+    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }159+    setChangePwSaving(true);160+    const res = await sendMessage({161+      type: "CHANGE_PASSWORD",162+      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },163+    }) as { ok: boolean; error?: string };164+    if (res.ok) {165+      setChangePwSuccess(true);166+      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");167+      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);168+    } else {169+      setChangePwError(res.error ?? "failed to change password");170+    }171+    setChangePwSaving(false);172+  }173+160174   async function handleActivateLicense() {161175     const key = licenseKeyInput().trim();162176     if (!key) return;@@ -195,11 +209,6 @@195209     }196210   }197211 198-  function maskLicenseKey(key: string): string {199-    if (key.length < 10) return key.slice(0, 4) + "****";200-    return key.slice(0, 6) + "****" + key.slice(-4);201-  }202-203212   async function handleCheckAllBreaches() {204213     setCheckingBreaches(true);205214     setBreachResult(null);@@ -269,8 +278,6 @@269278   }270279 271280   function isCurrentNetworkTrusted(): boolean {272-    // Show the "mark trusted" button only if there are no trusted networks at all273-    // (Tailscale is always trusted implicitly; any explicit networks count too)274281     return trustedNetworks().length > 0;275282   }276283 @@ -287,7 +294,6 @@287294     setDuressSaving(true);288295     setDuressError(null);289296 290-    // Verify main password first291297     const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };292298     if (!verifyRes.ok || !verifyRes.data) {293299       setDuressError("incorrect main password");@@ -360,144 +366,152 @@360366     }361367   }362368 363-  function renderSyncSection() {364-    if (!isPro()) {365-      return (366-        <div class="peach-settings-group" style="position:relative">367-          <p class="peach-settings-label">sync</p>368-          <div class="peach-sync-pro-gate">369-            <div class="peach-sync-pro-gate-blur">370-              <div class="peach-settings-row">371-                <span>Status</span>372-                <span class="peach-settings-value peach-settings-value--status">373-                  <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>374-                  {getStatusLabel(syncStatus())}375-                </span>376-              </div>377-              <div class="peach-settings-row peach-settings-row--meta">378-                <span>Last sync</span>379-                <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>380-              </div>381-            </div>382-            <div class="peach-sync-pro-gate-overlay">383-              <span class="peach-sync-pro-badge">Pro</span>384-              <a href="https://peachpasswords.com/#pricing" target="_blank" rel="noopener noreferrer" class="peach-link" style="color:var(--peach-accent);font-size:13px;font-weight:500">385-                Upgrade to Pro →386-              </a>387-            </div>388-          </div>389-        </div>390-      );369+  async function handleSubmitFeedback() {370+    if (!fbMessage().trim() || fbSubmitting()) return;371+    setFbSubmitting(true);372+    setFbResult(null);373+    const res = await sendMessage({374+      type: "SUBMIT_FEEDBACK",375+      payload: {376+        category: fbCategory(),377+        message: fbMessage().trim(),378+        version,379+        browser: navigator.userAgent,380+      },381+    }) as { ok: boolean; error?: string };382+    setFbSubmitting(false);383+    if (res.ok) {384+      setFbResult("success");385+      setFbMessage("");386+      setTimeout(() => setFbResult(null), 4000);387+    } else {388+      setFbResult("error");391389     }390+  }392391 392+  function handleHeaderBack() {393+    if (activeSection() !== null) {394+      setActiveSection(null);395+    } else {396+      props.onBack();397+    }398+  }399+400+  function currentTitle(): string {401+    const s = activeSection();402+    if (s === null) return "settings";403+    if (s === "license") return "account";404+    return s;405+  }406+407+  function renderCatRow(opts: {408+    section: SectionName;409+    name: string;410+    desc: string;411+    icon: () => any;412+    pro?: boolean;413+  }) {393414     return (394-      <div class="peach-settings-group">395-        <p class="peach-settings-label">sync</p>396-        <div class="peach-settings-row">397-          <span>Status</span>398-          <span class="peach-settings-value peach-settings-value--status">399-            <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>400-            {getStatusLabel(syncStatus())}415+      <button416+        class="peach-settings-cat-row"417+        onClick={() => setActiveSection(opts.section)}418+      >419+        <div class="peach-settings-cat-icon">{opts.icon()}</div>420+        <div class="peach-settings-cat-content">421+          <span class="peach-settings-cat-name">422+            {opts.name}423+            <Show when={opts.pro && !isPro()}>424+              <span class="peach-sync-pro-badge" style="margin-left:6px">Pro</span>425+            </Show>401426           </span>427+          <span class="peach-settings-cat-desc">{opts.desc}</span>402428         </div>403-        <div class="peach-settings-row peach-settings-row--meta">404-          <span>Last sync</span>405-          <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>406-        </div>407-        <div class="peach-settings-row peach-settings-row--meta">408-          <button409-            class="peach-btn peach-btn--subtle peach-btn--sm"410-            onClick={() => void handleSyncNow()}411-            disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}412-          >413-            {syncingNow() ? "Syncing..." : "Sync now"}414-          </button>415-        </div>429+        <svg430+          class="peach-settings-cat-chevron"431+          width="6" height="10" viewBox="0 0 6 10" fill="none"432+          stroke="currentColor" stroke-width="1.1"433+          stroke-linecap="round" stroke-linejoin="round"434+        >435+          <path d="M1 1l4 4-4 4" />436+        </svg>437+      </button>438+    );439+  }416440 417-        <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">418-          <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>419-          {pairedDevices().map((device) => (420-            <div class="peach-sync-device-row">421-              <div class="peach-sync-device-info">422-                <span class="peach-sync-device-name">{device.displayName}</span>423-                <span class="peach-sync-device-meta">424-                  Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}425-                </span>426-              </div>427-              <button428-                class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"429-                onClick={() => void handleRemoveDevice(device.deviceId)}430-              >431-                Remove432-              </button>433-            </div>434-          ))}435-          <div class="peach-settings-actions--row">436-            <button437-              class="peach-btn peach-btn--subtle peach-btn--sm"438-              onClick={() => props.onOpenSyncPairing?.("initiate")}439-            >440-              Pair a new device441-            </button>442-            <button443-              class="peach-btn peach-btn--subtle peach-btn--sm"444-              onClick={() => props.onOpenSyncPairing?.("accept")}445-            >446-              Pair with another device447-            </button>448-          </div>449-        </div>441+  const iconShield = () => (442+    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">443+      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>444+    </svg>445+  );446+  const iconKey = () => (447+    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">448+      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>449+    </svg>450+  );451+  const iconLock = () => (452+    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">453+      <rect x="3" y="7" width="10" height="7" rx="1.5"/>454+      <path d="M6 7V5a3 3 0 016 0v2"/>455+    </svg>456+  );457+  const iconSync = () => (458+    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">459+      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>460+      <path d="M14 6l-2 2-2-2"/>461+      <path d="M2 10l2-2 2 2"/>462+    </svg>463+  );464+  const iconSun = () => (465+    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">466+      <circle cx="8" cy="8" r="3"/>467+      <path d="M8 2v1m0 10v1M2 8h1m10 0h1m-2.1-4.1-.7.7M4.8 11.3l-.7.7m0-7.7.7.7m6.5 6.3-.7.7"/>468+    </svg>469+  );470+  const iconKeys = () => (471+    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">472+      <rect x="2" y="5" width="5" height="4" rx="1"/>473+      <rect x="9" y="7" width="5" height="4" rx="1"/>474+      <path d="M7 7h2m-2 2h2"/>475+    </svg>476+  );477+  const iconBubble = () => (478+    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">479+      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>480+    </svg>481+  );450482 451-        <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">452-          <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>453-          {trustedNetworks().filter(n => !n.isTailscale).map((network) => (454-            <div class="peach-sync-device-row">455-              <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>456-              <button457-                class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"458-                onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}459-              >460-                Remove461-              </button>462-            </div>463-          ))}464-          <div class="peach-settings-actions--row">465-            {!isCurrentNetworkTrusted() && (466-              <button467-                class="peach-btn peach-btn--subtle peach-btn--sm"468-                onClick={() => void handleMarkNetworkTrusted()}469-              >470-                Mark current network as trusted471-              </button>472-            )}473-          </div>474-          <span class="peach-sync-device-meta">475-            Tailscale is always trusted476-          </span>483+  function renderHome() {484+    return (485+      <div class="peach-settings-body">486+        <p class="peach-settings-nav-label">PROTECTION</p>487+        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}488+        {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}489+490+        <p class="peach-settings-nav-label">VAULT</p>491+        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}492+        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}493+494+        <p class="peach-settings-nav-label">PREFERENCES</p>495+        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}496+        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}497+498+        <p class="peach-settings-nav-label">SUPPORT</p>499+        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}500+501+        <div class="peach-settings-footer">502+          <span>Peach {version}</span>503+          <span class="peach-divider"></span>504+          <span>Browser extension</span>477505         </div>478506       </div>479507     );480508   }481509 482-  return (483-    <div class="peach-overlay-view ov-enter-right" style="display:flex">484-      <div class="peach-overlay-header">485-        <button486-          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"487-          onClick={props.onBack}488-          aria-label="Back"489-        >490-          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">491-            <path d="M6 1L1 6L6 11" />492-          </svg>493-        </button>494-        <span class="peach-overlay-title">settings</span>495-        <div class="peach-overlay-spacer"></div>496-      </div>497-510+  function renderSecurity() {511+    return (498512       <div class="peach-settings-body">499513         <div class="peach-settings-group">500-          <p class="peach-settings-label">security</p>514+          <p class="peach-settings-sub-label">LOCKING</p>501515           <div class="peach-settings-row">502516             <span>Lock on close</span>503517             <button@@ -522,13 +536,13 @@522536               <option value="0">Never</option>523537             </select>524538           </div>539+540+          <p class="peach-settings-sub-label">BREACH PROTECTION</p>525541           <div class="peach-settings-row">526542             <span>Breach monitoring</span>527543             <button528544               class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}529-              onClick={() =>530-                void props.onBreachMonitoringChange(!props.breachMonitoring)531-              }545+              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}532546               aria-label="Toggle breach monitoring"533547             >534548               <span class="peach-toggle-knob"></span>@@ -544,13 +558,14 @@544558               {checkingBreaches() ? "checking..." : "run"}545559             </button>546560           </div>547-          {breachResult() && (561+          <Show when={breachResult()}>548562             <div class="peach-settings-row peach-settings-row--meta">549563               <span>Last check</span>550564               <span class="peach-settings-value">{breachResult()}</span>551565             </div>552-          )}553-          {/* ── Change master password ──────────────────────────────────── */}566+          </Show>567+568+          <p class="peach-settings-sub-label">MASTER PASSWORD</p>554569           <div class="peach-settings-row peach-settings-row--meta">555570             <span>Master password</span>556571             <button@@ -603,42 +618,8 @@603618               </button>604619             </div>605620           </Show>606-        </div>607621 608-        {/* ── Keyboard shortcuts ──────────────────────────────────────────── */}609-        <div class="peach-settings-group">610-          <p class="peach-settings-label">keyboard shortcuts</p>611-          <div class="peach-settings-row peach-settings-row--meta">612-            <span>Inline popup shortcut</span>613-            <a614-              class="peach-settings-link"615-              href="chrome://extensions/shortcuts"616-              onClick={(e) => {617-                e.preventDefault();618-                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });619-              }}620-            >621-              Ctrl+Shift+Space — customize ↗622-            </a>623-          </div>624-        </div>625-626-        {/* ── Cards ─────────────────────────────────────────────────────── */}627-        <Show when={declinedCardCount() > 0}>628-          <div class="peach-settings-group">629-            <p class="peach-settings-label">cards</p>630-            <div class="peach-settings-row peach-settings-row--meta">631-              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>632-              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>633-                Clear634-              </button>635-            </div>636-          </div>637-        </Show>638-639-        {/* ── Duress password ─────────────────────────────────────────────── */}640-        <div class="peach-settings-group">641-          <p class="peach-settings-label">duress password</p>622+          <p class="peach-settings-sub-label">DURESS PASSWORD</p>642623           <div class="peach-settings-row peach-settings-row--top">643624             <div class="peach-settings-row-main">644625               <span>Duress password</span>@@ -741,10 +722,8 @@741722               </div>742723             </div>743724           </Show>744-        </div>745725 746-        <div class="peach-settings-group">747-          <p class="peach-settings-label">passkeys</p>726+          <p class="peach-settings-sub-label">PASSKEYS</p>748727           <div class="peach-settings-row peach-settings-row--top">749728             <div class="peach-settings-row-main">750729               <span>Intercept passkey requests</span>@@ -769,23 +748,27 @@769748             <span>Browser</span>770749             <span class="peach-settings-value">771750               {passkeyState()?.browserLabel ?? "Unknown"}772-              {passkeyState()?.supported ? " \u2713" : " (interception unavailable)"}751+              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}773752             </span>774753           </div>775754           <div class="peach-info-notice" style="margin-top:8px">776755             Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.777756           </div>778-          {passkeyState()?.conflict && (757+          <Show when={passkeyState()?.conflict}>779758             <div class="peach-settings-row peach-settings-row--meta">780759               <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>781760             </div>782-          )}761+          </Show>783762         </div>763+      </div>764+    );765+  }784766 785-        {renderSyncSection()}786-767+  function renderVault() {768+    return (769+      <div class="peach-settings-body">787770         <div class="peach-settings-group">788-          <p class="peach-settings-label">vault</p>771+          <p class="peach-settings-sub-label">ENTRIES</p>789772           <div class="peach-settings-row peach-settings-row--meta">790773             <span>Entries</span>791774             <span class="peach-settings-value">{props.entriesCount}</span>@@ -828,35 +811,9 @@828811               </div>829812             </div>830813           </Show>831-          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">832-            <span>Accent</span>833-            <div class="peach-accent-picker">834-              <For each={ACCENT_COLORS}>835-                {(color) => (836-                  <button837-                    class="peach-accent-swatch"838-                    classList={{ "is-active": props.accentColor === color.name }}839-                    style={{ background: color.hex }}840-                    onClick={() => void props.onAccentChange(color.name)}841-                    aria-label={color.name}842-                    title={color.name}843-                  />844-                )}845-              </For>846-            </div>847-          </div>814+815+          <p class="peach-settings-sub-label">MAINTENANCE</p>848816           <div class="peach-settings-row peach-settings-row--meta">849-            <span>Theme</span>850-            <button851-              class="peach-btn peach-btn--subtle peach-btn--sm"852-              onClick={() =>853-                props.onThemeChange(props.theme === "dark" ? "light" : "dark")854-              }855-            >856-              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}857-            </button>858-          </div>859-          <div class="peach-settings-row peach-settings-row--meta">860817             <span>Trash auto-delete</span>861818             <select862819               class="peach-settings-select"@@ -870,6 +827,22 @@870827             </select>871828           </div>872829           <div class="peach-settings-row peach-settings-row--meta">830+            <span>Vault Health</span>831+            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>832+              open833+            </button>834+          </div>835+          <Show when={declinedCardCount() > 0}>836+            <div class="peach-settings-row peach-settings-row--meta">837+              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>838+              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>839+                Clear840+              </button>841+            </div>842+          </Show>843+844+          <p class="peach-settings-sub-label">DATA</p>845+          <div class="peach-settings-row peach-settings-row--meta">873846             <span>Import passwords</span>874847             <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>875848               open@@ -881,12 +854,6 @@881854               open882855             </button>883856           </div>884-          <div class="peach-settings-row peach-settings-row--meta">885-            <span>Vault Health</span>886-            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>887-              open888-            </button>889-          </div>890857           <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">891858             <span>Replace vault from backup</span>892859             <div class="peach-restore-warning" style="margin-top:2px">@@ -908,63 +875,188 @@908875             </div>909876           </div>910877         </div>878+      </div>879+    );880+  }911881 912-        <div class="peach-settings-group">913-          <p class="peach-settings-label">license</p>914-          {licenseInfo()?.valid && licenseInfo()?.plan === "pro" ? (915-            <div class="peach-settings-row peach-settings-row--meta">916-              <span>Peach Pro</span>917-              <span class="peach-settings-value">918-                activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}919-              </span>882+  function renderSync() {883+    if (!isPro()) {884+      return (885+        <div class="peach-settings-body">886+          <div class="peach-settings-group" style="position:relative">887+            <div class="peach-sync-pro-gate">888+              <div class="peach-sync-pro-gate-blur">889+                <div class="peach-settings-row">890+                  <span>Status</span>891+                  <span class="peach-settings-value peach-settings-value--status">892+                    <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>893+                    {getStatusLabel(syncStatus())}894+                  </span>895+                </div>896+                <div class="peach-settings-row peach-settings-row--meta">897+                  <span>Last sync</span>898+                  <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>899+                </div>900+              </div>901+              <div class="peach-sync-pro-gate-overlay">902+                <span class="peach-sync-pro-badge">Pro</span>903+                <a href="https://peachpasswords.com/#pricing" target="_blank" rel="noopener noreferrer" class="peach-link" style="color:var(--peach-accent);font-size:13px;font-weight:500">904+                  Upgrade to Pro →905+                </a>906+              </div>920907             </div>921-          ) : (922-            <>923-              <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">924-                <input925-                  class="peach-underline-input"926-                  type="text"927-                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"928-                  value={licenseKeyInput()}929-                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}930-                  style="width:100%;box-sizing:border-box"931-                />908+          </div>909+        </div>910+      );911+    }912+913+    return (914+      <div class="peach-settings-body">915+        <div class="peach-settings-group">916+          <div class="peach-settings-row">917+            <span>Status</span>918+            <span class="peach-settings-value peach-settings-value--status">919+              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>920+              {getStatusLabel(syncStatus())}921+            </span>922+          </div>923+          <div class="peach-settings-row peach-settings-row--meta">924+            <span>Last sync</span>925+            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>926+          </div>927+          <div class="peach-settings-row peach-settings-row--meta">928+            <button929+              class="peach-btn peach-btn--subtle peach-btn--sm"930+              onClick={() => void handleSyncNow()}931+              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}932+            >933+              {syncingNow() ? "Syncing..." : "Sync now"}934+            </button>935+          </div>936+937+          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">938+            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>939+            {pairedDevices().map((device) => (940+              <div class="peach-sync-device-row">941+                <div class="peach-sync-device-info">942+                  <span class="peach-sync-device-name">{device.displayName}</span>943+                  <span class="peach-sync-device-meta">944+                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}945+                  </span>946+                </div>932947                 <button933-                  class="peach-btn peach-btn--subtle peach-btn--sm"934-                  onClick={() => void handleActivateLicense()}935-                  disabled={activatingLicense() || !licenseKeyInput().trim()}948+                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"949+                  onClick={() => void handleRemoveDevice(device.deviceId)}936950                 >937-                  {activatingLicense() ? "activating..." : "Activate"}951+                  Remove938952                 </button>939953               </div>940-              {licenseError() && (941-                <div class="peach-settings-row peach-settings-row--meta">942-                  <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>943-                </div>944-              )}945-              {licenseSuccess() && (946-                <div class="peach-settings-row peach-settings-row--meta">947-                  <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>948-                </div>949-              )}950-              <div class="peach-settings-row peach-settings-row--meta">951-                <a952-                  href="https://peachpasswords.com/#pricing"953-                  target="_blank"954-                  rel="noopener noreferrer"955-                  class="peach-link"956-                  style="color:var(--peach-accent)"954+            ))}955+            <div class="peach-settings-actions--row">956+              <button957+                class="peach-btn peach-btn--subtle peach-btn--sm"958+                onClick={() => props.onOpenSyncPairing?.("initiate")}959+              >960+                Pair a new device961+              </button>962+              <button963+                class="peach-btn peach-btn--subtle peach-btn--sm"964+                onClick={() => props.onOpenSyncPairing?.("accept")}965+              >966+                Pair with another device967+              </button>968+            </div>969+          </div>970+971+          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">972+            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>973+            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (974+              <div class="peach-sync-device-row">975+                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>976+                <button977+                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"978+                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}957979                 >958-                  Purchase →959-                </a>980+                  Remove981+                </button>960982               </div>961-            </>962-          )}983+            ))}984+            <div class="peach-settings-actions--row">985+              <Show when={!isCurrentNetworkTrusted()}>986+                <button987+                  class="peach-btn peach-btn--subtle peach-btn--sm"988+                  onClick={() => void handleMarkNetworkTrusted()}989+                >990+                  Mark current network as trusted991+                </button>992+              </Show>993+            </div>994+            <span class="peach-sync-device-meta">995+              Tailscale is always trusted996+            </span>997+          </div>963998         </div>999+      </div>1000+    );1001+  }9641002 965-        {/* ── Keyboard shortcuts ────────────────────────────────────── */}1003+  function renderAppearance() {1004+    return (1005+      <div class="peach-settings-body">9661006         <div class="peach-settings-group">967-          <p class="peach-settings-label">keyboard shortcuts</p>1007+          <p class="peach-settings-sub-label">THEME</p>1008+          <div class="peach-settings-row peach-settings-row--meta">1009+            <span>Theme</span>1010+            <button1011+              class="peach-btn peach-btn--subtle peach-btn--sm"1012+              onClick={() =>1013+                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1014+              }1015+            >1016+              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1017+            </button>1018+          </div>1019+1020+          <p class="peach-settings-sub-label">ACCENT</p>1021+          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1022+            <span>Accent</span>1023+            <div class="peach-accent-picker">1024+              <For each={ACCENT_COLORS}>1025+                {(color) => (1026+                  <button1027+                    class="peach-accent-swatch"1028+                    classList={{ "is-active": props.accentColor === color.name }}1029+                    style={{ background: color.hex }}1030+                    onClick={() => void props.onAccentChange(color.name)}1031+                    aria-label={color.name}1032+                    title={color.name}1033+                  />1034+                )}1035+              </For>1036+            </div>1037+          </div>1038+        </div>1039+      </div>1040+    );1041+  }1042+1043+  function renderShortcuts() {1044+    return (1045+      <div class="peach-settings-body">1046+        <div class="peach-settings-group">1047+          <div class="peach-settings-row peach-settings-row--meta">1048+            <span>Inline popup shortcut</span>1049+            <a1050+              class="peach-settings-link"1051+              href="chrome://extensions/shortcuts"1052+              onClick={(e) => {1053+                e.preventDefault();1054+                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1055+              }}1056+            >1057+              Ctrl+Shift+Space — customize ↗1058+            </a>1059+          </div>9681060           <div class="peach-shortcuts-grid">9691061             <span class="peach-shortcut-key">C</span><span class="peach-shortcut-desc">copy password</span>9701062             <span class="peach-shortcut-key">U</span><span class="peach-shortcut-desc">copy username</span>@@ -974,13 +1066,159 @@9741066             <span class="peach-shortcut-key">⌘⇧P</span><span class="peach-shortcut-desc">autofill on page</span>9751067           </div>9761068         </div>1069+      </div>1070+    );1071+  }9771072 978-        <div class="peach-settings-footer">979-          <span>Peach {version}</span>980-          <span class="peach-divider"></span>981-          <span>Browser extension</span>1073+  function renderLicense() {1074+    return (1075+      <div class="peach-settings-body">1076+        <div class="peach-settings-group">1077+          <p class="peach-settings-sub-label">PLAN</p>1078+          <Show1079+            when={licenseInfo()?.valid && licenseInfo()?.plan === "pro"}1080+            fallback={1081+              <>1082+                <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">1083+                  <input1084+                    class="peach-underline-input"1085+                    type="text"1086+                    placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1087+                    value={licenseKeyInput()}1088+                    onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1089+                    style="width:100%;box-sizing:border-box"1090+                  />1091+                  <button1092+                    class="peach-btn peach-btn--subtle peach-btn--sm"1093+                    onClick={() => void handleActivateLicense()}1094+                    disabled={activatingLicense() || !licenseKeyInput().trim()}1095+                  >1096+                    {activatingLicense() ? "activating..." : "Activate"}1097+                  </button>1098+                </div>1099+                <Show when={licenseError()}>1100+                  <div class="peach-settings-row peach-settings-row--meta">1101+                    <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>1102+                  </div>1103+                </Show>1104+                <Show when={licenseSuccess()}>1105+                  <div class="peach-settings-row peach-settings-row--meta">1106+                    <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>1107+                  </div>1108+                </Show>1109+                <div class="peach-settings-row peach-settings-row--meta">1110+                  <a1111+                    href="https://peachpasswords.com/#pricing"1112+                    target="_blank"1113+                    rel="noopener noreferrer"1114+                    class="peach-link"1115+                    style="color:var(--peach-accent)"1116+                  >1117+                    Purchase →1118+                  </a>1119+                </div>1120+              </>1121+            }1122+          >1123+            <div class="peach-settings-row peach-settings-row--meta">1124+              <span>Peach Pro</span>1125+              <span class="peach-settings-value">1126+                activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}1127+              </span>1128+            </div>1129+          </Show>9821130         </div>9831131       </div>1132+    );1133+  }1134+1135+  function renderFeedback() {1136+    return (1137+      <div class="peach-settings-body">1138+        <div class="peach-settings-group">1139+          <p class="peach-feedback-intro">1140+            Help us improve Peach. Every report goes directly to our inbox.1141+          </p>1142+1143+          <p class="peach-settings-sub-label">TYPE</p>1144+          <div class="peach-feedback-chips">1145+            <For each={FEEDBACK_CATEGORIES}>1146+              {(cat) => (1147+                <button1148+                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1149+                  onClick={() => setFbCategory(cat)}1150+                >1151+                  {cat}1152+                </button>1153+              )}1154+            </For>1155+          </div>1156+1157+          <p class="peach-settings-sub-label">MESSAGE</p>1158+          <textarea1159+            class="peach-feedback-textarea"1160+            placeholder="Describe the issue or idea..."1161+            value={fbMessage()}1162+            onInput={(e) => setFbMessage(e.currentTarget.value)}1163+            rows={5}1164+          />1165+1166+          <p class="peach-feedback-meta">1167+            Version {version} · will be included automatically1168+          </p>1169+1170+          <Show when={fbResult() === "success"}>1171+            <p class="peach-feedback-result peach-feedback-result--ok">1172+              Sent — thank you1173+            </p>1174+          </Show>1175+          <Show when={fbResult() === "error"}>1176+            <p class="peach-feedback-result peach-feedback-result--err">1177+              Failed to send — try again1178+            </p>1179+          </Show>1180+1181+          <button1182+            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1183+            onClick={() => void handleSubmitFeedback()}1184+            disabled={fbSubmitting() || !fbMessage().trim()}1185+          >1186+            {fbSubmitting() ? "Sending..." : "Send feedback"}1187+          </button>1188+1189+          <p class="peach-feedback-anon">1190+            Sent anonymously · no account required1191+          </p>1192+        </div>1193+      </div>1194+    );1195+  }1196+1197+  return (1198+    <div class="peach-overlay-view ov-enter-right" style="display:flex">1199+      <div class="peach-overlay-header">1200+        <button1201+          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1202+          onClick={handleHeaderBack}1203+          aria-label="Back"1204+        >1205+          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1206+            <path d="M6 1L1 6L6 11" />1207+          </svg>1208+        </button>1209+        <span class="peach-overlay-title">{currentTitle()}</span>1210+        <div class="peach-overlay-spacer"></div>1211+      </div>1212+1213+      <Switch fallback={renderHome()}>1214+        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1215+        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1216+        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1217+        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1218+        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1219+        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1220+        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1221+      </Switch>9841222     </div>9851223   );9861224 }

view file

src/popup/views/contracts.test.tsx+27 −6
@@ -327,6 +327,12 @@327327     );328328     await flushView();329329 330+    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(331+      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"332+    ) as HTMLButtonElement;333+    securityRow.click();334+    await flushView();335+330336     const changeButton = Array.from(container.querySelectorAll("button")).find(331337       (candidate) => candidate.textContent?.trim() === "change"332338     ) as HTMLButtonElement;@@ -377,16 +383,18 @@377383     await flushView();378384     sendMessageMock.mockClear();379385 386+    const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;387+388+    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(389+      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"390+    ) as HTMLButtonElement;391+    securityRow.click();392+    await flushView();393+380394     const selects = container.querySelectorAll("select");381395     changeSelect(selects[0] as HTMLSelectElement, "15");382396     expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);383397 384-    const themeButton = Array.from(container.querySelectorAll("button")).find(385-      (candidate) => candidate.textContent?.trim() === "Switch to light"386-    ) as HTMLButtonElement;387-    themeButton.click();388-    expect(onThemeChange).toHaveBeenCalledWith("light");389-390398     const changeButton = Array.from(container.querySelectorAll("button")).find(391399       (candidate) => candidate.textContent?.trim() === "change"392400     ) as HTMLButtonElement;@@ -404,6 +412,19 @@404412     ) as HTMLButtonElement;405413     saveButton.click();406414     await flushView();415+416+    headerBack.click();417+    await flushView();418+    const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(419+      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"420+    ) as HTMLButtonElement;421+    appearanceRow.click();422+    await flushView();423+    const themeButton = Array.from(container.querySelectorAll("button")).find(424+      (candidate) => candidate.textContent?.trim() === "Switch to light"425+    ) as HTMLButtonElement;426+    themeButton.click();427+    expect(onThemeChange).toHaveBeenCalledWith("light");407428 408429     expect(sendMessageMock).toHaveBeenCalledWith({409430       type: "CHANGE_PASSWORD",

view file