Files
relicario/docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md
adlee-was-taken 3caa7af194 docs(plan): v0.5.0 plans A/B and doc audit
Plan A (Rust + docs): S1 pre-receive hook fix, S2 tar
path-traversal hardening, S3 RELICARIO_* env-var audit, C1
stale branch cleanup. ~9 tasks, ~50 steps.

Plan B (extension UX): P4 error-copy centralization (subsumes
B2), B1 strength-meter regenerate fix, P1 password coloring
(inlined), P3 form-layout envelope, P2 setup → fullscreen tab.
~15 tasks, ~85 steps.

Doc audit: 14 findings, 6 fixed inline (README, ARCHITECTURE,
overview), 8 proposed for v0.5.0 release prep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:03:53 -04:00

58 KiB
Raw Permalink Blame History

v0.5.0 Plan B — Extension UX Polish + Bug Fixes

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Land the extension-UX slice of v0.5.0: centralize error-message copy (P4, subsumes B2), fix the strength-meter desync after regenerate (B1), color revealed passwords by character class (P1), repair the form-layout transition where lower sections break visual rhythm (P3), and route setup-wizard completion to the fullscreen vault tab (P2).

Architecture: Five independent slices on the existing extension shell. P4 introduces a single ERROR_COPY map keyed by snake_case error code, returning friendly title/body/CTA — the popup's one-off humanizeError regex chain and the fullscreen tab's raw state.error rendering both fold into it. B1 dispatches a synthetic input event after the regenerate handler programmatically sets the field's value, so the existing strength-meter listener at password-tools.ts:65 re-rates the new password. P1 inlines the password-coloring spec/plan as a single colorizePassword() utility plus a small chrome.storage.sync-backed scheme (defaults: digits blue, symbols red). P3 constrains the lower sections (notes, custom-fields disclosure, attachments disclosure) to the same max-width: 960px; margin: 0 auto envelope as .form-grid. P2 swaps the setup-wizard's terminal-step idle render for chrome.tabs.create({ url: chrome.runtime.getURL('vault.html') }) + window.close().

Tech Stack: TypeScript, vanilla DOM, vitest + JSDOM/happy-dom, plain CSS. No new dependencies.

Spec: docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md (sections P1, P2, P3, P4, B1, B2). P1 source spec/plan: docs/superpowers/specs/2026-05-01-password-coloring-design.md, docs/superpowers/plans/2026-05-01-password-coloring.md (inlined below).


File Structure

File Change Purpose
extension/src/shared/error-copy.ts Create Central ERROR_COPY map + lookupErrorCopy() (P4)
extension/src/shared/__tests__/error-copy.test.ts Create Generated test enumerating every grep'd error code (P4)
extension/src/popup/popup.ts Modify Replace humanizeError regex chain with lookupErrorCopy (P4)
extension/src/vault/vault.ts Modify Replace raw state.error render with friendly copy + Unlock CTA (P4)
extension/src/popup/components/types/login.ts Modify Dispatch input event after regenerate sets password value (B1); colorize revealed passwords (P1)
extension/src/popup/components/types/__tests__/login.test.ts Modify Vitest: regenerate dispatches input event (B1)
extension/src/shared/password-coloring.ts Create Pure colorizePassword() utility (P1)
extension/src/shared/__tests__/password-coloring.test.ts Create Vitest unit tests (P1)
extension/src/shared/color-scheme.ts Create Storage round-trip + applyColorScheme() (P1)
extension/src/shared/__tests__/color-scheme.test.ts Create Vitest unit tests (P1)
extension/src/popup/styles.css Modify .pwd-digit/.pwd-symbol/.pwd-letter rules + custom-property defaults (P1)
extension/src/vault/vault.css Modify Same coloring rules (P1); .form-lower constraint (P3)
extension/src/popup/components/field-history.ts Modify Colorize revealed history entries (P1)
extension/src/popup/components/settings.ts Modify Display section: pickers + swatch + reset (P1)
extension/src/popup/popup.ts Modify Boot-time applyColorScheme() + storage-change listener (P1)
extension/src/vault/vault.ts Modify Boot-time applyColorScheme() (P1)
extension/src/setup/setup.ts Modify After state.configPushed = true, open vault tab + close setup (P2)
extension/src/setup/__tests__/setup.test.ts Create or extend Vitest: completion calls chrome.tabs.create with vault URL (P2)

Sequencing

Per spec: P4 → B1 → P1 → P3 → P2. P4 lands first because it centralizes the error vocabulary that the rest of the work assumes. B1 second because it's the smallest fix and ships visible value fast. P1 third because it's self-contained and lands a polish slice before the layout work. P3 fourth — minimal CSS to repair the visual rhythm. P2 last — end-to-end change that benefits from earlier polish being in place when the new tab opens.


Task 1 (P4): Build ERROR_COPY map + replace popup regex

Files:

  • Create: extension/src/shared/error-copy.ts

  • Create: extension/src/shared/__tests__/error-copy.test.ts

  • Modify: extension/src/popup/popup.ts

  • Step 1: Enumerate every error code via grep

cd /home/alee/Sources/relicario && \
  grep -rohE "ok: false, error: '[^']+'" extension/src/service-worker/ \
    --include="*.ts" \
    --exclude-dir=__tests__ \
    | sed -E "s/.*error: '([^']+)'/\\1/" \
    | sort -u

Expected output (this is the canonical list; if the grep surfaces more, add them — the test in Step 2 enforces this):

attachment_not_found
captured_tab_gone
download_failed
Extension not configured. Run setup first.
invalid base32 secret
invalid_sender_url
item_not_found
no items to import
no reference image stored locally
no_totp
not_a_login
origin_mismatch
Reference image not set. Run setup first.
remote already contains a Relicario vault
tab_navigated
unauthorized_sender
unknown_message_type
upload_failed
vault_locked

Note: a few entries are full sentences rather than snake_case — those already render acceptably; the map should still cover them so the lookup is total.

  • Step 2: Write the failing test

Create extension/src/shared/__tests__/error-copy.test.ts:

import { describe, it, expect } from 'vitest';
import { execSync } from 'node:child_process';
import { resolve } from 'node:path';
import { ERROR_COPY, lookupErrorCopy } from '../error-copy';

const repoRoot = resolve(__dirname, '../../../..');

function discoverCodes(): Set<string> {
  const out = execSync(
    `grep -rohE "ok: false, error: '[^']+'" extension/src/service-worker/ \
       --include="*.ts" --exclude-dir=__tests__`,
    { cwd: repoRoot, encoding: 'utf-8' },
  );
  const codes = new Set<string>();
  for (const line of out.split('\n')) {
    const m = line.match(/error: '([^']+)'/);
    if (m) codes.add(m[1]);
  }
  return codes;
}

describe('ERROR_COPY', () => {
  it('contains an entry for every error code returned by the service worker', () => {
    const discovered = discoverCodes();
    expect(discovered.size).toBeGreaterThan(0);
    const missing: string[] = [];
    for (const code of discovered) {
      if (!ERROR_COPY[code]) missing.push(code);
    }
    expect(missing).toEqual([]);
  });

  it('lookupErrorCopy returns the mapped entry for known codes', () => {
    const copy = lookupErrorCopy('vault_locked');
    expect(copy.title).toBe('Vault locked');
    expect(copy.body).toMatch(/unlock/i);
  });

  it('lookupErrorCopy falls back to a generic shape for unknown codes', () => {
    const copy = lookupErrorCopy('made_up_code_xyz');
    expect(copy.title).toBeTruthy();
    expect(copy.body).toContain('made_up_code_xyz'); // raw code visible in fallback body
  });
});
  • Step 3: Run — expect FAIL (module missing)
cd extension && npx vitest run src/shared/__tests__/error-copy.test.ts
  • Step 4: Implement error-copy.ts

Create extension/src/shared/error-copy.ts:

/// Central registry mapping snake_case (or sentence) error codes returned by
/// the service worker to user-facing copy. One key per distinct error string
/// emitted from `extension/src/service-worker/router/`. The accompanying test
/// enumerates the live grep and asserts every code is keyed here.
///
/// Callers receive `{ title, body, cta? }`. The popup and fullscreen tab each
/// render this however suits their surface — popup as inline error block,
/// fullscreen as the lock-screen error region.

export interface ErrorCta {
  label: string;
  /** Action ids the surface knows how to handle. New surfaces add cases. */
  action?: 'unlock' | 'reload_extension' | 'open_setup';
}

export interface ErrorCopy {
  title: string;
  body: string;
  cta?: ErrorCta;
}

const UNLOCK_CTA: ErrorCta = { label: 'Unlock vault', action: 'unlock' };

export const ERROR_COPY: Record<string, ErrorCopy> = {
  vault_locked: {
    title: 'Vault locked',
    body: 'Unlock your vault to continue.',
    cta: UNLOCK_CTA,
  },
  unauthorized_sender: {
    title: 'Action not allowed',
    body: 'This action is not allowed from here.',
  },
  unknown_message_type: {
    title: 'Internal error',
    body: 'The extension received an unknown request — try reloading.',
    cta: { label: 'Reload extension', action: 'reload_extension' },
  },
  origin_mismatch: {
    title: 'Wrong site',
    body: 'This login belongs to a different site — refusing to leak credentials cross-origin.',
  },
  not_a_login: {
    title: 'Not a login',
    body: 'That item does not have a username and password to fill.',
  },
  no_totp: {
    title: 'No 2FA on this item',
    body: 'This item does not have a TOTP secret configured.',
  },
  invalid_sender_url: {
    title: 'Cannot read tab URL',
    body: 'The current tab has no recognizable URL — try reloading the page.',
  },
  tab_navigated: {
    title: 'Tab changed',
    body: 'The browser tab changed before the action could complete — try again.',
  },
  captured_tab_gone: {
    title: 'Tab is gone',
    body: 'The browser tab closed before the action could complete — try again.',
  },
  item_not_found: {
    title: 'Item not found',
    body: 'That item is no longer in the vault — it may have been deleted from another device.',
  },
  attachment_not_found: {
    title: 'Attachment missing',
    body: 'The attachment is referenced in the item but is not present in the vault.',
  },
  upload_failed: {
    title: 'Upload failed',
    body: 'Could not upload the attachment — check your connection and try again.',
  },
  download_failed: {
    title: 'Download failed',
    body: 'Could not download the attachment — check your connection and try again.',
  },
  'invalid base32 secret': {
    title: 'Invalid secret',
    body: 'The TOTP secret must be valid Base32 (letters A-Z and digits 2-7 only).',
  },
  'no items to import': {
    title: 'Nothing to import',
    body: 'The CSV did not contain any importable items.',
  },
  'no reference image stored locally': {
    title: 'No reference image',
    body: 'This device has no reference image saved locally — re-attach the device or restore from backup.',
  },
  'remote already contains a Relicario vault': {
    title: 'Vault already exists',
    body: 'The selected repository already contains a vault — use Attach existing instead of Create new.',
  },
  'Extension not configured. Run setup first.': {
    title: 'Extension not configured',
    body: 'Run setup before using this action.',
    cta: { label: 'Open setup', action: 'open_setup' },
  },
  'Reference image not set. Run setup first.': {
    title: 'Reference image missing',
    body: 'Run setup to save your reference image.',
    cta: { label: 'Open setup', action: 'open_setup' },
  },
};

/// Total lookup. Unknown codes return a generic shape with the raw code in the
/// body so debugging is still possible — the alternative (returning empty body)
/// hides bugs by silently degrading.
export function lookupErrorCopy(code: string): ErrorCopy {
  return ERROR_COPY[code] ?? {
    title: 'Something went wrong',
    body: code,
  };
}
  • Step 5: Run — expect PASS
cd extension && npx vitest run src/shared/__tests__/error-copy.test.ts
  • Step 6: Replace popup humanizeError with lookupErrorCopy

In extension/src/popup/popup.ts (around line 121-160), replace humanizeError and the sendMessage body with:

import { lookupErrorCopy } from '../shared/error-copy';

export function sendMessage(request: Request): Promise<Response> {
  return new Promise((resolve) => {
    chrome.runtime.sendMessage(request, (response: Response) => {
      if (response && !response.ok && response.error) {
        response = { ok: false, error: humanizeError(response.error) };
      }
      resolve(response);
    });
  });
}

/// Translate an error code from the service worker into a single human-readable
/// string for inline error blocks. Surfaces that need a CTA (unlock prompt, etc.)
/// should call `lookupErrorCopy(code)` directly and render `cta` themselves.
export function humanizeError(err: string): string {
  // Rust/serde leakage that doesn't pass through the router's error vocabulary.
  // Keep these regexes — they translate underlying-library wording, not snake_case codes.
  if (/relative URL without a base/i.test(err)) {
    return 'URL must start with https:// or http:// (e.g. https://example.com)';
  }
  if (/item json:/i.test(err)) {
    return 'Could not save item — one of the fields is in an invalid format.';
  }
  if (/settings json:/i.test(err)) {
    return 'Settings are in an invalid format — try reloading the extension.';
  }
  // For everything else, look up in ERROR_COPY (handles vault_locked, origin_mismatch,
  // unauthorized_sender, tab_navigated/captured_tab_gone, etc. — replacing the prior
  // five hand-written regexes).
  const copy = lookupErrorCopy(err);
  return copy.body;
}

The existing five regex tests (vault_locked, origin_mismatch, unauthorized_sender, tab_navigated|captured_tab_gone) are gone — lookupErrorCopy handles them via exact-key match instead.

  • Step 7: Run all popup tests to confirm nothing regressed
cd extension && npx vitest run src/popup/

Expected: PASS. If a test asserted on a specific humanized string, update it to assert against lookupErrorCopy('<code>').body instead.

  • Step 8: Commit
git add extension/src/shared/error-copy.ts \
        extension/src/shared/__tests__/error-copy.test.ts \
        extension/src/popup/popup.ts
git commit -m "$(cat <<'EOF'
feat(ext/shared): centralize error-message copy in ERROR_COPY map

Replaces the popup's regex-chain humanizeError with a total lookup over
every error code returned by extension/src/service-worker/router/. A
generated test discovers codes via grep so the registry can't drift.
The popup keeps its small set of regex translators for Rust/serde error
phrasing that doesn't go through the router's error vocabulary.

Subsumes B2 — fullscreen consumer lands in the next commit.
EOF
)"

Task 2 (P4 cont.): Wire ERROR_COPY into the fullscreen vault tab

Files:

  • Modify: extension/src/vault/vault.ts

  • Step 1: Locate raw error rendering

In extension/src/vault/vault.ts, the lock screen renders:

${state.error ? `<div class="error" style="text-align:center;">${escapeHtml(state.error)}</div>` : ''}

at line ~202, with state.error = resp.error ?? 'unlock failed' set at ~222. Other call sites set state.error = (resp as { error: string }).error at line ~414 — same pattern.

When the service worker returns { ok: false, error: 'vault_locked' }, that snake_case string lands directly in the rendered <div class="error">. This is exactly the B2 leak shown in the screenshot.

  • Step 2: Render via lookupErrorCopy

Add the import at the top:

import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';

Replace each <div class="error">${escapeHtml(state.error)}</div> pattern with a helper:

function renderErrorBlock(code: string | null | undefined): string {
  if (!code) return '';
  const copy = lookupErrorCopy(code);
  const ctaHtml = copy.cta
    ? `<button class="btn btn-primary error-cta" data-cta="${escapeHtml(copy.cta.action ?? '')}">${escapeHtml(copy.cta.label)}</button>`
    : '';
  return `
    <div class="error error-block">
      <div class="error-title">${escapeHtml(copy.title)}</div>
      <div class="error-body">${escapeHtml(copy.body)}</div>
      ${ctaHtml}
    </div>
  `;
}

Replace the lock-screen line (~202) with:

${renderErrorBlock(state.error)}

Replace any other ${state.error ? \

…` : ''}lines invault.ts` the same way.

  • Step 3: Wire CTA actions

After the lock-screen markup is mounted, attach a click handler that dispatches based on data-cta:

app.querySelector<HTMLButtonElement>('.error-cta')?.addEventListener('click', (e) => {
  const cta = (e.currentTarget as HTMLElement).dataset.cta as ErrorCta['action'];
  switch (cta) {
    case 'unlock': {
      // Already on the lock screen; just refocus the passphrase input.
      document.getElementById('vault-passphrase')?.focus();
      break;
    }
    case 'open_setup': {
      void chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
      break;
    }
    case 'reload_extension': {
      chrome.runtime.reload();
      break;
    }
  }
});
  • Step 4: Append .error-block styles to vault.css

Append to extension/src/vault/vault.css:

.error-block {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 12px 16px;
  border: 1px solid rgba(171, 43, 32, 0.4);
  border-radius: 6px;
  background: rgba(171, 43, 32, 0.08);
  margin-top: 12px;
}
.error-block .error-title {
  font-weight: 600;
  color: var(--danger);
}
.error-block .error-body {
  color: var(--text);
  font-size: 12px;
  text-align: center;
}
.error-block .error-cta {
  margin-top: 6px;
}
  • Step 5: Manual smoke test
cd extension && npm run build

Load extension/dist/ in Chrome, open the fullscreen vault while locked, trigger an action that returns vault_locked, confirm the block reads:

Vault locked
Unlock your vault to continue.
[Unlock vault]

with the snake_case code nowhere on screen.

  • Step 6: Commit
git add extension/src/vault/vault.ts extension/src/vault/vault.css
git commit -m "$(cat <<'EOF'
fix(ext/vault): friendly error block in fullscreen tab (closes B2)

Replaces raw escapeHtml(state.error) renders with lookupErrorCopy()-driven
title/body/CTA blocks. vault_locked specifically gets a 'Unlock vault'
CTA that refocuses the passphrase input. Other CTAs route to setup.html
or chrome.runtime.reload().

Closes B2; concludes P4.
EOF
)"

Task 3 (B1): Dispatch input event after regenerate sets password

Files:

  • Modify: extension/src/popup/components/types/login.ts

  • Modify: extension/src/popup/components/types/__tests__/login.test.ts

  • Step 1: Locate the regenerate handler

In extension/src/popup/components/types/login.ts at lines 420-439, the gen-btn click opens the generator panel. The picked-value callback is:

onPicked: (value) => {
  const pw = document.getElementById('f-password') as HTMLInputElement | null;
  if (pw) { pw.value = value; pw.type = 'text'; }
},

Programmatic pw.value = value does NOT fire input events in standard DOM behavior. The strength-meter listener at extension/src/shared/form-affordances/password-tools.ts:65 (input.addEventListener('input', update)) never sees the new value, so the meter keeps reporting the prior reading (or empty / "trivially crackable" if the field was empty before).

  • Step 2: Write the failing test

In extension/src/popup/components/types/__tests__/login.test.ts, add:

describe('regenerate handler dispatches input event', () => {
  let app: HTMLElement;
  beforeEach(() => {
    document.body.innerHTML = '<div id="app"></div>';
    app = document.getElementById('app')!;
    // ...existing setup mocks for renderForm should already be present above.
  });

  it('dispatches an InputEvent on #f-password after the generator picks a value', () => {
    renderForm(app, 'add', null);
    const pw = app.querySelector<HTMLInputElement>('#f-password')!;
    const dispatchSpy = vi.spyOn(pw, 'dispatchEvent');

    // Simulate the generator panel calling its onPicked callback. Easiest
    // route: export the onPicked builder, OR open the panel and trigger its
    // pick. The test here exercises the handler indirectly — the assertion
    // is that dispatchEvent was called with an InputEvent after value is set.
    //
    // Implementation strategy: extract the onPicked body into a named helper
    // `applyGeneratedPassword(input, value)` exported from login.ts so the
    // test can call it directly.
    applyGeneratedPassword(pw, 'sCMtTJkF%GN^mF#-N6D%');

    expect(pw.value).toBe('sCMtTJkF%GN^mF#-N6D%');
    expect(pw.type).toBe('text');
    expect(dispatchSpy).toHaveBeenCalledWith(expect.any(InputEvent));
    const evt = dispatchSpy.mock.calls.find(c => c[0] instanceof InputEvent)![0] as InputEvent;
    expect(evt.type).toBe('input');
    expect(evt.bubbles).toBe(true);
  });

  it('strength meter listener fires when the input event bubbles', () => {
    renderForm(app, 'add', null);
    const pw = app.querySelector<HTMLInputElement>('#f-password')!;
    let listenerFired = false;
    pw.addEventListener('input', () => { listenerFired = true; });
    applyGeneratedPassword(pw, 'newpass');
    expect(listenerFired).toBe(true);
  });
});
  • Step 3: Run — expect FAIL
cd extension && npx vitest run src/popup/components/types/__tests__/login.test.ts -t "regenerate handler"

Expected: failure on applyGeneratedPassword not being exported, or on dispatchSpy not being called.

  • Step 4: Extract and export applyGeneratedPassword

In extension/src/popup/components/types/login.ts, add (near the top of the module, alongside the other exported helpers):

/// Programmatically install a generated password into the field, then
/// dispatch a synthetic `input` event so listeners (strength meter, autosave,
/// etc.) re-rate the new value. Without the dispatch, the meter at
/// shared/form-affordances/password-tools.ts:65 stays stale (B1).
export function applyGeneratedPassword(input: HTMLInputElement, value: string): void {
  input.value = value;
  input.type = 'text';
  input.dispatchEvent(new InputEvent('input', { bubbles: true }));
}

Replace the existing onPicked body inside the gen-btn click handler (line ~434) with:

onPicked: (value) => {
  const pw = document.getElementById('f-password') as HTMLInputElement | null;
  if (pw) applyGeneratedPassword(pw, value);
},
  • Step 5: Run — expect PASS
cd extension && npx vitest run src/popup/components/types/__tests__/login.test.ts -t "regenerate handler"
  • Step 6: Manual verification
cd extension && npm run build

Load extension/dist/ in Chrome, open the popup, edit a login, click the regenerate (orange ) button, pick a generated password. Confirm the strength bar redraws and the entropy text reads roughly ~10^N guesses — beyond consumer-hardware reach (or similar, depending on the password) — NOT the stale ~10^1 guesses — trivially crackable.

  • Step 7: Commit
git add extension/src/popup/components/types/login.ts \
        extension/src/popup/components/types/__tests__/login.test.ts
git commit -m "$(cat <<'EOF'
fix(ext/login): dispatch input event after regenerate sets password (B1)

Programmatic input.value = newPassword does not fire input events, so
the strength-meter listener at shared/form-affordances/password-tools.ts:65
never re-rates the new value — meter stays stuck on the prior reading.

Extract applyGeneratedPassword(input, value) helper that sets value, type,
then dispatches new InputEvent('input', { bubbles: true }). Vitest covers
the dispatch + a sanity check that bubbling listeners fire.
EOF
)"

Task 4 (P1 — Phase A): colorizePassword() pure utility

Files:

  • Create: extension/src/shared/password-coloring.ts

  • Create: extension/src/shared/__tests__/password-coloring.test.ts

  • Step 1: Write the failing tests

Create extension/src/shared/__tests__/password-coloring.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import { colorizePassword, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER } from '../password-coloring';

describe('colorizePassword', () => {
  beforeEach(() => {
    const dom = new JSDOM('<!DOCTYPE html><body></body>');
    (global as any).document = dom.window.document;
  });

  function classes(frag: DocumentFragment): string[] {
    return Array.from(frag.querySelectorAll('span')).map(s => s.className);
  }
  function texts(frag: DocumentFragment): string[] {
    return Array.from(frag.querySelectorAll('span')).map(s => s.textContent ?? '');
  }

  it('returns empty fragment for empty input', () => {
    const frag = colorizePassword('');
    expect(frag.childNodes.length).toBe(0);
  });

  it('classifies a mixed-class run', () => {
    const frag = colorizePassword('aB3$xY');
    expect(classes(frag)).toEqual([PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER]);
    expect(texts(frag)).toEqual(['aB', '3', '$', 'xY']);
  });

  it('all-letters produces a single letter span', () => {
    const frag = colorizePassword('passwd');
    expect(classes(frag)).toEqual([PWD_LETTER]);
    expect(texts(frag)).toEqual(['passwd']);
  });

  it('all-digits produces a single digit span', () => {
    const frag = colorizePassword('123456');
    expect(classes(frag)).toEqual([PWD_DIGIT]);
    expect(texts(frag)).toEqual(['123456']);
  });

  it('all-symbols produces a single symbol span', () => {
    const frag = colorizePassword('!@#$%^');
    expect(classes(frag)).toEqual([PWD_SYMBOL]);
    expect(texts(frag)).toEqual(['!@#$%^']);
  });

  it('classifies unicode letters as letters', () => {
    const frag = colorizePassword('áñü');
    expect(classes(frag)).toEqual([PWD_LETTER]);
  });

  it('classifies whitespace as symbol', () => {
    const frag = colorizePassword('a b');
    expect(classes(frag)).toEqual([PWD_LETTER, PWD_SYMBOL, PWD_LETTER]);
    expect(texts(frag)).toEqual(['a', ' ', 'b']);
  });

  it('representative password snapshot: aB3$xY7&_!', () => {
    const frag = colorizePassword('aB3$xY7&_!');
    expect(classes(frag)).toEqual([
      PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER, PWD_DIGIT, PWD_SYMBOL,
    ]);
    expect(texts(frag)).toEqual(['aB', '3', '$', 'xY', '7', '&_!']);
  });
});
  • Step 2: Run — expect FAIL
cd extension && npx vitest run src/shared/__tests__/password-coloring.test.ts

Expected: Cannot find module '../password-coloring'.

  • Step 3: Implement

Create extension/src/shared/password-coloring.ts:

export const PWD_DIGIT = 'pwd-digit';
export const PWD_SYMBOL = 'pwd-symbol';
export const PWD_LETTER = 'pwd-letter';

type Class = typeof PWD_DIGIT | typeof PWD_SYMBOL | typeof PWD_LETTER;

function classify(ch: string): Class {
  if (/^\d$/.test(ch)) return PWD_DIGIT;
  if (/^\p{L}$/u.test(ch)) return PWD_LETTER;
  return PWD_SYMBOL;
}

/**
 * Split `text` into runs of same-class codepoints and return a DocumentFragment
 * of class-named <span> nodes (one span per run). Returns an empty fragment
 * for empty input.
 *
 * Pure: does not mutate any DOM outside the returned fragment, does not perform
 * I/O. Safe to call on every render.
 */
export function colorizePassword(text: string): DocumentFragment {
  const frag = document.createDocumentFragment();
  if (text.length === 0) return frag;

  const codepoints = Array.from(text);
  let runStart = 0;
  let runClass = classify(codepoints[0]);

  for (let i = 1; i <= codepoints.length; i++) {
    const c = i < codepoints.length ? classify(codepoints[i]) : null;
    if (c !== runClass) {
      const span = document.createElement('span');
      span.className = runClass;
      span.textContent = codepoints.slice(runStart, i).join('');
      frag.appendChild(span);
      if (c !== null) {
        runStart = i;
        runClass = c;
      }
    }
  }
  return frag;
}
  • Step 4: Run — expect PASS
cd extension && npx vitest run src/shared/__tests__/password-coloring.test.ts

Expected: 8 PASS.

  • Step 5: Commit
git add extension/src/shared/password-coloring.ts \
        extension/src/shared/__tests__/password-coloring.test.ts
git commit -m "feat(ext/shared): add colorizePassword utility"

Task 5 (P1 — Phase B): applyColorScheme() + storage round-trip

Files:

  • Create: extension/src/shared/color-scheme.ts

  • Create: extension/src/shared/__tests__/color-scheme.test.ts

  • Step 1: Write the failing tests

Create extension/src/shared/__tests__/color-scheme.test.ts:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import {
  loadColorScheme, saveColorScheme, resetColorScheme, applyColorScheme,
  DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
} from '../color-scheme';

function mockChromeStorage(initial: any = {}) {
  const store = { ...initial };
  (global as any).chrome = {
    storage: {
      sync: {
        get: vi.fn((key: string) => Promise.resolve(
          key in store ? { [key]: store[key] } : {})),
        set: vi.fn((kv: any) => { Object.assign(store, kv); return Promise.resolve(); }),
        remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
      },
    },
  };
  return store;
}

describe('color-scheme storage', () => {
  beforeEach(() => {
    const dom = new JSDOM('<!DOCTYPE html><body></body>');
    (global as any).document = dom.window.document;
  });

  it('load returns defaults when storage is empty', async () => {
    mockChromeStorage();
    const scheme = await loadColorScheme();
    expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
    expect(scheme.symbol_color).toBe(DEFAULT_SYMBOL_COLOR);
  });

  it('load returns stored values when present', async () => {
    mockChromeStorage({
      password_display_scheme: { digit_color: '#123456', symbol_color: '#abcdef' },
    });
    const scheme = await loadColorScheme();
    expect(scheme.digit_color).toBe('#123456');
    expect(scheme.symbol_color).toBe('#abcdef');
  });

  it('save round-trips', async () => {
    mockChromeStorage();
    await saveColorScheme({ digit_color: '#111111', symbol_color: '#222222' });
    const scheme = await loadColorScheme();
    expect(scheme).toEqual({ digit_color: '#111111', symbol_color: '#222222' });
  });

  it('reset removes the storage key', async () => {
    const store = mockChromeStorage({
      password_display_scheme: { digit_color: '#000000', symbol_color: '#ffffff' },
    });
    await resetColorScheme();
    expect(store.password_display_scheme).toBeUndefined();
    const scheme = await loadColorScheme();
    expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
  });

  it('apply sets CSS custom properties on document.documentElement', async () => {
    mockChromeStorage({
      password_display_scheme: { digit_color: '#deadbe', symbol_color: '#feed00' },
    });
    await applyColorScheme();
    const root = document.documentElement.style;
    expect(root.getPropertyValue('--relicario-pwd-digit-color').trim()).toBe('#deadbe');
    expect(root.getPropertyValue('--relicario-pwd-symbol-color').trim()).toBe('#feed00');
  });

  it('save rejects malformed hex values', async () => {
    mockChromeStorage();
    await expect(saveColorScheme({ digit_color: 'not-a-color', symbol_color: '#ffffff' }))
      .rejects.toThrow();
  });
});
  • Step 2: Run — expect FAIL
cd extension && npx vitest run src/shared/__tests__/color-scheme.test.ts
  • Step 3: Implement

Create extension/src/shared/color-scheme.ts:

export const DEFAULT_DIGIT_COLOR = '#2563eb';
export const DEFAULT_SYMBOL_COLOR = '#dc2626';
const STORAGE_KEY = 'password_display_scheme';
const HEX_RE = /^#[0-9a-fA-F]{6}$/;

export interface ColorScheme {
  digit_color: string;
  symbol_color: string;
}

export const DEFAULT_SCHEME: ColorScheme = {
  digit_color: DEFAULT_DIGIT_COLOR,
  symbol_color: DEFAULT_SYMBOL_COLOR,
};

function isValid(s: ColorScheme): boolean {
  return HEX_RE.test(s.digit_color) && HEX_RE.test(s.symbol_color);
}

export async function loadColorScheme(): Promise<ColorScheme> {
  const result = await chrome.storage.sync.get(STORAGE_KEY);
  const stored = result[STORAGE_KEY] as Partial<ColorScheme> | undefined;
  if (!stored) return { ...DEFAULT_SCHEME };
  return {
    digit_color: typeof stored.digit_color === 'string' && HEX_RE.test(stored.digit_color)
      ? stored.digit_color : DEFAULT_DIGIT_COLOR,
    symbol_color: typeof stored.symbol_color === 'string' && HEX_RE.test(stored.symbol_color)
      ? stored.symbol_color : DEFAULT_SYMBOL_COLOR,
  };
}

export async function saveColorScheme(scheme: ColorScheme): Promise<void> {
  if (!isValid(scheme)) {
    throw new Error('Invalid color values; expected #rrggbb hex strings.');
  }
  await chrome.storage.sync.set({ [STORAGE_KEY]: scheme });
}

export async function resetColorScheme(): Promise<void> {
  await chrome.storage.sync.remove(STORAGE_KEY);
}

/// Read the stored scheme (or defaults) and apply the colors as inline CSS
/// custom properties on document.documentElement. Idempotent — safe to call
/// from popup/vault boot and from a chrome.storage.onChanged handler.
export async function applyColorScheme(): Promise<void> {
  const scheme = await loadColorScheme();
  const root = document.documentElement.style;
  root.setProperty('--relicario-pwd-digit-color', scheme.digit_color);
  root.setProperty('--relicario-pwd-symbol-color', scheme.symbol_color);
}
  • Step 4: Run — expect PASS
cd extension && npx vitest run src/shared/__tests__/color-scheme.test.ts
  • Step 5: Commit
git add extension/src/shared/color-scheme.ts \
        extension/src/shared/__tests__/color-scheme.test.ts
git commit -m "feat(ext/shared): color-scheme storage + applyColorScheme"

Task 6 (P1 — Phase C): CSS rules + custom-property defaults

Files:

  • Modify: extension/src/popup/styles.css

  • Modify: extension/src/vault/vault.css

  • Step 1: Append rules to popup/styles.css

Append to extension/src/popup/styles.css:

/* Password coloring (P1) — character-class display colors. Custom properties
   are overridden at runtime by applyColorScheme() reading chrome.storage.sync. */
:root {
  --relicario-pwd-digit-color: #2563eb;
  --relicario-pwd-symbol-color: #dc2626;
}
.pwd-digit  { color: var(--relicario-pwd-digit-color); }
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
.pwd-letter { color: inherit; }

(If :root is already declared earlier in the file, fold the two new custom properties into the existing :root block instead of re-declaring.)

  • Step 2: Append the same rules to vault.css

Same block appended to extension/src/vault/vault.css. Same :root-merge note applies.

  • Step 3: Build
cd extension && npm run build 2>&1 | tail -5

Expected: webpack compiles cleanly.

  • Step 4: Commit
git add extension/src/popup/styles.css extension/src/vault/vault.css
git commit -m "style(ext): add password-coloring CSS rules + custom property defaults"

Task 7 (P1 — Phase D.1): Wire field-history viewer to colorizePassword

Files:

  • Modify: extension/src/popup/components/field-history.ts

  • Step 1: Locate the revealed-value render

grep -n "history-entry__value\|revealed" extension/src/popup/components/field-history.ts

The line near 72 reads roughly:

<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>

displayValue is HTML-escaped string interpolation. Switch to imperative DOM patch since colorizePassword() returns a fragment.

  • Step 2: Update render

After the template renders the entry markup, replace the revealed cell's text content imperatively. Add:

import { colorizePassword } from '../../shared/password-coloring';

// after innerHTML / template render:
container.querySelectorAll<HTMLElement>('.history-entry__value.revealed').forEach((el, idx) => {
  el.textContent = '';
  el.appendChild(colorizePassword(revealedValues[idx]));
});

(revealedValues is whatever array of revealed-entry strings the existing render already computes. Adapt to actual variable names in this file.)

  • Step 3: Build and manual check
cd extension && npm run build

Open the popup, view a password's field history, reveal an entry — confirm digits render blue and symbols render red.

  • Step 4: Commit
git add extension/src/popup/components/field-history.ts
git commit -m "feat(ext/popup/field-history): colorize revealed password entries"

Task 8 (P1 — Phase D.2): Wire popup item-detail password reveal

Files:

  • Modify: the popup component that renders the password field's revealed value

  • Step 1: Find the surface

grep -rn "field.*[Pp]assword\|FieldKind.Password\|reveal-password" extension/src/popup/components/

Identify the component that branches on field.kind === FieldKind.Password and renders the revealed string. Likely candidates: an item-detail / item-view component.

  • Step 2: Apply the imperative pattern
import { colorizePassword } from '../../shared/password-coloring';

passwordValueEl.textContent = '';
if (revealed) {
  passwordValueEl.appendChild(colorizePassword(field.value));
} else {
  passwordValueEl.textContent = '••••••••';
}
  • Step 3: Manual check

Build, load, reveal a password in the popup item view — confirm coloring.

  • Step 4: Commit
git add extension/src/popup/components/
git commit -m "feat(ext/popup/item-detail): colorize revealed password field"

Task 9 (P1 — Phase D.3): Wire fullscreen vault item-detail

Files:

  • Modify: the equivalent component under extension/src/vault/

  • Step 1: Find the surface

grep -rn "FieldKind.Password\|reveal.*password" extension/src/vault/
  • Step 2: Apply the same pattern as Task 8

Same code shape, different file.

  • Step 3: Manual check

Open the fullscreen vault, reveal a password — confirm coloring.

  • Step 4: Commit
git add extension/src/vault/
git commit -m "feat(ext/vault): colorize revealed password field in fullscreen view"

Task 10 (P1 — Phase D.4): Wire generator preview

Files:

  • Modify: the generator-panel component

  • Step 1: Find the surface

grep -rn "generated\|preview" extension/src/popup/components/generator-panel*.ts

The panel has a live preview element that updates each roll.

  • Step 2: Apply imperative pattern
import { colorizePassword } from '../../shared/password-coloring';

previewEl.textContent = '';
previewEl.appendChild(colorizePassword(generatedPassword));
  • Step 3: Manual check

Open the generator, click roll a few times — confirm preview shows colored characters.

  • Step 4: Commit
git add extension/src/popup/components/generator-panel*.ts
git commit -m "feat(ext/generator): colorize live password preview"

Task 11 (P1 — Phase E): Boot wiring on popup + vault

Files:

  • Modify: extension/src/popup/popup.ts

  • Modify: extension/src/vault/vault.ts

  • Step 1: Add the call in popup boot

Near the top of the popup's init() / main() (whichever runs on DOMContentLoaded):

import { applyColorScheme } from '../shared/color-scheme';

await applyColorScheme();

chrome.storage.onChanged.addListener((changes, area) => {
  if (area === 'sync' && 'password_display_scheme' in changes) {
    void applyColorScheme();
  }
});
  • Step 2: Add the call in vault boot

Same imports + same two blocks in extension/src/vault/vault.ts's boot function.

  • Step 3: Manual verification

Open both surfaces. (Settings UI for live edits arrives in Task 12 — for this task, manually pre-populate chrome.storage.sync via DevTools Application panel and confirm the colors apply on next reload.)

  • Step 4: Commit
git add extension/src/popup/popup.ts extension/src/vault/vault.ts
git commit -m "feat(ext): apply color scheme on popup + vault startup"

Task 12 (P1 — Phase F): Display section in settings

Files:

  • Modify: extension/src/popup/components/settings.ts

  • Modify: extension/src/popup/components/__tests__/settings.test.ts

  • Modify: extension/src/popup/styles.css

  • Step 1: Find the existing settings section pattern

grep -n "section\|render" extension/src/popup/components/settings.ts | head -30

Identify the function that builds a section group + child controls.

  • Step 2: Add the Display section

Following the existing pattern, render two color pickers, a sample swatch, and a reset button:

import {
  loadColorScheme, saveColorScheme, resetColorScheme,
  DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
} from '../../shared/color-scheme';
import { colorizePassword } from '../../shared/password-coloring';

const SAMPLE = 'Abc123!@#xyz';

async function renderDisplaySection(parent: HTMLElement): Promise<void> {
  const section = document.createElement('div');
  section.className = 'settings-section';
  section.innerHTML = `
    <h3 class="settings-section-title">Display</h3>
    <div class="form-group">
      <label class="label">digit color</label>
      <input type="color" id="display-digit-color">
    </div>
    <div class="form-group">
      <label class="label">symbol color</label>
      <input type="color" id="display-symbol-color">
    </div>
    <div class="color-preview-swatch" id="display-swatch"></div>
    <button class="btn" id="display-reset">reset to defaults</button>
  `;
  parent.appendChild(section);

  const scheme = await loadColorScheme();
  const digitInput = section.querySelector<HTMLInputElement>('#display-digit-color')!;
  const symbolInput = section.querySelector<HTMLInputElement>('#display-symbol-color')!;
  const swatch = section.querySelector<HTMLElement>('#display-swatch')!;
  const resetBtn = section.querySelector<HTMLButtonElement>('#display-reset')!;

  digitInput.value = scheme.digit_color;
  symbolInput.value = scheme.symbol_color;

  const updateSwatch = () => {
    swatch.style.setProperty('--relicario-pwd-digit-color', digitInput.value);
    swatch.style.setProperty('--relicario-pwd-symbol-color', symbolInput.value);
    swatch.textContent = '';
    swatch.appendChild(colorizePassword(SAMPLE));
  };
  updateSwatch();

  const onChange = async () => {
    updateSwatch();
    try {
      await saveColorScheme({
        digit_color: digitInput.value,
        symbol_color: symbolInput.value,
      });
    } catch {
      // Browser color inputs always emit valid hex; nothing to surface.
    }
  };
  digitInput.addEventListener('change', onChange);
  symbolInput.addEventListener('change', onChange);

  resetBtn.addEventListener('click', async () => {
    digitInput.value = DEFAULT_DIGIT_COLOR;
    symbolInput.value = DEFAULT_SYMBOL_COLOR;
    await resetColorScheme();
    updateSwatch();
  });
}

Wire renderDisplaySection(parent) into the existing settings render function alongside the other sections.

  • Step 3: Add swatch styling to popup/styles.css

Append to extension/src/popup/styles.css:

.color-preview-swatch {
  font-family: ui-monospace, monospace;
  font-size: 1.1rem;
  padding: 8px 12px;
  border: 1px solid var(--border-mid);
  border-radius: 4px;
  margin-top: 8px;
  background: var(--bg-input);
}
.color-preview-swatch .pwd-digit  { color: var(--relicario-pwd-digit-color); }
.color-preview-swatch .pwd-symbol { color: var(--relicario-pwd-symbol-color); }
.color-preview-swatch .pwd-letter { color: inherit; }

The custom properties are scoped to .color-preview-swatch itself via inline style.setProperty in the JS — handy so the swatch previews changes independent of the global root scheme.

  • Step 4: Add settings tests

Extend extension/src/popup/components/__tests__/settings.test.ts with:

it('Display section round-trips digit color to chrome.storage.sync', async () => {
  // Mount settings, find #display-digit-color, dispatch change with '#abcdef',
  // assert chrome.storage.sync.set was called with
  //   { password_display_scheme: { digit_color: '#abcdef', symbol_color: ... } }
});

it('Reset button removes the storage key and restores the swatch defaults', async () => {
  // Mount settings, click #display-reset, assert chrome.storage.sync.remove
  // was called with 'password_display_scheme', and the swatch contains the
  // default-color spans.
});

(Detail-fill the test bodies following the existing settings.test.ts mocking style.)

  • Step 5: Run all extension tests
cd extension && npx vitest run

Expected: PASS.

  • Step 6: Commit
git add extension/src/popup/components/settings.ts \
        extension/src/popup/components/__tests__/settings.test.ts \
        extension/src/popup/styles.css
git commit -m "feat(ext/settings): Display section with color pickers + swatch + reset"

Task 13 (P3): Constrain lower form sections to match .form-grid envelope

Files:

  • Modify: extension/src/vault/vault.css

  • Modify: extension/src/popup/components/types/login.ts (wrap lower sections in a constrained container)

  • Step 1: Confirm the layout shape

In extension/src/popup/components/types/login.ts:344-365, the form renders:

${sectionsHtml}                      // .form-grid (max-width: 960px; margin: 0 auto)
<div class="form-group">notes</div> // full-width — visual rhythm break
${renderSectionsEditor(...)}         // disclosure — full-width
${renderAttachmentsDisclosure(...)}  // disclosure — full-width
<div class="form-actions"></div>    // full-width (or hidden in fullscreen)

The cards inside .form-grid sit at max-width: 960px; margin: 0 auto (vault.css:1585-1590); the lower sections inherit no width constraint and stretch the full pane width. P3's fix: wrap the lower sections in a .form-lower container with the same envelope.

  • Step 2: Wrap lower sections in .form-lower

In extension/src/popup/components/types/login.ts, change the block starting at line ~351 to:

${sectionsHtml}
<div class="${surface === 'fullscreen' ? 'form-lower' : ''}">
  <div class="form-group">
    <div class="notes-with-toggle">
      <label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
      <button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace"></button>
    </div>
    <textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
  </div>

  ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
  ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
  <div class="form-actions" ${externalActions ? 'hidden' : ''}>
    <button class="btn" id="cancel-btn">cancel</button>
    <button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
  </div>
</div>

The .form-lower class is gated on surface === 'fullscreen' so the popup keeps its current single-column behavior (already constrained by the popup's narrow viewport).

  • Step 3: Add .form-lower rule to vault.css

Append to extension/src/vault/vault.css (next to .form-grid around line 1585):

/* P3: lower form sections (notes, custom-fields disclosure, attachments,
   form actions) constrained to the same envelope as .form-grid so the
   visual rhythm doesn't break at the 2-col → full-width transition. */
.form-lower {
  max-width: 960px;
  margin: 0 auto;
  padding: 0 0; /* matches .form-grid's lack of horizontal padding */
}
.form-lower > .form-group,
.form-lower > .disclosure,
.form-lower > .attachments-disclosure,
.form-lower > .form-actions {
  /* Items inherit the envelope from .form-lower; no per-child max-width. */
  width: 100%;
}
  • Step 4: Build
cd extension && npm run build 2>&1 | tail -3
  • Step 5: Manual viewport sweep

Load extension/dist/ in Chrome. Open a new-login form in the fullscreen vault tab. Resize the viewport at each of these widths and confirm the lower sections (notes, custom-fields disclosure, attachments disclosure, form actions if visible) align horizontally with the IDENTITY/CREDENTIALS card grid above:

  • 1920×1080 (DevTools desktop)
  • 1440×900
  • 1024×768 — borderline tablet; cards are still 2-col here per @media (max-width: 720px)
  • 768×1024 — under the 720px breakpoint, .form-grid collapses to 1-col; .form-lower stays at max-width: 960px but viewport is narrower, so it just fills the pane (no jarring transition because there are no cards beside it either)

Each viewport: confirm no jarring left/right edge change between the cards and the lower sections.

  • Step 6: Commit
git add extension/src/popup/components/types/login.ts extension/src/vault/vault.css
git commit -m "$(cat <<'EOF'
fix(ext/login): constrain lower form sections to .form-grid envelope (P3)

Notes, custom-fields disclosure, attachments disclosure, and form-actions
in fullscreen logins now sit inside a .form-lower wrapper with the same
max-width: 960px; margin: 0 auto envelope as .form-grid above. Removes
the visual rhythm break at the 2-col → full-width transition.

Popup keeps its current single-column behavior (gated on surface flag).
EOF
)"

Task 14 (P2): Setup-wizard completion → fullscreen vault tab

Files:

  • Modify: extension/src/setup/setup.ts

  • Create or extend: extension/src/setup/__tests__/setup.test.ts

  • Step 1: Locate the terminal-step success path

In extension/src/setup/setup.ts, the register-device handler at line ~1042 sets state.configPushed = true; render(); at line ~1102 after successful device registration. That's the terminal success — no further navigation. The user is left looking at a static "device registered" success-box.

P2's job: after state.configPushed = true; render();, open the fullscreen vault tab and close the setup tab.

  • Step 2: Write the failing test

Create or extend extension/src/setup/__tests__/setup.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { finishSetup } from '../setup';

describe('finishSetup', () => {
  beforeEach(() => {
    (global as any).chrome = {
      tabs: {
        create: vi.fn(() => Promise.resolve({ id: 999 })),
        getCurrent: vi.fn(() => Promise.resolve({ id: 42 })),
        remove: vi.fn(() => Promise.resolve()),
      },
      runtime: {
        getURL: vi.fn((p: string) => `chrome-extension://abc/${p}`),
      },
    };
  });

  it('opens vault.html in a new tab', async () => {
    await finishSetup();
    expect(chrome.runtime.getURL).toHaveBeenCalledWith('vault.html');
    expect(chrome.tabs.create).toHaveBeenCalledWith({
      url: 'chrome-extension://abc/vault.html',
    });
  });

  it('closes the current setup tab after opening the vault tab', async () => {
    await finishSetup();
    expect(chrome.tabs.getCurrent).toHaveBeenCalled();
    expect(chrome.tabs.remove).toHaveBeenCalledWith(42);
  });

  it('still opens the vault tab even if closing the setup tab fails', async () => {
    (chrome.tabs.remove as any).mockRejectedValueOnce(new Error('no permission'));
    await expect(finishSetup()).resolves.not.toThrow();
    expect(chrome.tabs.create).toHaveBeenCalled();
  });
});
  • Step 3: Run — expect FAIL
cd extension && npx vitest run src/setup/__tests__/setup.test.ts

Expected: finishSetup not exported.

  • Step 4: Implement finishSetup

In extension/src/setup/setup.ts, add (export it so tests can import):

/// Hand off from the setup wizard's terminal success state to the fullscreen
/// vault tab. Opens vault.html in a new tab and closes this setup tab.
/// Errors closing the setup tab are swallowed — the vault tab is what matters.
export async function finishSetup(): Promise<void> {
  const vaultUrl = chrome.runtime.getURL('vault.html');
  await chrome.tabs.create({ url: vaultUrl });
  try {
    const current = await chrome.tabs.getCurrent();
    if (current?.id !== undefined) {
      await chrome.tabs.remove(current.id);
    }
  } catch {
    // Setup tab may not be closeable (e.g., if it was opened as a popup, not a tab).
    // The vault tab is open — that's the user-visible success.
  }
}

In attachStep5 (line ~1102 area), change:

state.configPushed = true;
render();

to:

state.configPushed = true;
render();
// Hand off to the fullscreen vault tab.
void finishSetup();
  • Step 5: Run — expect PASS
cd extension && npx vitest run src/setup/__tests__/setup.test.ts
  • Step 6: Manual smoke test (both setup modes)
cd extension && npm run build

Load extension/dist/, run through the new-vault flow end-to-end:

  1. Step 0: pick "create new"
  2. …reach Step 5, click "register this device"
  3. After "device registered" appears, the setup tab should close and the fullscreen vault tab should open at chrome-extension://<id>/vault.html.

Repeat with the attach-existing flow (Step 0: "attach existing") — same handoff expected.

  • Step 7: Commit
git add extension/src/setup/setup.ts extension/src/setup/__tests__/setup.test.ts
git commit -m "$(cat <<'EOF'
feat(ext/setup): hand off completion to fullscreen vault tab (P2)

After successful device registration (state.configPushed = true), the
wizard now opens vault.html in a new tab and closes the setup tab.
Both create-new and attach-existing flows funnel through the same
finishSetup() handler. Closing the setup tab is best-effort —
chrome.tabs.remove failures don't block the vault open.
EOF
)"

Task 15: Final verification

  • Step 1: Run the full test suite
cd extension && npx vitest run

Expected: all tests pass.

  • Step 2: Build for production (Chrome + Firefox)
cd extension && npm run build:all 2>&1 | tail -5

Expected: webpack compiles both targets with no errors (only the existing 4MB WASM warning).

  • Step 3: End-to-end smoke test

Load extension/dist/ in Chrome via chrome://extensions → Developer mode → Load unpacked. Walk through the v0.5.0 Plan B acceptance set:

  1. B1 (regenerate desync): Edit a login, click , pick a generated password. Strength bar updates and entropy text reflects the new password's strength. No "trivially crackable" stale reading.
  2. P4 (vault_locked friendly copy): With the vault locked, open the fullscreen tab, attempt an action that returns vault_locked. Error block reads "Vault locked / Unlock your vault to continue. / [Unlock vault]". No vault_locked snake_case anywhere. Click "Unlock vault" — passphrase input gets focus.
  3. P4 (other codes): Trigger origin_mismatch (try to fill from a host different than the item's URL) — block reads "Wrong site / This login belongs to a different site …". Trigger unauthorized_sender (e.g., bad CSP context) — block reads "Action not allowed / This action is not allowed from here.".
  4. P1 (coloring): Reveal a password with mixed character classes in the popup item view, the fullscreen item view, the field-history viewer, and the generator preview. Digits render blue, symbols render red, letters inherit. Open settings → Display, change the digit color via the picker, observe the swatch update live. Reload the popup — the new color persists.
  5. P3 (form layout): Open a new-login form in the fullscreen tab. Identity/Credentials cards align with notes / custom-fields / attachments below them at all four viewport sizes (1920×1080, 1440×900, 1024×768, 768×1024).
  6. P2 (setup → vault): From a fresh extension install, run setup all the way through. After clicking "register this device" at step 5, the setup tab closes and the fullscreen vault tab opens.
  • Step 4: Fix any smoke-test follow-ups

If anything regresses, fix and commit with style(ext): polish smoke-test fixes or similar. Otherwise no extra commit.


Completion Checklist

  • Task 1 (P4): ERROR_COPY map + popup humanizeError rewrite
  • Task 2 (P4): Fullscreen vault renderErrorBlock (closes B2)
  • Task 3 (B1): Regenerate dispatches input event
  • Task 4 (P1-A): colorizePassword() utility
  • Task 5 (P1-B): Color-scheme storage + applyColorScheme()
  • Task 6 (P1-C): CSS rules + custom properties
  • Task 7 (P1-D.1): Field-history viewer
  • Task 8 (P1-D.2): Popup item-detail
  • Task 9 (P1-D.3): Fullscreen item-detail
  • Task 10 (P1-D.4): Generator preview
  • Task 11 (P1-E): Boot wiring on popup + vault
  • Task 12 (P1-F): Display section in settings
  • Task 13 (P3): .form-lower envelope constraint
  • Task 14 (P2): Setup completion → vault tab
  • Task 15: Final verification

Self-Review Notes

Spec coverage:

  • B1: Tasks 3 (input-event dispatch + vitest).
  • B2: Folded into Task 2 (fullscreen renderErrorBlock consumes ERROR_COPY).
  • P1: Tasks 412 (utility, storage, CSS, four reveal surfaces, boot wiring, settings UI). Inlined verbatim from docs/superpowers/plans/2026-05-01-password-coloring.md so Plan B stands alone.
  • P2: Task 14 (finishSetup + vitest on chrome.tabs.create).
  • P3: Task 13 (Approach A from spec — constrain lower sections to .form-grid envelope, no card-wrapping).
  • P4: Tasks 1 + 2 (centralized map, popup wiring, fullscreen wiring; generated test enumerates grep'd codes so it can't drift).

Placeholder scan: No "TBD". The two "find via grep" steps in Tasks 8 and 10 reference search commands and the live source — they are not placeholders, they're locator hints because the password-reveal surfaces in the popup item-detail and generator-panel components weren't load-bearing enough to enumerate up front. The grep commands are exact.

Type / name consistency:

  • ERROR_COPY, lookupErrorCopy, ErrorCopy, ErrorCta consistent across Tasks 1 and 2.
  • colorizePassword, PWD_DIGIT/SYMBOL/LETTER, loadColorScheme/saveColorScheme/resetColorScheme/applyColorScheme, DEFAULT_DIGIT_COLOR/DEFAULT_SYMBOL_COLOR, ColorScheme, STORAGE_KEY = 'password_display_scheme' consistent across Tasks 412.
  • applyGeneratedPassword(input, value) introduced in Task 3 and not reused elsewhere — single call site at the gen-btn onPicked.
  • finishSetup introduced in Task 14, called once from attachStep5.
  • .form-lower wrapper class introduced in Task 13, gated on surface === 'fullscreen' so the popup is unaffected.

Stand-alone test: A new engineer with zero context can execute this plan top-to-bottom. Each task has a concrete file list, a TDD-shaped sequence (write test → fail → implement → pass), the actual code to land, and a commit. Manual checks are bounded (specific viewports, specific error codes, specific reveal surfaces) so verification is unambiguous.