a80d09ce056d

feat: Reimagine Settings as Stack tray deck (v3.6.21)

- Replace Settings hub/subpages with full-window Stack deck cards - Collapse fan into focused card view with bottom sections handle - Preserve all existing settings bodies and behavior in the new card surface - Add Stack styles/components and update settings contract coverage - Fix audioCapture permission typing surfaced by typecheck

time
2026-05-19 01:54 UTC · 5 months ago
machine
koh2
parent
fef762d2460d
16 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 −28
manifest.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": {

view file

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",

view file

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",

view file

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": {

view file

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+}

view file

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+}

view file

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+}

view file

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+}

view file

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+}

view file

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>;

view file

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>

view file

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+}

view file

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. */

view file

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-

view file

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     >

view file

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(

view file