a80d09ce056d
feat: Reimagine Settings as Stack tray deck (v3.6.21)
- time
- 2026-05-19 01:54 UTC · 5 months ago
- machine
- koh2
- parent
- fef762d2460d
↓ peach-browser-a80d09ce.tar.gzatlas at this save
files changed16 files
manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/popup/components/settings/CardIcon.tsx+74 −0src/popup/components/settings/StackCard.tsx+56 −0src/popup/components/settings/StackDeck.tsx+96 −0src/popup/components/settings/StackPill.tsx+20 −0src/popup/components/settings/StackToggle.tsx+21 −0src/popup/components/settings/types.ts+31 −0src/popup/index.html+1 −0src/popup/styles/settings-stack.css+547 −0src/popup/styles/vars.css+19 −0src/popup/views/ChirpSync.tsx+3 −2src/popup/views/Settings.tsx+164 −171src/popup/views/contracts.test.tsx+7 −28manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach",5- "version": "3.6.17",5+ "version": "3.6.21",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "3.6.17",4+ "version": "3.6.21",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22 "name": "peach",3- "version": "3.2.0",3+ "version": "3.6.21",44 "lockfileVersion": 3,55 "requires": true,66 "packages": {77 "": {88 "name": "peach",9- "version": "3.2.0",9+ "version": "3.6.21",1010 "dependencies": {1111 "@bokuweb/zstd-wasm": "^0.0.27",1212 "@digitaldefiance/reed-solomon-erasure.wasm": "^1.0.2",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.6.17",3+ "version": "3.6.21",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/popup/components/settings/CardIcon.tsx+74 −0
@@ -0,0 +1,74 @@1+import type { StackIconKind } from "./types";2+3+interface CardIconProps {4+ kind: StackIconKind;5+}6+7+export function CardIcon(props: CardIconProps) {8+ const common = {9+ width: 16,10+ height: 16,11+ viewBox: "0 0 16 16",12+ fill: "none",13+ stroke: "currentColor",14+ "stroke-width": "1.25",15+ "stroke-linecap": "round",16+ "stroke-linejoin": "round",17+ "aria-hidden": true,18+ } as const;19+20+ switch (props.kind) {21+ case "shield":22+ return (23+ <svg {...common}>24+ <path d="M8 2.2 3.4 4.4v3.2c0 3 2.1 5.1 4.6 6.2 2.5-1.1 4.6-3.2 4.6-6.2V4.4L8 2.2Z" />25+ <path d="M6.3 8.1 7.4 9.2 9.9 6.6" />26+ </svg>27+ );28+ case "lock":29+ return (30+ <svg {...common}>31+ <rect x="3" y="7" width="10" height="7" rx="1.6" />32+ <path d="M5.8 7V5.4a2.2 2.2 0 0 1 4.4 0V7" />33+ </svg>34+ );35+ case "sync":36+ return (37+ <svg {...common}>38+ <path d="M4 8a4 4 0 0 1 6.8-2.8" />39+ <path d="M11 3.6v2.2H8.8" />40+ <path d="M12 8a4 4 0 0 1-6.8 2.8" />41+ <path d="M5 12.4v-2.2h2.2" />42+ </svg>43+ );44+ case "sun":45+ return (46+ <svg {...common}>47+ <circle cx="8" cy="8" r="2.8" />48+ <path d="M8 1.8v1.3M8 12.9v1.3M1.8 8h1.3M12.9 8h1.3M3.6 3.6l.9.9M11.5 11.5l.9.9M12.4 3.6l-.9.9M4.5 11.5l-.9.9" />49+ </svg>50+ );51+ case "keys":52+ return (53+ <svg {...common}>54+ <rect x="2" y="5" width="5" height="4" rx="1" />55+ <rect x="9" y="7" width="5" height="4" rx="1" />56+ <path d="M7 7h2M7 9h2" />57+ </svg>58+ );59+ case "spark":60+ return (61+ <svg {...common}>62+ <path d="M8 1.8 9.2 6l4.1 1.2-4.1 1.2L8 12.6 6.8 8.4 2.7 7.2 6.8 6 8 1.8Z" />63+ <path d="m12 11 .5 1.5L14 13l-1.5.5L12 15l-.5-1.5L10 13l1.5-.5L12 11Z" />64+ </svg>65+ );66+ case "bubble":67+ return (68+ <svg {...common}>69+ <path d="M3 4.2h10a1 1 0 0 1 1 1v5.6a1 1 0 0 1-1 1H6l-3.2 2V5.2a1 1 0 0 1 1-1Z" />70+ <path d="M5.5 7h5M5.5 9.2h3.2" />71+ </svg>72+ );73+ }74+}src/popup/components/settings/StackCard.tsx+56 −0
@@ -0,0 +1,56 @@1+import type { JSX } from "solid-js";2+import { CardIcon } from "./CardIcon";3+import type { StackCardSpec } from "./types";4+5+interface StackCardProps {6+ spec: StackCardSpec;7+ active: boolean;8+ focused?: boolean;9+ concealed?: boolean;10+ top: number;11+ zIndex: number;12+ height: number;13+ onClick: () => void;14+ children: JSX.Element;15+}16+17+export function StackCard(props: StackCardProps) {18+ return (19+ <article20+ class={`stk-card ${props.active ? "is-active" : "is-tab"} ${props.focused ? "is-focused" : ""} ${props.concealed ? "is-concealed" : ""}`}21+ data-settings-card-id={props.spec.id}22+ data-active={props.active ? "true" : "false"}23+ role={props.active || props.concealed ? undefined : "button"}24+ aria-hidden={props.concealed ? "true" : undefined}25+ aria-label={props.active || props.concealed ? undefined : `Open ${props.spec.title}`}26+ tabIndex={props.active || props.concealed ? -1 : 0}27+ onClick={() => {28+ if (!props.active && !props.concealed) props.onClick();29+ }}30+ onKeyDown={(e) => {31+ if (props.active) return;32+ if (e.key === "Enter" || e.key === " ") {33+ e.preventDefault();34+ props.onClick();35+ }36+ }}37+ style={{38+ background: `var(${props.spec.bgVar})`,39+ color: `var(${props.spec.fgVar})`,40+ transform: `translateY(${props.top}px)`,41+ "z-index": props.zIndex,42+ height: `${props.height}px`,43+ }}44+ >45+ <div class="stk-card-top">46+ <span class="stk-card-num">{props.spec.num}</span>47+ <span class="stk-card-icon">48+ <CardIcon kind={props.spec.icon} />49+ </span>50+ </div>51+ <h2 class="stk-card-title">{props.spec.title}</h2>52+ <p class="stk-card-lede">{props.spec.lede}</p>53+ <div class="stk-card-body">{props.children}</div>54+ </article>55+ );56+}src/popup/components/settings/StackDeck.tsx+96 −0
@@ -0,0 +1,96 @@1+import { createMemo, createSignal, For, Show } from "solid-js";2+import { StackCard } from "./StackCard";3+import type { CardId, StackBodies, StackCardSpec } from "./types";4+5+interface StackDeckProps {6+ active: CardId;7+ onActiveChange: (id: CardId) => void;8+ onBack: () => void;9+ cards: readonly StackCardSpec[];10+ bodies: StackBodies;11+}12+13+const EXPANDED_H = 248;14+const FOCUS_H = 420;15+const TAB_H = 46;16+const TAB_OVERLAP = 14;17+18+export function StackDeck(props: StackDeckProps) {19+ const [deckOpen, setDeckOpen] = createSignal(true);20+ const activeIndex = createMemo(() => {21+ const idx = props.cards.findIndex((card) => card.id === props.active);22+ return idx >= 0 ? idx : 0;23+ });24+25+ return (26+ <section class="stk-shell" aria-label="Settings">27+ <div class="stk-mast">28+ <button29+ class="stk-back"30+ onClick={props.onBack}31+ aria-label="Back"32+ type="button"33+ >34+ <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">35+ <path d="M6 1L1 6L6 11" />36+ </svg>37+ </button>38+ <div class="stk-mast-title">Settings</div>39+ <div class="stk-mast-meta">40+ {activeIndex() + 1} / {props.cards.length}41+ </div>42+ </div>43+44+ <div class={`stk-deck ${deckOpen() ? "is-open" : "is-focused"}`}>45+ <For each={props.cards}>46+ {(card, index) => {47+ const active = () => card.id === props.active;48+ const concealed = () => !deckOpen() && !active();49+ const distance = createMemo(() => {50+ const ai = activeIndex();51+ const ci = index();52+ if (ci === ai) return -1;53+ if (ci > ai) return ci - ai - 1;54+ return props.cards.length - ai + ci - 1;55+ });56+ const top = () => {57+ if (active()) return 0;58+ if (!deckOpen()) return FOCUS_H + 44 + distance() * 8;59+ return EXPANDED_H + distance() * (TAB_H - TAB_OVERLAP);60+ };61+ const zIndex = () => (active() ? 99 : 50 - distance());62+63+ return (64+ <StackCard65+ spec={card}66+ active={active()}67+ focused={!deckOpen() && active()}68+ concealed={concealed()}69+ top={top()}70+ zIndex={zIndex()}71+ height={active() ? (deckOpen() ? EXPANDED_H : FOCUS_H) : TAB_H + 4}72+ onClick={() => {73+ if (active()) return;74+ props.onActiveChange(card.id);75+ setDeckOpen(false);76+ }}77+ >78+ {props.bodies[card.id]}79+ </StackCard>80+ );81+ }}82+ </For>83+ <Show when={!deckOpen()}>84+ <button85+ class="stk-tray-handle"86+ type="button"87+ aria-label="Show settings sections"88+ onClick={() => setDeckOpen(true)}89+ >90+ <span>sections</span>91+ </button>92+ </Show>93+ </div>94+ </section>95+ );96+}src/popup/components/settings/StackPill.tsx+20 −0
@@ -0,0 +1,20 @@1+import type { JSX } from "solid-js";2+3+interface StackPillProps {4+ accent?: boolean;5+ class?: string;6+ children: JSX.Element;7+ onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>;8+}9+10+export function StackPill(props: StackPillProps) {11+ return (12+ <button13+ class={`stk-pill ${props.accent ? "is-accent" : ""} ${props.class ?? ""}`}14+ onClick={props.onClick}15+ type="button"16+ >17+ {props.children}18+ </button>19+ );20+}src/popup/components/settings/StackToggle.tsx+21 −0
@@ -0,0 +1,21 @@1+interface StackToggleProps {2+ on: boolean;3+ disabled?: boolean;4+ label: string;5+ onToggle: () => void;6+}7+8+export function StackToggle(props: StackToggleProps) {9+ return (10+ <button11+ class={`peach-toggle ${props.on ? "is-on" : ""}`}12+ onClick={props.onToggle}13+ aria-label={props.label}14+ aria-pressed={props.on}15+ disabled={props.disabled}16+ type="button"17+ >18+ <span class="peach-toggle-knob" />19+ </button>20+ );21+}src/popup/components/settings/types.ts+31 −0
@@ -0,0 +1,31 @@1+import type { JSX } from "solid-js";2+3+export type CardId =4+ | "protection"5+ | "vault"6+ | "sync"7+ | "appearance"8+ | "shortcuts"9+ | "pro"10+ | "feedback";11+12+export type StackIconKind =13+ | "shield"14+ | "lock"15+ | "sync"16+ | "sun"17+ | "keys"18+ | "spark"19+ | "bubble";20+21+export interface StackCardSpec {22+ id: CardId;23+ num: string;24+ title: string;25+ lede: string;26+ bgVar: string;27+ fgVar: string;28+ icon: StackIconKind;29+}30+31+export type StackBodies = Record<CardId, JSX.Element>;src/popup/index.html+1 −0
@@ -19,6 +19,7 @@1919 />2020 <link rel="stylesheet" href="./styles/vars.css" />2121 <link rel="stylesheet" href="./styles/popup.css" />22+ <link rel="stylesheet" href="./styles/settings-stack.css" />2223 <link rel="stylesheet" href="./styles/codex.css" />2324 <title>Peach</title>2425 </head>src/popup/styles/settings-stack.css+547 −0
@@ -0,0 +1,547 @@1+/* Settings Stack — layered category deck for the full settings surface. */2+3+.stk-shell {4+ flex: 1;5+ min-height: 0;6+ position: relative;7+ overflow: hidden;8+ padding: 10px 12px 10px;9+ background: var(--peach-bg);10+}11+12+.stk-mast {13+ display: flex;14+ align-items: center;15+ justify-content: space-between;16+ gap: 10px;17+ padding: 0 0 8px;18+}19+20+.stk-back {21+ width: 28px;22+ height: 28px;23+ display: inline-flex;24+ align-items: center;25+ justify-content: center;26+ flex: 0 0 auto;27+ border: 1px solid var(--peach-border);28+ border-radius: 999px;29+ background: color-mix(in srgb, var(--peach-t0) 4%, transparent);30+ color: var(--peach-t1);31+ cursor: pointer;32+}33+34+.stk-back:hover {35+ color: var(--peach-t0);36+ border-color: color-mix(in srgb, var(--peach-accent) 34%, transparent);37+ background: color-mix(in srgb, var(--peach-accent) 10%, transparent);38+}39+40+.stk-mast-title {41+ flex: 1;42+ font-family: var(--font-display);43+ font-style: italic;44+ font-weight: 400;45+ font-size: 22px;46+ line-height: 1;47+ color: var(--peach-t0);48+}49+50+.stk-mast-meta {51+ font-family: var(--font-mono);52+ font-size: 9px;53+ letter-spacing: 0.22em;54+ text-transform: uppercase;55+ color: var(--peach-t2);56+}57+58+.stk-deck {59+ position: relative;60+ height: min(462px, calc(100% - 36px));61+ min-height: 462px;62+ margin-top: 0;63+ overflow: hidden;64+}65+66+.stk-deck.is-focused {67+ overflow: visible;68+}69+70+.stk-card {71+ position: absolute;72+ left: 0;73+ right: 0;74+ border-radius: var(--stack-card-radius);75+ padding: 18px 18px 16px;76+ overflow: hidden;77+ cursor: pointer;78+ transition:79+ transform 380ms var(--stack-spring),80+ height 380ms var(--stack-spring),81+ box-shadow 280ms ease,82+ opacity 220ms ease;83+ box-shadow:84+ 0 1px 0 color-mix(in srgb, var(--peach-t0) 5%, transparent) inset,85+ 0 8px 20px rgba(0, 0, 0, 0.25);86+ border: 1px solid color-mix(in srgb, var(--peach-t0) 6%, transparent);87+ will-change: transform;88+}89+90+.stk-card::after {91+ content: "";92+ position: absolute;93+ inset: 0;94+ border-radius: inherit;95+ pointer-events: none;96+ background:97+ radial-gradient(98+ 120% 80% at 0% 0%,99+ color-mix(in srgb, #fff 12%, transparent),100+ transparent 50%101+ );102+}103+104+.stk-card.is-active {105+ box-shadow:106+ 0 1px 0 color-mix(in srgb, var(--peach-t0) 8%, transparent) inset,107+ 0 18px 36px rgba(0, 0, 0, 0.42);108+ cursor: default;109+}110+111+.stk-card.is-focused {112+ cursor: default;113+}114+115+.stk-card.is-concealed {116+ opacity: 0;117+ pointer-events: none;118+}119+120+.stk-card-top {121+ display: flex;122+ justify-content: space-between;123+ align-items: center;124+ margin-bottom: 12px;125+ position: relative;126+ z-index: 1;127+}128+129+.stk-card-num {130+ font-family: var(--font-mono);131+ font-size: 8.5px;132+ letter-spacing: 0.26em;133+ text-transform: uppercase;134+ opacity: 0.7;135+}136+137+.stk-card-icon {138+ opacity: 0.85;139+}140+141+.stk-card-title {142+ position: relative;143+ z-index: 1;144+ margin: 0;145+ font-family: var(--font-display);146+ font-style: italic;147+ font-weight: 400;148+ font-size: 26px;149+ line-height: 1;150+}151+152+.stk-card-lede {153+ position: relative;154+ z-index: 1;155+ margin: 4px 0 0;156+ font-family: var(--font-display);157+ font-style: italic;158+ font-size: 12.5px;159+ line-height: 1.4;160+ opacity: 0.72;161+}162+163+.stk-card-body {164+ position: relative;165+ z-index: 1;166+ height: calc(100% - 76px);167+ margin-top: 14px;168+ overflow-y: auto;169+ overflow-x: hidden;170+ opacity: 0;171+ scrollbar-width: thin;172+ scrollbar-color: color-mix(in srgb, currentColor 28%, transparent) transparent;173+ transition: opacity 200ms 100ms ease;174+}175+176+.stk-card.is-active .stk-card-body {177+ opacity: 1;178+}179+180+.stk-card.is-tab .stk-card-top {181+ display: none;182+}183+184+.stk-card.is-tab {185+ padding: 10px 16px 8px;186+}187+188+.stk-card.is-tab .stk-card-title {189+ font-size: 19px;190+ line-height: 1;191+ white-space: nowrap;192+ overflow: hidden;193+ text-overflow: ellipsis;194+}195+196+.stk-card.is-tab .stk-card-lede,197+.stk-card.is-tab .stk-card-body {198+ display: none;199+}200+201+.stk-card *:focus-visible {202+ outline: 1px solid currentColor;203+ outline-offset: 2px;204+}205+206+.stk-tray-handle {207+ position: absolute;208+ left: 50%;209+ bottom: 8px;210+ z-index: 120;211+ transform: translateX(-50%);212+ height: 30px;213+ min-width: 112px;214+ display: inline-flex;215+ align-items: center;216+ justify-content: center;217+ border: 1px solid color-mix(in srgb, var(--peach-accent) 32%, transparent);218+ border-radius: 999px;219+ background:220+ linear-gradient(180deg, color-mix(in srgb, var(--peach-bg) 88%, transparent), color-mix(in srgb, var(--peach-bg) 72%, transparent)),221+ color-mix(in srgb, var(--peach-accent) 10%, transparent);222+ color: var(--peach-t1);223+ font-family: var(--font-mono);224+ font-size: 9px;225+ letter-spacing: 0.2em;226+ text-transform: uppercase;227+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22);228+ cursor: pointer;229+}230+231+.stk-tray-handle::before {232+ content: "";233+ position: absolute;234+ top: 5px;235+ width: 26px;236+ height: 2px;237+ border-radius: 999px;238+ background: color-mix(in srgb, currentColor 45%, transparent);239+}240+241+.stk-tray-handle span {242+ transform: translateY(4px);243+}244+245+.stk-row {246+ display: flex;247+ align-items: center;248+ justify-content: space-between;249+ gap: 12px;250+ padding: 10px 0;251+ border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);252+}253+254+.stk-row:last-child {255+ border-bottom: none;256+}257+258+.stk-row-name {259+ font-size: 13px;260+}261+262+.stk-row-sub {263+ margin-top: 2px;264+ font-family: var(--font-display);265+ font-style: italic;266+ font-size: 11px;267+ opacity: 0.7;268+}269+270+.stk-pill {271+ display: inline-flex;272+ align-items: center;273+ justify-content: center;274+ min-height: 24px;275+ padding: 3px 10px;276+ font-family: var(--font-mono);277+ font-size: 10px;278+ letter-spacing: 0.08em;279+ text-transform: lowercase;280+ background: transparent;281+ color: currentColor;282+ border: 1px solid color-mix(in srgb, currentColor 35%, transparent);283+ border-radius: 999px;284+ cursor: pointer;285+ transition: background 140ms ease, border-color 140ms ease;286+}287+288+.stk-pill:hover,289+.stk-pill.is-accent {290+ background: color-mix(in srgb, currentColor 18%, transparent);291+ border-color: color-mix(in srgb, currentColor 55%, transparent);292+}293+294+.stk-pip {295+ display: inline-block;296+ width: 18px;297+ height: 18px;298+ border-radius: 50%;299+ background: rgba(0, 0, 0, 0.18);300+ border: 1px solid color-mix(in srgb, currentColor 24%, transparent);301+}302+303+.stk-pip.is-on {304+ background: var(--peach-health-ok);305+ box-shadow: 0 0 6px color-mix(in srgb, var(--peach-health-ok) 55%, transparent);306+}307+308+.stk-sync-actions,309+.stk-fb-chips,310+.stk-inline-actions {311+ display: flex;312+ flex-wrap: wrap;313+ gap: 6px;314+}315+316+.stk-key {317+ font-family: var(--font-mono);318+ font-size: 10px;319+ letter-spacing: 0.06em;320+ padding: 2px 7px;321+ border: 1px solid color-mix(in srgb, currentColor 32%, transparent);322+ border-radius: 4px;323+ background: color-mix(in srgb, currentColor 6%, transparent);324+}325+326+.stk-pro-headline {327+ padding: 6px 0 14px;328+ border-bottom: 1px solid color-mix(in srgb, currentColor 14%, transparent);329+}330+331+.stk-pro-title,332+.stk-pro-price {333+ font-family: var(--font-display);334+ font-style: italic;335+ line-height: 1;336+}337+338+.stk-pro-title {339+ font-size: 22px;340+}341+342+.stk-pro-price {343+ font-size: 28px;344+}345+346+.stk-pro-meta {347+ margin-top: 4px;348+ font-family: var(--font-mono);349+ font-size: 9.5px;350+ letter-spacing: 0.18em;351+ text-transform: uppercase;352+ opacity: 0.7;353+}354+355+.stk-card .peach-settings-body {356+ display: block;357+ flex: none;358+ min-height: 0;359+ overflow: visible;360+ padding: 0;361+ background: transparent;362+}363+364+.stk-card .peach-settings-group {365+ margin: 0;366+ padding: 0;367+ border: 0;368+ border-radius: 0;369+ background: transparent;370+ box-shadow: none;371+ backdrop-filter: none;372+ -webkit-backdrop-filter: none;373+}374+375+.stk-card .peach-settings-group + .peach-settings-group {376+ margin-top: 0;377+}378+379+.stk-card .peach-settings-sub-label,380+.stk-card .peach-settings-label {381+ margin: 12px 0 4px;382+ color: color-mix(in srgb, currentColor 58%, transparent);383+}384+385+.stk-card .peach-settings-sub-label:first-child,386+.stk-card .peach-settings-label:first-child {387+ margin-top: 0;388+}389+390+.stk-card .peach-settings-row,391+.stk-card .peach-shortcut-row,392+.stk-card .peach-history-row {393+ border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);394+ color: currentColor;395+}396+397+.stk-card .peach-settings-row--meta,398+.stk-card .peach-settings-value,399+.stk-card .peach-hint,400+.stk-card .peach-settings-hint,401+.stk-card .peach-feedback-meta,402+.stk-card .peach-feedback-anon,403+.stk-card .peach-feedback-intro,404+.stk-card .peach-history-sub,405+.stk-card .peach-history-footer,406+.stk-card .peach-danger-card-copy {407+ color: color-mix(in srgb, currentColor 70%, transparent);408+}409+410+.stk-card .peach-toggle {411+ --peach-accent: currentColor;412+ border-color: color-mix(in srgb, currentColor 32%, transparent);413+ background: color-mix(in srgb, currentColor 8%, transparent);414+}415+416+.stk-card .peach-toggle.is-on {417+ background: color-mix(in srgb, currentColor 28%, transparent);418+ border-color: color-mix(in srgb, currentColor 50%, transparent);419+}420+421+.stk-card .peach-toggle-knob {422+ background: color-mix(in srgb, currentColor 58%, transparent);423+}424+425+.stk-card .peach-toggle.is-on .peach-toggle-knob {426+ background: currentColor;427+}428+429+.stk-card .peach-btn--subtle,430+.stk-card .peach-btn--ghost,431+.stk-card .peach-btn--accent,432+.stk-card .peach-shortcut-chip,433+.stk-card .peach-shortcut-reset,434+.stk-card .peach-feedback-chip,435+.stk-card .peach-settings-license-copy,436+.stk-card .peach-settings-license-toggle {437+ color: currentColor;438+ border-color: color-mix(in srgb, currentColor 28%, transparent);439+ background: color-mix(in srgb, currentColor 8%, transparent);440+}441+442+.stk-card .peach-btn--subtle:hover,443+.stk-card .peach-btn--ghost:hover,444+.stk-card .peach-btn--accent:hover,445+.stk-card .peach-shortcut-chip:hover,446+.stk-card .peach-feedback-chip:hover,447+.stk-card .peach-feedback-chip.is-active {448+ background: color-mix(in srgb, currentColor 16%, transparent);449+}450+451+.stk-card .peach-settings-input,452+.stk-card .peach-settings-select,453+.stk-card .peach-pro-key-inline-input,454+.stk-card .peach-feedback-textarea {455+ color: currentColor;456+ border-color: color-mix(in srgb, currentColor 22%, transparent);457+ background: rgba(0, 0, 0, 0.18);458+}459+460+.stk-card .peach-settings-input::placeholder,461+.stk-card .peach-pro-key-inline-input::placeholder,462+.stk-card .peach-feedback-textarea::placeholder {463+ color: color-mix(in srgb, currentColor 42%, transparent);464+}465+466+.stk-card .peach-accent-picker {467+ justify-content: flex-end;468+ max-width: 160px;469+}470+471+.stk-card .peach-accent-swatch {472+ width: 20px;473+ height: 20px;474+}475+476+.stk-card .peach-accent-swatch.is-active {477+ border-color: currentColor;478+}479+480+.stk-card .peach-history-list {481+ border-color: color-mix(in srgb, currentColor 18%, transparent);482+}483+484+.stk-card .peach-danger-card {485+ background: rgba(0, 0, 0, 0.18);486+ border-color: color-mix(in srgb, currentColor 20%, transparent);487+}488+489+.stk-card .peach-pro-upgrade,490+.stk-card .peach-pro-active,491+.stk-card .peach-pro-active-hero,492+.stk-card .peach-pro-grid-item {493+ background: transparent;494+ border-color: color-mix(in srgb, currentColor 14%, transparent);495+ box-shadow: none;496+}497+498+.stk-card .peach-pro-grid {499+ gap: 6px;500+}501+502+.stk-card .peach-pro-grid-item {503+ padding: 7px 0;504+}505+506+.stk-card .peach-pro-headline,507+.stk-card .peach-pro-active-title {508+ color: currentColor;509+}510+511+.stk-card .peach-feedback-textarea {512+ min-height: 116px;513+}514+515+.light .stk-shell,516+[data-theme="light"] .stk-shell {517+ background: var(--peach-bg);518+}519+520+.light .stk-card,521+[data-theme="light"] .stk-card {522+ box-shadow:523+ 0 1px 0 color-mix(in srgb, var(--peach-t0) 5%, transparent) inset,524+ 0 8px 20px rgba(78, 56, 36, 0.18);525+}526+527+.light .stk-card.is-active,528+[data-theme="light"] .stk-card.is-active {529+ box-shadow:530+ 0 1px 0 color-mix(in srgb, var(--peach-t0) 8%, transparent) inset,531+ 0 18px 36px rgba(78, 56, 36, 0.35);532+}533+534+:root[data-native="true"] {535+ --stack-tab-h: 64px;536+ --stack-tab-overlap: 16px;537+}538+539+:root[data-native="true"] .stk-deck {540+ height: calc(100dvh - 126px);541+ min-height: 520px;542+}543+544+[data-motion="reduce"] .stk-card,545+[data-motion="reduce"] .stk-card-body {546+ transition: none !important;547+}src/popup/styles/vars.css+19 −0
@@ -155,6 +155,25 @@155155 --peach-copy-clear: 1.4s;156156 --peach-rune-idle: rgba(245, 240, 234, 0.16);157157 --peach-ghost-opacity: 0.03;158+ --stack-protect-fg: #FFEEDD;159+ --stack-protect-bg: linear-gradient(155deg, #B85A3A 0%, #6E2818 100%);160+ --stack-vault-fg: #E5EEFA;161+ --stack-vault-bg: linear-gradient(155deg, #2A3A4D 0%, #0F1620 100%);162+ --stack-sync-fg: #E6F2DE;163+ --stack-sync-bg: linear-gradient(155deg, #3A4E3A 0%, #15241A 100%);164+ --stack-appear-fg: #F4E9D2;165+ --stack-appear-bg: linear-gradient(155deg, #5A4A2C 0%, #281D10 100%);166+ --stack-shortcut-fg: #E4E0D6;167+ --stack-shortcut-bg: linear-gradient(155deg, #3A3A45 0%, #1A1A22 100%);168+ --stack-pro-fg: #FFF1DD;169+ --stack-pro-bg: linear-gradient(155deg, #C2884A 0%, #6E3E1A 100%);170+ --stack-feedback-fg: #EEE4F2;171+ --stack-feedback-bg: linear-gradient(155deg, #4A3A4E 0%, #1D1424 100%);172+ --stack-card-radius: 16px;173+ --stack-tab-h: 56px;174+ --stack-tab-overlap: 14px;175+ --stack-pad: 14px;176+ --stack-spring: cubic-bezier(0.18, 0.9, 0.22, 1.02);158177 /* Tell the browser engine which color schemes the UI knows how to render —159178 this gets us correct form-control colors and scrollbar tinting in the160179 WebView when the user follows the system theme. */src/popup/views/ChirpSync.tsx+3 −2
@@ -76,7 +76,9 @@7676 // during a user gesture. Firefox ignores this API but getUserMedia works7777 // under its existing host permission grant.7878 if (typeof chrome !== "undefined" && chrome.permissions?.request) {79- const granted = await chrome.permissions.request({ permissions: ["audioCapture"] });79+ const granted = await chrome.permissions.request({80+ permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],81+ });8082 if (!granted) {8183 setPairing({ phase: "mic_denied" });8284 setMode("pairing");@@ -401,4 +403,3 @@401403 </div>402404 );403405 };404-src/popup/views/Settings.tsx+164 −171
@@ -1,7 +1,8 @@1-import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";1+import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js";22 import { sendMessage } from "../App";33 import { PeachMark } from "../components/PeachMark";4-import { LockRuneCanvas } from "../components/LockRuneCanvas";4+import { StackDeck } from "../components/settings/StackDeck";5+import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";56 import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";67 import type { Canary } from "../../shared/types";78 import { ACCENT_COLORS } from "../../shared/types";@@ -68,14 +69,6 @@6869 onProGate?: (feature: ProFeature) => void;6970 }7071 71-type SectionName =72- | "security"73- | "vault"74- | "appearance"75- | "shortcuts"76- | "license"77- | "feedback";78-7972 const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];8073 8174 function formatActivatedSince(issued: string | undefined): string {@@ -89,24 +82,30 @@8982 }9083 }9184 92-function isSectionName(value: string): value is SectionName {93- return (94- value === "security" ||85+function initialSectionToCard(value: string | null | undefined): CardId | null {86+ if (!value) return null;87+ if (value === "security") return "protection";88+ if (value === "license") return "pro";89+ if (90+ value === "protection" ||9591 value === "vault" ||92+ value === "sync" ||9693 value === "appearance" ||9794 value === "shortcuts" ||98- value === "license" ||95+ value === "pro" ||9996 value === "feedback"100- );97+ ) {98+ return value;99+ }100+ return null;101101 }102102 103103 export function SettingsView(props: SettingsProps) {104104 const manifest = chrome.runtime.getManifest();105105 const version = manifest.version;106- const isAndroid = (manifest as any).version_name === 'mobile';107106 const initial = props.initialSection;108- const [activeSection, setActiveSection] = createSignal<SectionName | null>(109- initial && isSectionName(initial) ? (initial as SectionName) : null107+ const [activeCard, setActiveCard] = createSignal<CardId>(108+ props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"110109 );111110 112111 const [checkingBreaches, setCheckingBreaches] = createSignal(false);@@ -118,6 +117,71 @@118117 const [activatingLicense, setActivatingLicense] = createSignal(false);119118 120119 const isPro = () => props.isPro ?? false;120+ const cards = createMemo<StackCardSpec[]>(() => [121+ {122+ id: "protection",123+ num: "I · PROTECT",124+ title: "Protection",125+ lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,126+ bgVar: "--stack-protect-bg",127+ fgVar: "--stack-protect-fg",128+ icon: "shield",129+ },130+ {131+ id: "vault",132+ num: "II · KEEP",133+ title: "Vault",134+ lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,135+ bgVar: "--stack-vault-bg",136+ fgVar: "--stack-vault-fg",137+ icon: "lock",138+ },139+ {140+ id: "sync",141+ num: "III · ECHO",142+ title: "Sync",143+ lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",144+ bgVar: "--stack-sync-bg",145+ fgVar: "--stack-sync-fg",146+ icon: "sync",147+ },148+ {149+ id: "appearance",150+ num: "IV · DRESS",151+ title: "Appearance",152+ lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,153+ bgVar: "--stack-appear-bg",154+ fgVar: "--stack-appear-fg",155+ icon: "sun",156+ },157+ {158+ id: "shortcuts",159+ num: "V · KEYS",160+ title: "Shortcuts",161+ lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,162+ bgVar: "--stack-shortcut-bg",163+ fgVar: "--stack-shortcut-fg",164+ icon: "keys",165+ },166+ {167+ id: "pro",168+ num: "VI · FOREVER",169+ title: "Peach Pro",170+ lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",171+ bgVar: "--stack-pro-bg",172+ fgVar: "--stack-pro-fg",173+ icon: "spark",174+ },175+ {176+ id: "feedback",177+ num: "VII · ECHO",178+ title: "Feedback",179+ lede: `version ${version} · tell us what would make it better`,180+ bgVar: "--stack-feedback-bg",181+ fgVar: "--stack-feedback-fg",182+ icon: "bubble",183+ },184+ ]);121185 const [biometricSupported, setBiometricSupported] = createSignal(false);122186 const [biometricStored, setBiometricStored] = createSignal(false);123187 const [biometricBusy, setBiometricBusy] = createSignal(false);@@ -219,6 +283,12 @@219283 const [fbSubmitting, setFbSubmitting] = createSignal(false);220284 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);221285 286+ createEffect(() => {287+ if (props.upgradeHighlight) {288+ setActiveCard("pro");289+ }290+ });291+222292 onMount(async () => {223293 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([224294 sendMessage({ type: "GET_LICENSE" }),@@ -519,133 +589,6 @@519589 }520590 }521591 522- function handleHeaderBack() {523- if (activeSection() !== null) {524- setActiveSection(null);525- } else {526- props.onBack();527- }528- }529-530- function currentTitle(): string {531- const s = activeSection();532- if (s === null) return "settings";533- if (s === "license") return "account";534- return s;535- }536-537- function renderCatRow(opts: {538- section: SectionName;539- name: string;540- desc: string;541- icon: () => any;542- pro?: boolean;543- }) {544- return (545- <button546- class="peach-settings-cat-row"547- onClick={() => setActiveSection(opts.section)}548- >549- <div class="peach-settings-cat-icon">{opts.icon()}</div>550- <div class="peach-settings-cat-content">551- <span class="peach-settings-cat-name">552- {opts.name}553- <Show when={opts.pro && !isPro()}>554- <span class="peach-pro-mark" aria-label="Pro feature">✦</span>555- </Show>556- </span>557- <span class="peach-settings-cat-desc">{opts.desc}</span>558- </div>559- <svg560- class="peach-settings-cat-chevron"561- width="6" height="10" viewBox="0 0 6 10" fill="none"562- stroke="currentColor" stroke-width="1.1"563- stroke-linecap="round" stroke-linejoin="round"564- >565- <path d="M1 1l4 4-4 4" />566- </svg>567- </button>568- );569- }570-571- const iconShield = () => (572- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">573- <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>574- </svg>575- );576- const iconKey = () => (577- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">578- <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>579- </svg>580- );581- const iconLock = () => (582- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">583- <rect x="3" y="7" width="10" height="7" rx="1.5"/>584- <path d="M6 7V5a3 3 0 016 0v2"/>585- </svg>586- );587- const iconSync = () => (588- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">589- <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>590- <path d="M14 6l-2 2-2-2"/>591- <path d="M2 10l2-2 2 2"/>592- </svg>593- );594- const iconSun = () => (595- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">596- <circle cx="8" cy="8" r="3"/>597- <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"/>598- </svg>599- );600- const iconKeys = () => (601- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">602- <rect x="2" y="5" width="5" height="4" rx="1"/>603- <rect x="9" y="7" width="5" height="4" rx="1"/>604- <path d="M7 7h2m-2 2h2"/>605- </svg>606- );607- const iconBubble = () => (608- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">609- <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>610- </svg>611- );612-613- function renderHome() {614- return (615- <div class="peach-settings-body">616- <p class="peach-settings-nav-label">PROTECTION</p>617- {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}618- {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}619-620- <p class="peach-settings-nav-label">VAULT</p>621- {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}622- <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>623- <div class="peach-settings-cat-icon">{iconSync()}</div>624- <div class="peach-settings-cat-content">625- <span class="peach-settings-cat-name">Sync</span>626- <span class="peach-settings-cat-desc">Pair and sync across devices</span>627- </div>628- <svg class="peach-settings-cat-chevron" width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">629- <path d="M1 1l4 4-4 4" />630- </svg>631- </button>632-633- <p class="peach-settings-nav-label">PREFERENCES</p>634- {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}635- {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}636-637- <p class="peach-settings-nav-label">SUPPORT</p>638- {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}639-640- <div class="peach-settings-footer">641- <span>Peach {version}</span>642- <span class="peach-divider"></span>643- <span>{isAndroid ? 'Android app' : 'Browser extension'}</span>644- </div>645- </div>646- );647- }648-649592 function renderSecurity() {650593 return (651594 <div class="peach-settings-body">@@ -827,7 +770,7 @@827770 // for a user who can't complete it.828771 if (!isPro() && !duressEnabled()) {829772 props.onProGate?.("duress_vault");830- setActiveSection("license");773+ setActiveCard("pro");831774 return;832775 }833776 setShowDuressSetup(true);@@ -1217,6 +1160,62 @@12171160 );12181161 }12191162 1163+ function renderSync() {1164+ return (1165+ <div class="peach-settings-body">1166+ <div class="peach-settings-group">1167+ <p class="peach-settings-sub-label">PAIRING</p>1168+ <div class="peach-settings-row peach-settings-row--top">1169+ <div class="peach-settings-row-main">1170+ <span>1171+ Peach Sync1172+ <Show when={!isPro()}>1173+ <span class="peach-pro-badge">Pro</span>1174+ </Show>1175+ </span>1176+ <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1177+ Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1178+ </span>1179+ </div>1180+ <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1181+ </div>1182+ <div class="peach-settings-row peach-settings-row--meta">1183+ <span>Status</span>1184+ <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1185+ </div>1186+ <div class="peach-settings-row peach-settings-row--meta">1187+ <span>Sync hub</span>1188+ <button1189+ class="peach-btn peach-btn--subtle peach-btn--sm"1190+ onClick={() => props.onOpenSyncPairing?.()}1191+ >1192+ open1193+ </button>1194+ </div>1195+1196+ <p class="peach-settings-sub-label">DEVICE FLOW</p>1197+ <div class="peach-settings-row peach-settings-row--top">1198+ <div class="peach-settings-row-main">1199+ <span>Pair new device</span>1200+ <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1201+ Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1202+ </span>1203+ </div>1204+ <button1205+ class="peach-btn peach-btn--subtle peach-btn--sm"1206+ onClick={() => props.onOpenSyncPairing?.()}1207+ >1208+ pair1209+ </button>1210+ </div>1211+ <div class="peach-info-notice" style="margin-top:8px">1212+ Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1213+ </div>1214+ </div>1215+ </div>1216+ );1217+ }1218+12201219 function renderAppearance() {12211220 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [12221221 { id: "small", label: "Small" },@@ -1246,7 +1245,7 @@12461245 <For each={ACCENT_COLORS}>12471246 {(color) => (12481247 <button1249- class="peach-accent-swatch"1248+ class="peach-accent-swatch peach-swatch"12501249 classList={{ "is-active": props.accentColor === color.name }}12511250 style={{ background: color.hex }}12521251 onClick={() => void props.onAccentChange(color.name)}@@ -1455,7 +1454,6 @@14551454 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14561455 const [showKey, setShowKey] = createSignal(false);14571456 1458- const [showKeyEntry, setShowKeyEntry] = createSignal(false);14591457 const [deactivating, setDeactivating] = createSignal(false);14601458 14611459 async function handleDeactivateLicense() {@@ -1715,33 +1713,28 @@17151713 );17161714 }17171715 1716+ const bodies: StackBodies = {1717+ protection: renderSecurity(),1718+ vault: renderVault(),1719+ sync: renderSync(),1720+ appearance: renderAppearance(),1721+ shortcuts: renderShortcuts(),1722+ pro: renderLicense(),1723+ feedback: renderFeedback(),1724+ };1725+17181726 return (17191727 <Show17201728 when={showNewRecoveryWords()}17211729 fallback={1722- <div class="peach-overlay-view ov-enter-right" style="display:flex">1723- <div class="peach-overlay-header">1724- <button1725- class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1726- onClick={handleHeaderBack}1727- aria-label="Back"1728- >1729- <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1730- <path d="M6 1L1 6L6 11" />1731- </svg>1732- </button>1733- <span class="peach-overlay-title">{currentTitle()}</span>1734- <div class="peach-overlay-spacer"></div>1735- </div>1736-1737- <Switch fallback={renderHome()}>1738- <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1739- <Match when={activeSection() === "vault"}>{renderVault()}</Match>1740- <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1741- <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1742- <Match when={activeSection() === "license"}>{renderLicense()}</Match>1743- <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1744- </Switch>1730+ <div class="peach-overlay-view ov-enter-right" style="display:flex;flex-direction:column">1731+ <StackDeck1732+ active={activeCard()}1733+ onActiveChange={setActiveCard}1734+ onBack={props.onBack}1735+ cards={cards()}1736+ bodies={bodies}1737+ />17451738 </div>17461739 }17471740 >src/popup/views/contracts.test.tsx+7 −28
@@ -288,11 +288,8 @@288288 );289289 await flushView();290290 291- const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(292- (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"293- ) as HTMLButtonElement;294- securityRow.click();295- await flushView();291+ expect(container.querySelectorAll(".stk-card").length).toBe(7);292+ expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Protection");296293 297294 const changeButton = Array.from(container.querySelectorAll("button")).find(298295 (candidate) => candidate.textContent?.trim() === "change"@@ -362,31 +359,15 @@362359 await flushView();363360 sendMessageMock.mockClear();364361 365- function headerBack() {366- return container.querySelector(".peach-overlay-header button") as HTMLButtonElement;367- }368-369362 // ── Auto-lock + theme changes (before password change) ─────────────370- const securityRow = () =>371- Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(372- (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"373- ) as HTMLButtonElement;374-375- securityRow().click();376- await flushView();377-378363 const selects = container.querySelectorAll("select");379364 changeSelect(selects[0] as HTMLSelectElement, "15");380365 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);381366 382- headerBack().click();367+ const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;368+ appearanceCard.click();383369 await flushView();384-385- const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(386- (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"387- ) as HTMLButtonElement;388- appearanceRow.click();389- await flushView();370+ expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");390371 const themeButton = Array.from(container.querySelectorAll("button")).find(391372 (candidate) => candidate.textContent?.trim() === "Switch to light"392373 ) as HTMLButtonElement;@@ -394,10 +375,8 @@394375 expect(onThemeChange).toHaveBeenCalledWith("light");395376 396377 // ── Password change → recovery words display ────────────────────────397- headerBack().click();398- await flushView();399-400- securityRow().click();378+ const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;379+ protectionCard.click();401380 await flushView();402381 403382 const changeButton = Array.from(container.querySelectorAll("button")).find(