SETTINGS_REDESIGN_SPEC.md

23.0 KB · raw

rendered · source

Settings Redesign + Feedback System — Implementation Spec

Read AGENTS.md, .agent/Projects.md, and .agent/CONTINUITY.md before touching any file. Follow every invariant in Projects.md without exception. All Koh rules in AGENTS.md apply. Use the dev lane. Start with a checkpoint.


0. Pre-flight

koh status --json         # confirm dev lane, clean working tree
koh checkpoint "before settings redesign and feedback feature"

Note: Projects.md §8 says "no new features until P0/P1 findings are closed." This task has explicit user authorisation to proceed despite that rule. Record the override in .agent/CONTINUITY.md before starting.


1. Version bump

This is a new feature (MINOR). Bump all three files from 2.0.3 → 2.1.0. All three must agree — this is a hard invariant (Projects.md §2.1 / §4.8).


2. src/shared/types.ts — add SUBMIT_FEEDBACK to the MessageType union

Add "SUBMIT_FEEDBACK" to the background-bound section of MessageType (before the content-script-bound comment block, after "IS_CARD_DECLINED"):

| "IS_CARD_DECLINED"
| "SUBMIT_FEEDBACK"
// Content-script-bound message types ...

No other changes to this file.


3. src/background/messages.ts — SUBMIT_FEEDBACK handler

3a. Add the feedback endpoint constant

Directly below the existing PRIVATE_IPV4_RANGES constant at the top of the file, add:

// Feedback endpoint — replace with your ingestion URL before shipping.
// Leaving this empty disables network submission while keeping the UI functional.
// When set, this URL is validated by isAllowedCanaryWebhookUrl before use.
const FEEDBACK_ENDPOINT = "";

3b. Add the case to the switch

Place this case immediately before the default: case at the bottom of the switch (msg.type) block:

case "SUBMIT_FEEDBACK": {
  const { category, message, version, browser } = msg.payload as {
    category: string;
    message: string;
    version: string;
    browser: string;
  };
  if (!message.trim()) return { ok: false, error: "message is empty" };
  if (!FEEDBACK_ENDPOINT) return { ok: true };
  if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {
    return { ok: false, error: "invalid feedback endpoint" };
  }
  try {
    const res = await fetch(FEEDBACK_ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),
    });
    return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : "network error" };
  }
}

3c. Update shouldRefreshAutoLock

SUBMIT_FEEDBACK must not refresh the auto-lock timer. Add it to the exclusion list in shouldRefreshAutoLock:

function shouldRefreshAutoLock(type: MessageType): boolean {
  return (
    type !== "HAS_VAULT" &&
    type !== "IS_UNLOCKED" &&
    type !== "GET_VAULT_STATUS" &&
    type !== "LOCK" &&
    type !== "UPDATE_SETTINGS" &&
    type !== "GET_PENDING_PASSKEY" &&
    type !== "GET_PASSKEY_PROVIDER_STATE" &&
    type !== "DECLINE_CARD_SAVE" &&
    type !== "IS_CARD_DECLINED" &&
    type !== "SUBMIT_FEEDBACK"    // ← add this line
  );
}

3d. Network surface — update Projects.md §3.7

Add a third permitted fetch entry to §3.7 Network surface in .agent/Projects.md:

3. `FEEDBACK_ENDPOINT` constant in `src/background/messages.ts` — only when
   non-empty, only on `SUBMIT_FEEDBACK` messages initiated by the user. URL
   validated by `isAllowedCanaryWebhookUrl`. Defaults to empty string
   (disabled). Set by the developer before shipping.

Also verify the network grep still passes:

grep -r "fetch(" src/ | grep -v "pwnedpasswords" | grep -v "webhookUrl" | grep -v "FEEDBACK_ENDPOINT" | grep -v "\.test\."

Must return zero results.

3e. Tests — src/background/messages.test.ts (or equivalent test file)

Per Projects.md §2.2, every new messages.ts handler requires tests. Add these three cases to the existing test file:

describe("SUBMIT_FEEDBACK", () => {
  it("returns ok:true when FEEDBACK_ENDPOINT is empty (no-op path)", async () => {
    // FEEDBACK_ENDPOINT defaults to "" in test environment
    const res = await handleMessageDirect({
      type: "SUBMIT_FEEDBACK",
      payload: { category: "Bug report", message: "test", version: "2.1.0", browser: "Chrome" },
    });
    expect(res.ok).toBe(true);
  });

  it("returns ok:false when message is empty", async () => {
    const res = await handleMessageDirect({
      type: "SUBMIT_FEEDBACK",
      payload: { category: "Bug report", message: "  ", version: "2.1.0", browser: "Chrome" },
    });
    expect(res.ok).toBe(false);
    expect(res.error).toBe("message is empty");
  });

  it("does not refresh auto-lock timer", () => {
    expect(shouldRefreshAutoLockPublic("SUBMIT_FEEDBACK")).toBe(false);
  });
});

Adapt to the existing test harness conventions in the project. If handleMessageDirect or shouldRefreshAutoLockPublic are not already exported for testing, export them or use whatever test helper pattern exists.


4. src/popup/views/Settings.tsx — full rewrite

This is the largest change. The file goes from a single 987-line flat scroll to a two-level navigation system: a home screen of category rows, each navigating to a focused sub-view.

4a. Overall structure

The outer component keeps the same SettingsProps interface (no changes to App.tsx). It gains a new activeSection signal that drives navigation.

type SectionName =
  | "security"
  | "vault"
  | "sync"
  | "appearance"
  | "shortcuts"
  | "license"
  | "feedback";

The component returns a single peach-overlay-view div. The back button navigates: sub-view → home → props.onBack(). The title reflects the current section or reads "settings" on home.

Use SolidJS <Switch>/<Match> (not early returns — see Projects.md §4.1):

<Switch fallback={renderHome()}>
  <Match when={activeSection() === "security"}>{renderSecurity()}</Match>
  <Match when={activeSection() === "vault"}>{renderVault()}</Match>
  <Match when={activeSection() === "sync"}>{renderSync()}</Match>
  <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>
  <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>
  <Match when={activeSection() === "license"}>{renderLicense()}</Match>
  <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>
</Switch>

All state signals from the current implementation are preserved at the top of SettingsView exactly as they are today. Nothing is split into child components — this is one file, render-function style, closing over shared signals. Follow "co-locate related logic" from AGENTS.md.

Add the feedback signals alongside the existing ones:

const [fbCategory, setFbCategory] = createSignal("Bug report");
const [fbMessage, setFbMessage] = createSignal("");
const [fbSubmitting, setFbSubmitting] = createSignal(false);
const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);

Add the feedback submit handler:

async function handleSubmitFeedback() {
  if (!fbMessage().trim() || fbSubmitting()) return;
  setFbSubmitting(true);
  setFbResult(null);
  const res = await sendMessage({
    type: "SUBMIT_FEEDBACK",
    payload: {
      category: fbCategory(),
      message: fbMessage().trim(),
      version,
      browser: navigator.userAgent,
    },
  }) as { ok: boolean; error?: string };
  setFbSubmitting(false);
  if (res.ok) {
    setFbResult("success");
    setFbMessage("");
    setTimeout(() => setFbResult(null), 4000);
  } else {
    setFbResult("error");
  }
}

4b. renderHome() — the category list

Returns a <div class="peach-settings-body"> containing two section groups and a footer. Each row uses the .peach-settings-cat-row class (new, defined in §5).

Group 1 label: "PROTECTION"
  Row: Security        "Locking, breach monitoring, passkeys"   → "security"
  Row: Account         "Plan & license key"                     → "license"

Group 2 label: "VAULT"
  Row: Vault           "Entries, health, import & export"       → "vault"
  Row: Sync    [Pro]   "Paired devices & trusted networks"      → "sync"

Group 3 label: "PREFERENCES"
  Row: Appearance      "Theme & accent color"                   → "appearance"
  Row: Shortcuts       "Keyboard reference"                     → "shortcuts"

Group 4 label: "SUPPORT"
  Row: Feedback        "Report issues, suggest features"        → "feedback"

Footer: "Peach {version}  ·  Browser extension"  (same as current)

Each row renders as:

<button
  class="peach-settings-cat-row"
  onClick={() => setActiveSection("security")}
>
  <div class="peach-settings-cat-icon peach-settings-cat-icon--security">
    {/* inline SVG shield icon, 14×14, stroke="var(--peach-accent)" */}
  </div>
  <div class="peach-settings-cat-content">
    <span class="peach-settings-cat-name">Security</span>
    <span class="peach-settings-cat-desc">Locking, breach monitoring, passkeys</span>
  </div>
  <svg class="peach-settings-cat-chevron" ...>›</svg>
</button>

The Sync row also renders a Pro badge when !isPro():

<Show when={!isPro()}>
  <span class="peach-sync-pro-badge">Pro</span>
</Show>

The peach-sync-pro-badge class already exists in popup.css.

Icons — all inline SVG, width="14" height="14", fill="none", stroke="currentColor" (inherits from icon container's color), stroke-width="1.2", stroke-linecap="round", stroke-linejoin="round":

Section Icon path description
Security Shield: <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>
Account Key: <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>
Vault Lock body+shackle: <rect x="3" y="7" width="10" height="7" rx="1.5"/><path d="M6 7V5a3 3 0 016 0v2"/>
Sync Two arrows in circle: <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/><path d="M14 6l-2 2-2-2"/><path d="M2 10l2-2 2 2"/>
Appearance Sun: <circle cx="8" cy="8" r="3"/><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"/>
Shortcuts Two keys: <rect x="2" y="5" width="5" height="4" rx="1"/><rect x="9" y="7" width="5" height="4" rx="1"/><path d="M7 7h2m-2 2h2"/>
Feedback Comment bubble: <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>

Section labels use a new class .peach-settings-nav-label (defined in §5).

4c. renderSecurity() — Security sub-view

Content is exactly the existing "security" + "duress password" + "passkeys" groups from the current Settings.tsx, reorganised into three labelled sub-sections within a single peach-settings-body. No logic changes — copy the JSX and signals verbatim, just remove the outer peach-settings-group wrapper divs and replace with peach-settings-sub-label divs (new class, defined in §5).

Sub-section labels:

The peach-info-notice div for the passkey conflict note is kept verbatim.

4d. renderVault() — Vault sub-view

Takes the existing "vault" group and splits it into labelled sub-sections:

No logic changes.

4e. renderSync() — Sync sub-view

This is the existing renderSyncSection() body verbatim. Remove the outer peach-settings-group wrapper; the body is now the sub-view itself. The Pro gate (blurred rows + overlay) works exactly as before.

4f. renderAppearance() — Appearance sub-view

Contains the two rows currently buried in the "vault" group:

Add a sub-section label "THEME" above the theme row and "ACCENT" above the accent picker.

4g. renderShortcuts() — Shortcuts sub-view

Merges the two existing keyboard shortcut groups (currently duplicated):

No duplication. The customize link opens chrome://extensions/shortcuts exactly as before.

4h. renderLicense() — License/Account sub-view

The existing "license" group body verbatim. Label at top: "PLAN".

4i. renderFeedback() — Feedback sub-view

function renderFeedback() {
  const categories = ["Bug report", "Feature request", "Question", "Other"];
  return (
    <div class="peach-settings-body">
      <div class="peach-settings-group">
        <p class="peach-feedback-intro">
          Help us improve Peach. Every report goes directly to our inbox.
        </p>

        <p class="peach-settings-sub-label">TYPE</p>
        <div class="peach-feedback-chips">
          <For each={categories}>
            {(cat) => (
              <button
                class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}
                onClick={() => setFbCategory(cat)}
              >
                {cat}
              </button>
            )}
          </For>
        </div>

        <p class="peach-settings-sub-label">MESSAGE</p>
        <textarea
          class="peach-feedback-textarea"
          placeholder="Describe the issue or idea..."
          value={fbMessage()}
          onInput={(e) => setFbMessage(e.currentTarget.value)}
          rows={5}
        />

        <p class="peach-feedback-meta">
          Version {version} · will be included automatically
        </p>

        <Show when={fbResult() === "success"}>
          <p class="peach-feedback-result peach-feedback-result--ok">
            Sent — thank you
          </p>
        </Show>
        <Show when={fbResult() === "error"}>
          <p class="peach-feedback-result peach-feedback-result--err">
            Failed to send — try again
          </p>
        </Show>

        <button
          class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"
          onClick={() => void handleSubmitFeedback()}
          disabled={fbSubmitting() || !fbMessage().trim()}
        >
          {fbSubmitting() ? "Sending..." : "Send feedback"}
        </button>

        <p class="peach-feedback-anon">
          Sent anonymously · no account required
        </p>
      </div>
    </div>
  );
}

4j. Imports

Add Switch, Match, and For to the SolidJS import at the top:

import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";

5. src/popup/styles/popup.css — new CSS rules

Append the following block after the existing /* ── Settings view ── */ section (after line ~1714, before the .peach-passkey-badge rule). Do not modify any existing rules.

/* ── Settings home navigation ──────────────────────────────────────────── */
.peach-settings-nav-label {
  margin: 0 0 2px;
  padding: 12px 16px 4px;
  font-family: "IBM Plex Mono", monospace;
  font-size: 8px;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  color: var(--peach-t2);
}

.peach-settings-cat-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 9px 16px;
  background: none;
  border: none;
  border-bottom: 1px solid var(--peach-t3);
  cursor: pointer;
  text-align: left;
  color: inherit;
  transition: background var(--peach-dur-80) ease;
}
.peach-settings-cat-row:hover {
  background: var(--peach-t3);
}
.peach-settings-cat-row:last-child {
  border-bottom: none;
}

.peach-settings-cat-icon {
  width: 28px;
  height: 28px;
  border-radius: 7px;
  background: color-mix(in srgb, var(--peach-accent) 10%, transparent);
  color: var(--peach-accent);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.peach-settings-cat-content {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.peach-settings-cat-name {
  font-size: 13px;
  font-weight: 450;
  color: var(--peach-t0);
  line-height: 1.2;
}

.peach-settings-cat-desc {
  font-size: 10px;
  color: var(--peach-t2);
  line-height: 1.3;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.peach-settings-cat-chevron {
  color: var(--peach-t2);
  flex-shrink: 0;
  opacity: 0.5;
}

/* ── Settings sub-view section labels ──────────────────────────────────── */
.peach-settings-sub-label {
  margin: 12px 0 4px;
  font-family: "IBM Plex Mono", monospace;
  font-size: 8px;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  color: var(--peach-t2);
}
.peach-settings-sub-label:first-child {
  margin-top: 4px;
}

/* ── Feedback form ──────────────────────────────────────────────────────── */
.peach-feedback-intro {
  margin: 4px 0 14px;
  font-size: 12px;
  line-height: 1.55;
  color: var(--peach-t1);
}

.peach-feedback-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 14px;
}

.peach-feedback-chip {
  padding: 4px 10px;
  border-radius: 999px;
  border: 1px solid var(--peach-t3);
  background: none;
  color: var(--peach-t2);
  font-size: 11px;
  font-family: var(--font-ui);
  cursor: pointer;
  transition: border-color var(--peach-dur-80) ease, color var(--peach-dur-80) ease,
    background var(--peach-dur-80) ease;
}
.peach-feedback-chip:hover {
  border-color: var(--peach-t2);
  color: var(--peach-t1);
}
.peach-feedback-chip.is-active {
  border-color: color-mix(in srgb, var(--peach-accent) 50%, transparent);
  background: color-mix(in srgb, var(--peach-accent) 8%, transparent);
  color: var(--peach-accent);
}

.peach-feedback-textarea {
  width: 100%;
  box-sizing: border-box;
  background: var(--peach-t3);
  border: 1px solid var(--peach-t3);
  border-radius: 6px;
  color: var(--peach-t0);
  font-size: 12px;
  font-family: var(--font-ui);
  line-height: 1.5;
  padding: 8px 10px;
  resize: none;
  outline: none;
  transition: border-color var(--peach-dur-150) ease;
}
.peach-feedback-textarea:focus {
  border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);
}
.peach-feedback-textarea::placeholder {
  color: var(--peach-t2);
}

.peach-feedback-meta {
  margin: 6px 0 0;
  font-size: 10px;
  color: var(--peach-t2);
  font-family: "IBM Plex Mono", monospace;
}

.peach-feedback-result {
  margin: 8px 0 0;
  font-size: 11px;
  font-family: "IBM Plex Mono", monospace;
}
.peach-feedback-result--ok { color: var(--peach-sync-ok); }
.peach-feedback-result--err { color: var(--peach-breach); }

.peach-feedback-submit {
  width: 100%;
  margin-top: 12px;
  justify-content: center;
}

.peach-feedback-anon {
  margin: 8px 0 0;
  font-size: 10px;
  color: var(--peach-t2);
  text-align: center;
}

6. App.tsx — no changes required

The SettingsProps interface is unchanged. The <SettingsView> call site in App.tsx does not need modification.


7. .agent/CONTINUITY.md — create/update

Create .agent/CONTINUITY.md if it does not exist. Add an entry:

## [PLANS]
- 2026-04-26T00:00Z [USER] Settings screen redesign: two-level navigation
  (home → category sub-views) + in-settings feedback form with configurable
  backend endpoint. Explicit user authorisation to proceed despite Projects.md
  §8 "no new features" freeze.

## [DECISIONS]
- 2026-04-26T00:00Z [CODE] Kept all state signals in top-level SettingsView
  rather than splitting into child components — avoids prop drilling, keeps
  co-location, and the file was already a single component. Render functions
  close over shared signals.
- 2026-04-26T00:00Z [CODE] FEEDBACK_ENDPOINT defaults to empty string so
  SUBMIT_FEEDBACK is a no-op until the developer sets the URL. Reuses the
  existing isAllowedCanaryWebhookUrl validator — no new validation surface.
- 2026-04-26T00:00Z [ASSUMPTION] No changes to App.tsx required; SettingsProps
  interface is unchanged.

## [PROGRESS]
  (fill in as work proceeds)

## [DISCOVERIES]
- Keyboard shortcuts section was duplicated in the old Settings.tsx (lines 608
  and 965). Fixed by merging into a single Shortcuts sub-view.
- The "vault" group was a catch-all for 9 unrelated concerns. Split into:
  Vault (data ops), Appearance (theme/accent), Sync (devices/networks).

8. Verification gate

Run the full sequence from Projects.md §2 before the final save:

# Network surface check — must return zero results
grep -r "fetch(" src/ | grep -v "pwnedpasswords" | grep -v "webhookUrl" | grep -v "FEEDBACK_ENDPOINT" | grep -v "\.test\."

# TypeScript — zero errors
npx tsc --noEmit

# Tests — zero failures
npm test

# Build — no new errors
npm run build

# Version consistency
grep '"version"' package.json manifest.json manifest.firefox.json
# All three must show 2.1.0

9. Koh save sequence

Use session name "settings redesign and feedback" on every save.

# Pass 1 — scaffolding
koh save --agent "<model>" --session "settings redesign and feedback" \
  "add SUBMIT_FEEDBACK message type, handler, feedback endpoint constant, version bump to 2.1.0"

# Pass 2 — Settings.tsx
koh save --agent "<model>" --session "settings redesign and feedback" \
  "rewrite Settings.tsx: two-level navigation, seven sub-views, feedback form"

# Pass 3 — CSS
koh save --agent "<model>" --session "settings redesign and feedback" \
  "add category nav and feedback form CSS rules to popup.css"

# Pass 4 — tests + CONTINUITY
koh save --agent "<model>" --session "settings redesign and feedback" \
  "add SUBMIT_FEEDBACK handler tests, update CONTINUITY.md and Projects.md §3.7"

10. What is intentionally out of scope