peach_faithful_spec.md

35.4 KB · raw

rendered · source

Peach — Every Surface Faithful to the Demo

v1.4.1 target


Purpose

Every popup view has been read in full. This spec prescribes the exact changes needed to make every surface — including the newer ones (Passkeys, Health, Sync, Tags, Canary) that postdate the website demo — feel like they were designed together. The goal is visual confidence and coherence: one system, no escaped inline styles, no unstyled improvised surfaces.

The demo's design language is established and non-negotiable:


Files Modified

File Type
src/popup/styles/popup.css CSS additions + fixes
src/popup/views/Entry.tsx TOTP ring trigger; regen panel CSS classes
src/popup/views/PasskeyApproval.tsx Full inline-style → CSS class migration
src/popup/views/PasskeyEntry.tsx window.confirm() replacement; badge CSS
src/popup/views/Vault.tsx Passkey badge CSS class
src/popup/views/Settings.tsx .peach-info-notice, sync row CSS, color literal fix
src/popup/views/Export.tsx Input class fix
src/popup/views/CJKExport.tsx Inline style → CSS class migration
src/popup/views/CJKImport.tsx Inline style → CSS class migration
src/popup/views/Lock.tsx Restore link wrapper CSS class
src/popup/components/TOTP.tsx Class rename to peach- prefix

No changes to detect.ts, inject.ts, fill.ts, save.ts, background files, or the Rust sync host.


Issue 1 — TOTP font: missing font-style: italic

Root cause

popup.css declares:

.peach-totp-code {
  font-family: "Cormorant Garamond", "Times New Roman", serif;
  font-size: 38px; color: var(--peach-accent);
  line-height: 0.95; min-height: 1em;
}

No font-style is set, so the browser renders Cormorant Garamond Regular (upright). The demo showed the code in italic — the defining characteristic of Peach's display numerals. Everything else that uses Cormorant Garamond (.peach-entry-name, .peach-unlock-headline, .peach-wordmark, the entry-row name) explicitly declares font-style: italic. The TOTP code is the only exception, making it look like a mistake.

Fix — popup.css

/* BEFORE */
.peach-totp-code {
  margin: 2px 0 0;
  font-family: "Cormorant Garamond", "Times New Roman", serif;
  font-size: 38px; color: var(--peach-accent);
  line-height: 0.95; min-height: 1em;
}

/* AFTER */
.peach-totp-code {
  margin: 2px 0 0;
  font-family: "Cormorant Garamond", "Times New Roman", serif;
  font-style: italic;
  font-weight: 300;
  font-size: 38px; color: var(--peach-accent);
  line-height: 0.95; min-height: 1em;
}

font-weight: 300 (Light Italic) matches the CormorantGaramond-LightItalic.woff2 loaded in vars.css and gives the numerals the same airy quality as the large display heading.


Issue 2 — TOTP ring does not fire on code refresh

Root cause

Ring.tsx exposes trigger() via (window as any).__peachRingTrigger. triggerRing() in App.tsx calls this, and it fires correctly on copy. But Entry.tsx never calls it when the TOTP period resets — the ring only glows when you manually copy the code, not at the natural moment of anticipation (the new code appearing).

Fix — Entry.tsx

Add a createEffect that watches the TOTP code string itself. When the code changes after the initial render, trigger the ring. This fires once per 30-second period, at the exact instant the new code appears.

// After the existing totpState memo, add:

// Trigger the accent ring every time the TOTP code changes (new period)
let prevTotpCode: string | undefined;
createEffect(() => {
  const state = totpState();
  if (!state) { prevTotpCode = undefined; return; }
  if (prevTotpCode !== undefined && state.code !== prevTotpCode) {
    triggerRing();
  }
  prevTotpCode = state.code;
});

This is completely passive — it reads totpState() (already reactive) and calls triggerRing() when the code flips. No new intervals needed. The ring animation in Ring.tsx self-terminates with a 1200ms fade, so it won't persist.


Issue 3 — TOTP.tsx standalone component has unstyled class names

The file at src/popup/components/TOTP.tsx uses totp-display, totp-code, totp-countdown — class names without the peach- prefix. These are not defined in popup.css. If this component renders anywhere in the popup, those elements are completely unstyled.

Fix — TOTP.tsx

Rename classes to the established peach- convention:

export function TOTPDisplay(props: TOTPProps) {
  const [totp, setTotp] = createSignal(generateTOTP(props.secret));
  const interval = setInterval(() => { setTotp(generateTOTP(props.secret)); }, 1000);
  onCleanup(() => clearInterval(interval));

  return (
    <div class="peach-totp-band">
      <div>
        <span class="peach-label">2-factor</span>
        <p class="peach-totp-code">
          <span class="peach-totp-code-text">
            {totp().code.slice(0, 3)} {totp().code.slice(3)}
          </span>
        </p>
      </div>
      <div class="peach-totp-meta">
        <span class="peach-label">expires</span>
        <div class="peach-totp-countdown">{totp().remaining}s</div>
      </div>
    </div>
  );
}

This reuses the exact same structure and CSS classes as the full Entry.tsx TOTP band, ensuring visual consistency wherever this simpler component is used.


Issue 4 — PasskeyApproval.tsx: all layout via inline styles

Root cause

Every structural element in PasskeyApproval.tsx uses inline style attributes. The class peach-overlay-card is used but not defined in popup.css (Entry.tsx uses it in the QR modal, so it may be defined there — verify and add if missing). The icon wrapper, text hierarchy, and action area have no CSS classes.

New CSS classes — add to popup.css after the existing overlay section

/* ── Passkey approval overlay ─────────────────────────────── */
.peach-passkey-approval {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 28px 22px 22px;
  gap: 14px;
  text-align: center;
}

.peach-passkey-approval-icon {
  width: 46px;
  height: 46px;
  border-radius: 12px;
  background: var(--peach-s2);
  border: 1px solid var(--peach-t3);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.peach-passkey-approval-icon img,
.peach-passkey-approval-icon svg {
  width: 28px;
  height: 28px;
  border-radius: 6px;
}

.peach-passkey-approval-rp {
  font-family: var(--font-ui);
  font-size: 12px;
  color: var(--peach-t2);
  letter-spacing: 0.03em;
}

.peach-passkey-approval-action {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 300;
  font-size: 26px;
  color: var(--peach-t0);
  line-height: 1;
}

.peach-passkey-approval-user {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--peach-t1);
  background: var(--peach-s2);
  border: 1px solid var(--peach-t3);
  border-radius: 4px;
  padding: 3px 8px;
}

.peach-passkey-approval-notice {
  font-size: 10px;
  color: var(--peach-t2);
  line-height: 1.5;
  max-width: 220px;
}

.peach-passkey-approval-actions {
  display: flex;
  gap: 8px;
  width: 100%;
  margin-top: 6px;
}

.peach-passkey-approval-actions .peach-btn {
  flex: 1;
}

/* Ensure peach-overlay-card is defined (also used in Entry.tsx QR modal) */
.peach-overlay-card {
  background: var(--peach-s1);
  border: 1px solid var(--peach-t3);
  border-radius: 12px;
  overflow: hidden;
  width: 100%;
}

Fix — PasskeyApproval.tsx

Replace the inline-styled structure with the new classes:

<div class="peach-overlay-view" role="dialog" aria-modal="true">
  <div class="peach-overlay-card">
    <div class="peach-passkey-approval">

      <div class="peach-passkey-approval-icon">
        <Show when={props.request.iconUrl} fallback={<PasskeyGlyph />}>
          <img src={props.request.iconUrl} alt={props.request.rpId} />
        </Show>
      </div>

      <div class="peach-passkey-approval-rp">{props.request.rpId}</div>

      <div class="peach-passkey-approval-action">
        {props.request.type === "create" ? "create passkey" : "sign in"}
      </div>

      <Show when={props.request.userName}>
        <div class="peach-passkey-approval-user">{props.request.userName}</div>
      </Show>

      <div class="peach-passkey-approval-notice">
        {props.request.type === "create"
          ? "Peach will generate and store a passkey for this site."
          : "Peach will authenticate you using your stored passkey."}
      </div>

      <div class="peach-passkey-approval-actions">
        <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={props.onDeny}>
          Deny
        </button>
        <button class="peach-btn peach-btn--accent peach-btn--sm" onClick={props.onApprove}>
          {props.request.type === "create" ? "Create" : "Sign in"}
        </button>
      </div>

    </div>
  </div>
</div>

Issue 5 — PasskeyEntry.tsx: window.confirm() for delete

Root cause

window.confirm() opens a native OS dialog — completely outside the Peach visual system, jarring, and especially wrong in a popup context where the OS dialog usually appears behind the extension popup.

Fix — In-popup confirmation pattern

Add a deleteConfirmPending signal. When the user clicks "Remove passkey", show an inline confirmation row instead of calling window.confirm():

const [deleteConfirmPending, setDeleteConfirmPending] = createSignal(false);

// Replace the delete button with:
<Show
  when={deleteConfirmPending()}
  fallback={
    <button
      class="peach-btn peach-btn--ghost peach-btn--sm peach-passkey-delete-btn"
      onClick={() => setDeleteConfirmPending(true)}
    >
      Remove passkey
    </button>
  }
>
  <div class="peach-passkey-delete-confirm">
    <span class="peach-passkey-delete-confirm-copy">Remove this passkey?</span>
    <button
      class="peach-btn peach-btn--ghost peach-btn--sm"
      onClick={() => setDeleteConfirmPending(false)}
    >
      Cancel
    </button>
    <button
      class="peach-btn peach-btn--ghost peach-btn--sm peach-passkey-delete-btn--confirm"
      onClick={() => {
        setDeleteConfirmPending(false);
        props.onDelete();
      }}
    >
      Remove
    </button>
  </div>
</Show>

New CSS classes — add to popup.css

/* ── Passkey entry view ───────────────────────────────────── */
.peach-passkey-badge {
  margin-left: 8px;
  font-size: 10px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--peach-t2);
  font-family: var(--font-ui);
  vertical-align: middle;
}

.peach-passkey-delete-btn {
  color: var(--peach-breach);
  border-color: rgba(229, 85, 85, 0.25);
}

.peach-passkey-delete-confirm {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  background: rgba(229, 85, 85, 0.06);
  border: 1px solid rgba(229, 85, 85, 0.2);
  border-radius: 6px;
}

.peach-passkey-delete-confirm-copy {
  flex: 1;
  font-size: 11px;
  color: var(--peach-t1);
}

.peach-passkey-delete-btn--confirm {
  color: var(--peach-breach);
  border-color: rgba(229, 85, 85, 0.4);
}

Fix — PasskeyEntry.tsx: inline badge in domain row

Replace the inline badge style:

/* BEFORE */
<span style="margin-left:8px;font-size:10px;...">passkey</span>

/* AFTER */
<span class="peach-passkey-badge">passkey</span>

Replace the inline delete button style:

/* BEFORE */
<button style="color:#e55;border-color:rgba(229,85,85,0.25)">Remove passkey</button>

/* AFTER */
<button class="peach-btn peach-btn--ghost peach-btn--sm peach-passkey-delete-btn">Remove passkey</button>

The .peach-restore-warning used for the "private key stored encrypted" notice is semantically wrong — that class was designed for destructive action warnings. Replace it with the new .peach-info-notice (defined below in Issue 7).


Issue 6 — Vault.tsx: passkey badge inline styles

The passkey badge in the vault list uses a long inline style string. Replace with the same .peach-passkey-badge class defined above:

/* BEFORE */
<span style="margin-left:6px;font-size:10px;letter-spacing:0.06em;text-transform:uppercase;color:var(--t2)">passkey</span>

/* AFTER */
<span class="peach-passkey-badge">passkey</span>

Issue 7 — Settings.tsx: semantic misuse of .peach-restore-warning and inline styles

.peach-restore-warning misused for non-destructive notices

.peach-restore-warning has a red-tinted border and background — visually communicating danger. It is used in Settings for:

  1. "Only one passkey provider can be active at a time" — informational, not dangerous
  2. The passkey private key storage notice — informational

These should use a new neutral notice class.

New CSS class — add to popup.css

/* ── Info notice (neutral, non-destructive) ───────────────── */
.peach-info-notice {
  font-size: 11px;
  color: var(--peach-t2);
  line-height: 1.5;
  background: rgba(240, 232, 216, 0.04);
  border: 1px solid var(--peach-t3);
  border-radius: 6px;
  padding: 8px 10px;
}

Replace the passkey provider conflict notice and the private key storage notice with class="peach-info-notice" instead of class="peach-restore-warning".

Sync section: color literals and inline styles

The sync section in Settings.tsx uses:

Fix for getStatusColor() — replace literals with CSS vars defined in vars.css:

Add to vars.css:

--peach-sync-ok:   #5ebd5e;
--peach-sync-busy: #c8a840;
--peach-sync-off:  var(--peach-t2);
--peach-sync-err:  var(--peach-breach);

Update getStatusColor():

function getStatusColor(status: SyncStatus): string {
  switch (status) {
    case "synced":      return "var(--peach-sync-ok)";
    case "syncing":     return "var(--peach-sync-busy)";
    case "no_peers":    return "var(--peach-sync-off)";
    case "not_trusted": return "var(--peach-sync-err)";
    case "offline":     return "var(--peach-sync-off)";
    default:            return "var(--peach-sync-off)";
  }
}

New CSS classes for sync device/network rows — add to popup.css:

/* ── Settings sync rows ───────────────────────────────────── */
.peach-sync-section-label {
  font-size: 11px;
  color: var(--peach-t2);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.peach-sync-device-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 6px 0;
  border-bottom: 1px solid var(--peach-t3);
}

.peach-sync-device-info {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.peach-sync-device-name {
  font-size: 13px;
  color: var(--peach-t0);
}

.peach-sync-device-meta {
  font-size: 11px;
  color: var(--peach-t2);
}

.peach-sync-status-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  vertical-align: middle;
}

.peach-sync-remove-btn {
  color: var(--peach-breach);
}

Fix — Settings.tsx: Replace all inline-styled device/network row elements with these classes. The style={...} template literals on device rows become:

<div class="peach-sync-device-row">
  <div class="peach-sync-device-info">
    <span class="peach-sync-device-name">{device.displayName}</span>
    <span class="peach-sync-device-meta">
      Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}
    </span>
  </div>
  <button
    class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"
    onClick={() => void handleRemoveDevice(device.deviceId)}
  >
    Remove
  </button>
</div>

The status row inline dot becomes:

<span
  class="peach-sync-status-dot"
  style={{ background: getStatusColor(syncStatus()) }}
/>

(The single style={{ background }} is acceptable here since it's driven by a dynamic value. All structural layout moves to CSS.)


Issue 8 — Entry.tsx: regen panel inline styles

The regen panel, breach badge, canary badge, and canary suppress row all use inline styles. These should be CSS classes.

New CSS classes — add to popup.css

/* ── Breach badge ─────────────────────────────────────────── */
.peach-breach-badge {
  display: flex;
  position: relative;
  z-index: 1;
  align-items: center;
  gap: 8px;
  font-size: 11px;
  color: var(--peach-breach);
  padding: 6px 0;
  margin-bottom: 4px;
}

/* ── Canary badge ─────────────────────────────────────────── */
.peach-canary-badge {
  display: flex;
  position: relative;
  z-index: 1;
  align-items: center;
  gap: 5px;
  padding: 4px 10px;
  background: rgba(217, 160, 48, 0.12);
  border: 1px solid rgba(217, 160, 48, 0.35);
  border-radius: 6px;
  margin-bottom: 6px;
  width: fit-content;
}

.peach-canary-badge-label {
  font-size: 10px;
  letter-spacing: 0.06em;
  color: rgba(217, 160, 48, 0.9);
  font-family: var(--font-ui);
  text-transform: uppercase;
}

/* ── Canary suppress row ──────────────────────────────────── */
.peach-canary-suppress-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  background: rgba(217, 160, 48, 0.08);
  border-radius: 6px;
  margin-bottom: 8px;
}

.peach-canary-suppress-label {
  font-size: 11px;
  color: rgba(217, 160, 48, 0.85);
  cursor: pointer;
  font-family: var(--font-ui);
  letter-spacing: 0.04em;
}

/* ── Regen panel ──────────────────────────────────────────── */
.peach-regen-panel {
  position: relative;
  z-index: 1;
  background: rgba(217, 104, 48, 0.08);
  border: 1px solid rgba(217, 104, 48, 0.25);
  border-radius: 8px;
  padding: 12px;
  margin-bottom: 8px;
}

.peach-regen-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 10px;
}

.peach-regen-panel-title {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: rgba(217, 104, 48, 0.9);
}

.peach-regen-output {
  background: rgba(0, 0, 0, 0.2);
  border-radius: 6px;
  padding: 10px;
  margin-bottom: 10px;
  font-family: var(--font-mono);
  font-size: 13px;
  word-break: break-all;
  color: var(--peach-t1);
}

.peach-regen-actions {
  display: flex;
  gap: 6px;
}

.peach-regen-success {
  position: relative;
  z-index: 1;
  background: rgba(40, 180, 80, 0.1);
  border: 1px solid rgba(40, 180, 80, 0.3);
  border-radius: 8px;
  padding: 10px 12px;
  margin-bottom: 8px;
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 11px;
  color: rgba(40, 180, 80, 0.9);
}

Fix — Entry.tsx: replace inline styles with these classes. The most important one is peach-regen-output — it currently uses font-family:monospace (generic), which must become var(--font-mono) (IBM Plex Mono).


Issue 9 — Lock.tsx: restore link wrapper inline style

The restore link buttons under the onboarding create-vault panel are wrapped in a div with inline style:

<div style="display:flex;flex-direction:column;gap:2px;padding:0 2px;">

New CSS class — add to popup.css

.peach-onboarding-alt-actions {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 0 2px;
}

Fix — Lock.tsx

/* BEFORE */
<div style="display:flex;flex-direction:column;gap:2px;padding:0 2px;">
  ...
</div>

/* AFTER */
<div class="peach-onboarding-alt-actions">
  ...
</div>

Issue 10 — Export.tsx: password input class mismatch

Export.tsx uses peach-search-input for the export password field and CJK description text:

<input class="peach-search-input" style="width:100%;box-sizing:border-box;padding:8px 10px;..."

.peach-search-input is the vault search bar — it's styled as an italic Cormorant Garamond field with no border. Using it for a password input with additional inline overrides is wrong.

Fix — Export.tsx and CJKExport.tsx / CJKImport.tsx

Replace all instances of peach-search-input used for password/text inputs with a proper class:

New CSS class — add to popup.css

/* ── Settings-context text/password input ─────────────────── */
.peach-settings-input {
  width: 100%;
  box-sizing: border-box;
  padding: 8px 10px;
  background: var(--peach-s2);
  border: 1px solid var(--peach-t3);
  border-radius: 6px;
  color: var(--peach-t0);
  font-family: var(--font-ui);
  font-size: 12px;
  outline: none;
  transition: border-color 120ms ease;
}

.peach-settings-input:focus {
  border-color: var(--peach-accent);
}

.peach-settings-input[type="password"] {
  font-family: var(--font-mono);
  letter-spacing: 0.04em;
}

Replace all:

class="peach-search-input" style="width:100%;box-sizing:border-box;padding:8px 10px;background:var(--s2);border:1px solid var(--t3);border-radius:6px;color:var(--t0);font-size:12px"

with:

class="peach-settings-input"

This applies to Export.tsx (export password), CJKExport.tsx (passphrase + confirm), and CJKImport.tsx (passphrase + master password inputs).


Issue 11 — CJKExport.tsx and CJKImport.tsx: inline-styled shells

Strength meter

The strength meter bar in CJKExport.tsx is built with inline styles. Add CSS classes:

/* ── CJK export passphrase strength ──────────────────────── */
.peach-strength-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 10px;
}

.peach-strength-track {
  flex: 1;
  height: 4px;
  background: var(--peach-s2);
  border-radius: 2px;
  overflow: hidden;
}

.peach-strength-fill {
  height: 100%;
  border-radius: 2px;
  transition: width 120ms ease, background 120ms ease;
}

.peach-strength-label {
  font-size: 10px;
  font-weight: 500;
  min-width: 64px;
  text-align: right;
}

Replace the inline strength meter in CJKExport.tsx > PassphraseScreen:

<Show when={props.passphrase.length > 0}>
  <div class="peach-strength-row">
    <div class="peach-strength-track">
      <div
        class="peach-strength-fill"
        style={{
          width: `${barFillPercent()}%`,
          background: STRENGTH_COLOUR[props.strengthTier],
        }}
      />
    </div>
    <span
      class="peach-strength-label"
      style={{ color: STRENGTH_COLOUR[props.strengthTier] }}
    >
      {STRENGTH_LABEL[props.strengthTier]}
    </span>
  </div>
</Show>

Warning block

Both CJKExport.tsx and Export.tsx use an inline-styled red notice block. Add:

.peach-danger-notice {
  background: rgba(220, 80, 60, 0.08);
  border: 1px solid rgba(220, 80, 60, 0.2);
  border-radius: 6px;
  padding: 9px 11px;
  font-size: 11px;
  color: var(--peach-t1);
  line-height: 1.5;
}

Replace:

<div style="background:rgba(220,80,60,0.08);border:1px solid rgba(220,80,60,0.2);border-radius:6px;padding:9px 11px;font-size:11px;color:var(--t1);margin:12px 0">

with:

<div class="peach-danger-notice" style="margin:12px 0">

Spinner (generating/restoring screens)

Both CJK views declare @keyframes peach-spin inline inside a <style> tag inside the component. This duplicates the animation and pollutes the style sheet at runtime. Extract to popup.css:

/* ── Spinner (shared) ─────────────────────────────────────── */
@keyframes peach-spin {
  to { transform: rotate(360deg); }
}

.peach-spinner {
  width: 32px;
  height: 32px;
  border: 2px solid var(--peach-s2);
  border-top-color: var(--peach-accent);
  border-radius: 50%;
  margin: 0 auto 16px;
  animation: peach-spin 0.8s linear infinite;
}

.peach-progress-screen {
  text-align: center;
  padding: 32px 16px;
}

.peach-progress-screen-title {
  font-size: 13px;
  color: var(--peach-t0);
  margin-bottom: 16px;
}

.peach-progress-screen-stage {
  font-size: 11px;
  color: var(--peach-t2);
  min-height: 14px;
}

.peach-progress-screen-note {
  font-size: 10px;
  color: var(--peach-t2);
  margin-top: 8px;
}

Remove the inline <style> blocks from GeneratingScreen and RestoringScreen. Replace the spinner <div> and surrounding layout with:

function GeneratingScreen(props: GeneratingScreenProps) {
  return (
    <div class="peach-settings-group peach-progress-screen">
      <div class="peach-progress-screen-title">Creating your backup phrase…</div>
      <div class="peach-spinner" aria-label="Working" />
      <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>
      <Show when={props.highSecurity}>
        <div class="peach-progress-screen-note">Using High Security — this takes about 3 seconds.</div>
      </Show>
    </div>
  );
}

CJK QR overlay

The QR overlay in CJKExport.tsx is entirely inline-styled. Replace with the .peach-overlay-view + .peach-overlay-card pattern already used throughout the extension:

<Show when={props.showQrOverlay && props.qrDataUrl}>
  <div class="peach-overlay-view" role="dialog" aria-modal="true" onClick={props.onCloseQr}>
    <div class="peach-overlay-card peach-cjk-qr-card" onClick={(e) => e.stopPropagation()}>
      <div class="peach-overlay-header">
        <span class="peach-overlay-title">scan with Peach</span>
        <div class="peach-overlay-spacer" />
        <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" onClick={props.onCloseQr} aria-label="Close">
          <CloseGlyph />
        </button>
      </div>
      <div class="peach-cjk-qr-body">
        <p class="peach-cjk-qr-hint">Open Peach → Import → Restore from CJK phrase</p>
        <img src={props.qrDataUrl!} alt="Vault QR code" class="peach-cjk-qr-image" width="320" height="320" />
      </div>
    </div>
  </div>
</Show>

New CSS:

.peach-cjk-qr-card {
  max-width: 400px;
  margin: auto;
}

.peach-cjk-qr-body {
  padding: 0 20px 20px;
  text-align: center;
}

.peach-cjk-qr-hint {
  font-size: 11px;
  color: var(--peach-t2);
  margin: 0 0 14px;
}

.peach-cjk-qr-image {
  border-radius: 4px;
  display: block;
  margin: 0 auto;
  width: 100%;
  max-width: 320px;
  height: auto;
}

Issue 12 — SyncPairing.tsx: verify peach-pairing-* CSS coverage

SyncPairing.tsx is well-structured — it uses peach-pairing-* class names throughout with no inline styles. Verify that all referenced classes exist in popup.css:

Class Expected
.peach-pairing-code-display Container for the 6-digit pairing code
.peach-pairing-code-digit Individual digit cell — mono, large
.peach-pairing-countdown-ring SVG ring wrapper
.peach-pairing-ring-track SVG circle background
.peach-pairing-ring-progress SVG circle fill
.peach-pairing-countdown-num Countdown number overlay
.peach-pairing-content Centered flex column
.peach-pairing-body Scrollable body
.peach-pairing-headline Large display text
.peach-pairing-subline Secondary copy
.peach-pairing-status Spinner + status text row
.peach-pairing-error Error text
.peach-pairing-error-msg Error message paragraph
.peach-pairing-code-inputs 6-box code input grid
.peach-pairing-code-box Individual code input
.peach-pairing-spinner CSS spinner
.peach-pairing-success-icon Success checkmark wrapper
.peach-pairing-error-icon Error X wrapper

If any of these are missing from popup.css, add them. The critical ones for visual coherence:

/* ── Sync pairing ─────────────────────────────────────────── */
.peach-pairing-body {
  flex: 1;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
}

.peach-pairing-content {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 28px 20px;
  text-align: center;
}

.peach-pairing-headline {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 300;
  font-size: 28px;
  color: var(--peach-t0);
  margin: 0;
  line-height: 1;
}

.peach-pairing-subline {
  font-size: 12px;
  color: var(--peach-t2);
  max-width: 220px;
  line-height: 1.5;
}

.peach-pairing-subline--center {
  text-align: center;
}

.peach-pairing-code-display {
  display: flex;
  gap: 6px;
}

.peach-pairing-code-digit {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 48px;
  background: var(--peach-s2);
  border: 1px solid var(--peach-t3);
  border-radius: 6px;
  font-family: var(--font-mono);
  font-size: 22px;
  color: var(--peach-t0);
  letter-spacing: 0;
}

.peach-pairing-countdown-ring {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.peach-pairing-countdown-num {
  position: absolute;
  font-family: var(--font-mono);
  font-size: 14px;
  color: var(--peach-t1);
}

.peach-pairing-ring-track {
  fill: none;
  stroke: var(--peach-t3);
  stroke-width: 2;
}

.peach-pairing-ring-progress {
  fill: none;
  stroke: var(--peach-accent);
  stroke-width: 2;
  stroke-linecap: round;
  transform: rotate(-90deg);
  transform-origin: 50% 50%;
  transition: stroke-dashoffset 0.5s linear;
}

.peach-pairing-code-inputs {
  display: flex;
  gap: 8px;
}

.peach-pairing-code-box {
  width: 36px;
  height: 48px;
  text-align: center;
  background: var(--peach-s2);
  border: 1px solid var(--peach-t3);
  border-radius: 6px;
  font-family: var(--font-mono);
  font-size: 20px;
  color: var(--peach-t0);
  outline: none;
  transition: border-color 120ms ease;
}

.peach-pairing-code-box:focus {
  border-color: var(--peach-accent);
}

.peach-pairing-code-box.has-error {
  border-color: var(--peach-breach);
}

.peach-pairing-status {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--peach-t1);
}

.peach-pairing-error {
  font-size: 11px;
  color: var(--peach-breach);
}

.peach-pairing-error-msg {
  color: var(--peach-breach);
}

.peach-pairing-success-icon,
.peach-pairing-error-icon {
  display: flex;
  align-items: center;
  justify-content: center;
}

.peach-pairing-spinner {
  width: 20px;
  height: 20px;
  border: 1.5px solid var(--peach-t3);
  border-top-color: var(--peach-accent);
  border-radius: 50%;
  animation: peach-spin 0.8s linear infinite;
  flex-shrink: 0;
}

Issue 13 — GeneratorDrawer.tsx: verify CSS coverage

GeneratorDrawer.tsx uses peach-generator-drawer, peach-generator-drawer-inner, peach-generator-drawer-content, peach-pwgen-display-row, peach-pwgen-output, peach-pwgen-length-row, peach-pwgen-slider, peach-pwgen-len-num, peach-pwgen-toggles, peach-pwgen-toggle-row, peach-pwgen-toggle-label. Verify all are in popup.css. The drawer animation uses grid-template-rows: 0fr → 1fr for the smooth open. Confirm:

.peach-generator-drawer {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 240ms var(--ease-out);
}

.peach-generator-drawer.is-open {
  grid-template-rows: 1fr;
}

.peach-generator-drawer-inner {
  overflow: hidden;
}

The password output (peach-pwgen-output) must use var(--font-mono):

.peach-pwgen-output {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--peach-t0);
  word-break: break-all;
  flex: 1;
}

Summary of CSS additions to popup.css

New class Purpose
.peach-passkey-approval Passkey approval overlay layout
.peach-passkey-approval-icon Icon wrapper with border
.peach-passkey-approval-rp RP domain text
.peach-passkey-approval-action Large italic action verb
.peach-passkey-approval-user Mono username chip
.peach-passkey-approval-notice Informational fine print
.peach-passkey-approval-actions Button row
.peach-overlay-card Shared overlay card shell (if missing)
.peach-passkey-badge "passkey" chip in Vault + PasskeyEntry
.peach-passkey-delete-btn Red-tinted delete trigger
.peach-passkey-delete-confirm Inline confirm row
.peach-passkey-delete-confirm-copy Confirm text
.peach-passkey-delete-btn--confirm Confirm action button
.peach-info-notice Neutral informational notice
.peach-sync-section-label Devices/networks section label
.peach-sync-device-row Device/network row
.peach-sync-device-info Device name + meta column
.peach-sync-device-name Device name text
.peach-sync-device-meta Last seen / last synced text
.peach-sync-status-dot Status indicator dot
.peach-sync-remove-btn Red remove button
.peach-breach-badge Breach warning row in Entry
.peach-canary-badge Canary indicator
.peach-canary-badge-label Canary label text
.peach-canary-suppress-row Session suppress checkbox row
.peach-canary-suppress-label Suppress label text
.peach-regen-panel Password regen overlay
.peach-regen-panel-header Regen header row
.peach-regen-panel-title Regen title text
.peach-regen-output Password preview (IBM Plex Mono)
.peach-regen-actions Regen button row
.peach-regen-success Success confirmation row
.peach-onboarding-alt-actions Restore link column on onboarding
.peach-settings-input Settings-context text/password input
.peach-strength-row Passphrase strength meter row
.peach-strength-track Strength bar background
.peach-strength-fill Strength bar fill
.peach-strength-label Strength tier label
.peach-danger-notice Red-tinted destructive warning block
.peach-spinner Loading spinner
.peach-progress-screen Centered progress screen wrapper
.peach-progress-screen-title Progress title
.peach-progress-screen-stage Stage label
.peach-progress-screen-note Supplemental note
.peach-cjk-qr-card CJK QR overlay card width
.peach-cjk-qr-body QR body padding
.peach-cjk-qr-hint QR hint text
.peach-cjk-qr-image QR image
@keyframes peach-spin Shared spinner keyframe (extracted from JSX)
.peach-pairing-* (11 classes) Full SyncPairing visual coverage

Summary of vars.css additions

--peach-sync-ok:   #5ebd5e;
--peach-sync-busy: #c8a840;
--peach-sync-off:  var(--peach-t2);
--peach-sync-err:  var(--peach-breach);

What this does NOT change