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

1655 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```bash
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`:
```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)**
```bash
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`:
```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**
```bash
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:
```ts
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**
```bash
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**
```bash
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:
```ts
${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:
```ts
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
```
Replace each `<div class="error">${escapeHtml(state.error)}</div>` pattern with a helper:
```ts
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:
```ts
${renderErrorBlock(state.error)}
```
Replace any other `${state.error ? \`<div class="error">…\` : ''}` lines in `vault.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`:
```ts
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`:
```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**
```bash
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**
```bash
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:
```ts
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:
```ts
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**
```bash
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):
```ts
/// 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:
```ts
onPicked: (value) => {
const pw = document.getElementById('f-password') as HTMLInputElement | null;
if (pw) applyGeneratedPassword(pw, value);
},
```
- [ ] **Step 5: Run — expect PASS**
```bash
cd extension && npx vitest run src/popup/components/types/__tests__/login.test.ts -t "regenerate handler"
```
- [ ] **Step 6: Manual verification**
```bash
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**
```bash
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`:
```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**
```bash
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`:
```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**
```bash
cd extension && npx vitest run src/shared/__tests__/password-coloring.test.ts
```
Expected: 8 PASS.
- [ ] **Step 5: Commit**
```bash
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`:
```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**
```bash
cd extension && npx vitest run src/shared/__tests__/color-scheme.test.ts
```
- [ ] **Step 3: Implement**
Create `extension/src/shared/color-scheme.ts`:
```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**
```bash
cd extension && npx vitest run src/shared/__tests__/color-scheme.test.ts
```
- [ ] **Step 5: Commit**
```bash
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`:
```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**
```bash
cd extension && npm run build 2>&1 | tail -5
```
Expected: webpack compiles cleanly.
- [ ] **Step 4: Commit**
```bash
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**
```bash
grep -n "history-entry__value\|revealed" extension/src/popup/components/field-history.ts
```
The line near 72 reads roughly:
```ts
<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:
```ts
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**
```bash
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**
```bash
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**
```bash
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**
```ts
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```ts
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**
```bash
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`):
```ts
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**
```bash
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**
```bash
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:
```ts
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`:
```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:
```ts
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**
```bash
cd extension && npx vitest run
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
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:
```ts
${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:
```ts
${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):
```css
/* 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**
```bash
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**
```bash
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`:
```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**
```bash
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):
```ts
/// 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:
```ts
state.configPushed = true;
render();
```
to:
```ts
state.configPushed = true;
render();
// Hand off to the fullscreen vault tab.
void finishSetup();
```
- [ ] **Step 5: Run — expect PASS**
```bash
cd extension && npx vitest run src/setup/__tests__/setup.test.ts
```
- [ ] **Step 6: Manual smoke test (both setup modes)**
```bash
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**
```bash
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**
```bash
cd extension && npx vitest run
```
Expected: all tests pass.
- [ ] **Step 2: Build for production (Chrome + Firefox)**
```bash
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.