diff --git a/docs/superpowers/plans/2026-05-02-phase-2b-polish-and-form-layout.md b/docs/superpowers/plans/2026-05-02-phase-2b-polish-and-form-layout.md new file mode 100644 index 0000000..8b8e8a2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-phase-2b-polish-and-form-layout.md @@ -0,0 +1,1257 @@ +# Phase 2B: Polish Foundation + Form Layout — Implementation Plan + +> **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 patina palette, polish vocabulary (backdrop, glass cards, button hierarchy, arrow glyph), and the two-column login form layout across popup, setup wizard, and fullscreen vault. + +**Architecture:** Foundation CSS tokens + shared classes go into `popup/styles.css` and `vault/vault.css` first. Each surface (login, setup, vault) is then updated to consume the new classes. The two-column login form gets a `surface: 'popup' | 'fullscreen'` flag on `renderForm()` so the same component renders single-column in popup and two-column in fullscreen. + +**Tech Stack:** TypeScript, vanilla DOM, vitest + happy-dom, plain CSS (no preprocessor). + +**Spec:** `docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md` + +--- + +## File Structure + +| File | Change | Purpose | +|------|--------|---------| +| `extension/src/shared/glyphs.ts` | Modify | Add `GLYPH_NEXT = '▸'` | +| `extension/src/popup/styles.css` | Modify | Patina tokens, `.surface-backdrop`, `.glass`, `.btn-primary/secondary` | +| `extension/src/vault/vault.css` | Modify | Same tokens + form-grid + sticky save bar + header treatment | +| `extension/src/popup/components/unlock.ts` | Modify | Logo lockup, glass card, primary unlock button | +| `extension/src/popup/components/settings-vault.ts:164,171` | Modify | Replace `→` with `▸` | +| `extension/src/setup/setup.ts` | Modify | Backdrop wrapper, glass step cards, `▸` on next buttons | +| `extension/src/vault/vault.ts` | Modify | Backdrop wrapper, surface flag passed to login renderer, dirty subtitle | +| `extension/src/popup/components/types/login.ts` | Modify | `surface` param on renderForm; column wrapping for fullscreen | +| `extension/src/popup/components/__tests__/unlock.test.ts` | Create | Unlock view structure tests | +| `extension/src/setup/__tests__/setup.test.ts` | Create | Setup wizard structure tests | +| `extension/src/popup/components/types/__tests__/login.test.ts` | Modify | Surface flag + two-column rendering tests | + +--- + +## Task 1: Add patina color tokens to popup/styles.css + +**Files:** +- Modify: `extension/src/popup/styles.css:3-28` + +- [ ] **Step 1: Read the current `:root` block in styles.css** + +The existing tokens are at lines 3-28. We're adding patina tokens alongside, keeping the `--accent` alias for backwards compatibility. + +- [ ] **Step 2: Replace the `:root` block with patina tokens** + +In `extension/src/popup/styles.css`, replace lines 3-28 with: + +```css +:root { + /* Patina gold (Phase 2B) */ + --gold-base: #a88a4a; + --gold-mid: #cdb47a; + --gold-shadow: #5a3f12; + --gold-text: #c9a868; + --gold-soft: rgba(184, 149, 86, 0.14); + --gold-ring: rgba(184, 149, 86, 0.18); + --gold-stroke: #b89556; + --gold-hi-end: #dac8a0; + + /* Brand alias (kept for backwards compatibility) */ + --accent: var(--gold-base); + --accent-soft: var(--gold-soft); + --accent-strong: var(--gold-shadow); + + /* Surfaces */ + --bg-page: #0a0e14; + --bg-pane: #11161e; + --bg-elevated: #1c2330; + --bg-card: rgba(22, 27, 34, 0.55); + --bg-input: #0a0e14; + --border-soft: rgba(255, 255, 255, 0.05); + --border-mid: #262d36; + --border-subtle: var(--border-mid); + + /* Text */ + --text: #c9d1d9; + --text-muted: #8b949e; + --text-dim: #6b7888; + + /* Status */ + --danger: #ab2b20; + --danger-bg: #791111; + --success: #6cb37a; + + /* Focus */ + --focus-ring: 0 0 0 2px var(--gold-ring); +} +``` + +- [ ] **Step 3: Update `body` background to use new token** + +Find the `body` rule (around line 36) and replace `background: #0d1117;` with `background: var(--bg-page);`. + +- [ ] **Step 4: Update `.brand` color to use `--gold-text`** + +Find `.brand` (around line 62) and replace `color: #d2ab43;` with `color: var(--gold-text);`. + +- [ ] **Step 5: Build extension to verify no CSS errors** + +Run from the `extension/` directory: +```bash +npm run build 2>&1 | tail -5 +``` + +Expected: webpack compiles successfully. + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/popup/styles.css +git commit -m "$(cat <<'EOF' +style(ext/popup): add patina palette tokens + +Replaces bright amber #d2ab43 with patina gold #a88a4a as the new base. +Keeps --accent as alias for backwards compatibility. Adds --bg-card +and --border-soft for upcoming glass card class. +EOF +)" +``` + +--- + +## Task 2: Add patina tokens to vault.css + +**Files:** +- Modify: `extension/src/vault/vault.css:3-28` + +- [ ] **Step 1: Apply the same token block to vault.css** + +In `extension/src/vault/vault.css`, replace lines 3-28 with the same `:root` block from Task 1 Step 2. + +- [ ] **Step 2: Update `body` background and `.brand` color** + +Same updates as Task 1 Steps 3-4 but in `vault.css`. + +- [ ] **Step 3: Build to verify** + +```bash +cd extension && npm run build 2>&1 | tail -5 +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/vault/vault.css +git commit -m "$(cat <<'EOF' +style(ext/vault): add patina palette tokens + +Mirrors popup/styles.css token block so the two surfaces share a +consistent color vocabulary. +EOF +)" +``` + +--- + +## Task 3: Add `.surface-backdrop` class to both stylesheets + +**Files:** +- Modify: `extension/src/popup/styles.css` (append) +- Modify: `extension/src/vault/vault.css` (append) + +- [ ] **Step 1: Append `.surface-backdrop` to popup/styles.css** + +Add at the end of `extension/src/popup/styles.css`: + +```css +/* Phase 2B: surface backdrop — subtle radial top-glow + grid texture. + Apply to body or a top-level wrapper. Children must sit above the ::before. */ +.surface-backdrop { + position: relative; + background: + radial-gradient(ellipse 700px 240px at 50% -40px, rgba(184, 149, 86, 0.05), transparent 65%), + linear-gradient(180deg, #11161e 0%, #0a0e14 100%); +} +.surface-backdrop::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.012) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.012) 1px, transparent 1px); + background-size: 18px 18px; + pointer-events: none; + z-index: 0; +} +.surface-backdrop > * { + position: relative; + z-index: 1; +} +``` + +- [ ] **Step 2: Append the same block to vault.css** + +Append the identical block to `extension/src/vault/vault.css`. + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/popup/styles.css extension/src/vault/vault.css +git commit -m "$(cat <<'EOF' +style(ext): add .surface-backdrop class + +Subtle radial top-glow + 18px grid texture. Used as the backdrop for +the login popup, setup wizard, and fullscreen vault shell. +EOF +)" +``` + +--- + +## Task 4: Add `.glass` card class to both stylesheets + +**Files:** +- Modify: `extension/src/popup/styles.css` (append) +- Modify: `extension/src/vault/vault.css` (append) + +- [ ] **Step 1: Append `.glass` to both stylesheets** + +Add at the end of both `extension/src/popup/styles.css` and `extension/src/vault/vault.css`: + +```css +/* Phase 2B: glass card. Translucent panel with backdrop blur for the + unlock card, setup step card, and form section panels. Falls back + gracefully on browsers without backdrop-filter (just stays translucent). */ +.glass { + background: var(--bg-card); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--border-soft); + border-radius: 10px; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.03) inset, + 0 6px 18px rgba(0, 0, 0, 0.35); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/popup/styles.css extension/src/vault/vault.css +git commit -m "$(cat <<'EOF' +style(ext): add .glass card class + +Translucent fill, soft border, inner highlight, drop shadow. Used for +the unlock card, setup step cards, and form section panels. +EOF +)" +``` + +--- + +## Task 5: Add `.btn-primary` / `.btn-secondary` classes + +**Files:** +- Modify: `extension/src/popup/styles.css` (append) +- Modify: `extension/src/vault/vault.css` (append) + +- [ ] **Step 1: Append button hierarchy to both stylesheets** + +```css +/* Phase 2B: button hierarchy. Existing .btn class kept for backwards + compatibility; .btn-primary and .btn-secondary express clearer intent + and are used in updated views. */ +.btn-primary { + background: var(--gold-base); + color: var(--bg-page); + border: none; + padding: 9px 14px; + font-size: 12px; + font-weight: 600; + border-radius: 6px; + font-family: inherit; + cursor: pointer; + letter-spacing: 0.3px; + display: inline-flex; + align-items: center; + gap: 8px; + transition: background-color 0.15s; +} +.btn-primary:hover { background: var(--gold-stroke); } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-primary:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.btn-secondary { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.06); + color: var(--text-muted); + padding: 6px 12px; + font-size: 11px; + border-radius: 5px; + font-family: inherit; + cursor: pointer; +} +.btn-secondary:hover { border-color: rgba(255, 255, 255, 0.12); color: var(--text); } +.btn-secondary:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/popup/styles.css extension/src/vault/vault.css +git commit -m "$(cat <<'EOF' +style(ext): add .btn-primary and .btn-secondary classes + +Two-tier button hierarchy. .btn-primary uses patina gold fill; .btn-secondary +is a ghost button with muted border. Existing .btn class kept for +backwards compatibility. +EOF +)" +``` + +--- + +## Task 6: Add `GLYPH_NEXT` and apply to existing arrow uses + +**Files:** +- Modify: `extension/src/shared/glyphs.ts` +- Modify: `extension/src/popup/components/settings-vault.ts:164,171` +- Test: `extension/src/shared/__tests__/glyphs.test.ts` (create or extend) + +- [ ] **Step 1: Add `GLYPH_NEXT` constant** + +In `extension/src/shared/glyphs.ts`, add after `GLYPH_LOCK`: + +```typescript +export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family) +``` + +- [ ] **Step 2: Write a snapshot test for the constants** + +Check whether `extension/src/shared/__tests__/glyphs.test.ts` exists. If not, create it: + +```typescript +import { describe, it, expect } from 'vitest'; +import { + GLYPH_REVEAL, GLYPH_HIDE, GLYPH_GENERATE, GLYPH_FILL_FROM_TAB, + GLYPH_QR, GLYPH_MONO, GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, + GLYPH_LOCK, GLYPH_NEXT, +} from '../glyphs'; + +describe('glyph constants', () => { + it('uses single unicode codepoints (no emoji multi-codepoint)', () => { + const all = [ + GLYPH_REVEAL, GLYPH_HIDE, GLYPH_GENERATE, GLYPH_FILL_FROM_TAB, + GLYPH_QR, GLYPH_MONO, GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, + GLYPH_LOCK, GLYPH_NEXT, + ]; + for (const g of all) { + expect([...g].length).toBe(1); + } + }); + + it('GLYPH_NEXT is the small right triangle (U+25B8)', () => { + expect(GLYPH_NEXT).toBe('▸'); + }); +}); +``` + +- [ ] **Step 3: Run the test** + +```bash +cd extension && npx vitest run src/shared/__tests__/glyphs.test.ts +``` + +Expected: PASS. + +- [ ] **Step 4: Replace `→` in settings-vault.ts** + +In `extension/src/popup/components/settings-vault.ts`, change: + +```typescript +// Line 164: + +// becomes: + + +// Line 171: + +// becomes: + +``` + +Add the import at the top of the file: + +```typescript +import { GLYPH_NEXT } from '../../shared/glyphs'; +``` + +- [ ] **Step 5: Run vitest to confirm nothing broke** + +```bash +cd extension && npx vitest run +``` + +Expected: all existing tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/shared/glyphs.ts extension/src/shared/__tests__/glyphs.test.ts extension/src/popup/components/settings-vault.ts +git commit -m "$(cat <<'EOF' +feat(ext): add GLYPH_NEXT and replace ASCII arrows with ▸ + +Replaces the ASCII rightwards arrow → with U+25B8 ▸ in settings-vault +buttons. Matches the existing ▾/▸ disclosure-glyph family. +EOF +)" +``` + +--- + +## Task 7: Restructure unlock view with logo lockup, glass card, primary button + +**Files:** +- Modify: `extension/src/popup/components/unlock.ts` +- Modify: `extension/src/popup/index.html` (body wrapper class) +- Test: `extension/src/popup/components/__tests__/unlock.test.ts` (create) + +- [ ] **Step 1: Apply `.surface-backdrop` to popup body** + +In `extension/src/popup/index.html`, change the `` tag to: + +```html + +``` + +- [ ] **Step 2: Write the unlock view structure test** + +Create `extension/src/popup/components/__tests__/unlock.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderUnlock } from '../unlock'; + +vi.mock('../../../shared/state', () => ({ + getState: () => ({ loading: false, error: null }), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + openVaultTab: vi.fn(), +})); + +describe('renderUnlock', () => { + let app: HTMLElement; + beforeEach(() => { + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + }); + + it('renders the logo lockup (logo + brand + tagline)', () => { + renderUnlock(app); + expect(app.querySelector('.brand-logo')).toBeTruthy(); + expect(app.querySelector('.brand')?.textContent).toBe('Relicario'); + expect(app.querySelector('.tagline')?.textContent).toContain('two-factor'); + }); + + it('renders the unlock form inside a .glass card', () => { + renderUnlock(app); + const glass = app.querySelector('.glass'); + expect(glass).toBeTruthy(); + expect(glass!.querySelector('#passphrase-input')).toBeTruthy(); + expect(glass!.querySelector('.btn-primary')).toBeTruthy(); + }); + + it('renders open-vault and settings as secondary buttons outside the card', () => { + renderUnlock(app); + const vaultBtn = app.querySelector('#vault-btn'); + const settingsBtn = app.querySelector('#settings-btn'); + expect(vaultBtn?.classList.contains('btn-secondary')).toBe(true); + expect(settingsBtn?.classList.contains('btn-secondary')).toBe(true); + // They should NOT be inside the .glass card + const glass = app.querySelector('.glass'); + expect(glass!.contains(vaultBtn!)).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +cd extension && npx vitest run src/popup/components/__tests__/unlock.test.ts +``` + +Expected: FAIL (current unlock view doesn't have these classes / structure). + +- [ ] **Step 4: Rewrite renderUnlock** + +Replace the entire `renderUnlock` function in `extension/src/popup/components/unlock.ts`: + +```typescript +/// Unlock view — passphrase input with ENTER to submit. + +import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state'; +import type { ItemId, ManifestEntry } from '../../shared/types'; + +export function renderUnlock(app: HTMLElement): void { + const state = getState(); + + app.innerHTML = ` +
+
+ +
Relicario
+

two-factor vault

+
+ +
+
unlock
+
+ +
+ ${state.loading ? '
' : ''} + ${state.error ? `
${escapeHtml(state.error)}
` : ''} + +
+ +
+ + +
+
+ `; + + const input = document.getElementById('passphrase-input') as HTMLInputElement; + const unlockBtn = document.getElementById('unlock-btn') as HTMLButtonElement | null; + + const submit = async () => { + const passphrase = input.value; + if (!passphrase) return; + setState({ loading: true, error: null }); + const resp = await sendMessage({ type: 'unlock', passphrase }); + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items }); + } else { + setState({ loading: false, error: listResp.error }); + } + } else { + setState({ loading: false, error: resp.error }); + } + }; + + if (input && !state.loading) { + input.focus(); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); + } + unlockBtn?.addEventListener('click', submit); + + document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab()); + document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings')); +} +``` + +- [ ] **Step 5: Add `.tagline` and `.logo-lockup` CSS to popup/styles.css** + +Append to `extension/src/popup/styles.css`: + +```css +.logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; } +.logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; } +.tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; } +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +cd extension && npx vitest run src/popup/components/__tests__/unlock.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add extension/src/popup/index.html extension/src/popup/components/unlock.ts extension/src/popup/components/__tests__/unlock.test.ts extension/src/popup/styles.css +git commit -m "$(cat <<'EOF' +feat(ext/popup): polish unlock view with logo lockup + glass card + +Restructures the unlock screen so the form sits in a glass card with +a primary 'unlock vault' button. Logo, brand, and tagline are grouped +as a lockup. Open-vault and settings are demoted to secondary buttons. +Body gets the .surface-backdrop wrapper. +EOF +)" +``` + +--- + +## Task 8: Apply backdrop + glass cards to setup wizard + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +- [ ] **Step 1: Find the body wrapper / outer container in setup.ts** + +The `app.innerHTML = ...` block around line 191-199 wraps content in a `.pad` div. That's where we'll apply the backdrop. + +- [ ] **Step 2: Wrap setup content in `.surface-backdrop`** + +In `extension/src/setup/setup.ts`, locate the `render()` function. Change the outer wrapper from: + +```typescript +app.innerHTML = ` +
+ ... +
+`; +``` + +to: + +```typescript +app.innerHTML = ` +
+
+ ... +
+
+`; +``` + +- [ ] **Step 3: Wrap each step body in a `.glass` card** + +Each `renderStepN()` function returns a string starting with `
...`. Update each to: + +```typescript +return ` +
+

...

+ ... +
+`; +``` + +Apply this to `renderStep0`, `renderStep1`, `renderStep2`, `renderStep3New`, `renderStep3Attach`, `renderStep4`, `renderStep5`. The `wizard-step glass` combination preserves any existing `.wizard-step` rules while adding the glass treatment. + +- [ ] **Step 4: Update mode-card style to use glass class** + +In `renderStep0`, the mode cards use ` + +// Line 445: + + +// Line 546: + + +// Line 921 (continue button — also gets the glyph): + +``` + +For `attach-btn` (~line 301) and `create-btn` (~line 696), keep them as `btn-primary` but no arrow glyph (those are commit-action buttons, not "next"-style). + +- [ ] **Step 6: Build and visually verify in Chrome** + +```bash +cd extension && npm run build 2>&1 | tail -3 +``` + +Open the extension's setup page (or load the dist into Chrome) to confirm rendering. This is a manual visual check — there isn't an existing test harness for setup.ts views. + +- [ ] **Step 7: Run vitest to confirm nothing regressed** + +```bash +cd extension && npx vitest run +``` + +Expected: all existing tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add extension/src/setup/setup.ts +git commit -m "$(cat <<'EOF' +feat(ext/setup): apply polish vocabulary to setup wizard + +- Wraps setup content in .surface-backdrop +- Each wizard step gets a .glass card +- Mode-picker cards become glass cards +- 'next' / 'continue' buttons get the ▸ glyph +- Migrate from .btn .btn-primary to the new .btn-primary class +EOF +)" +``` + +--- + +## Task 9: Apply backdrop to fullscreen vault shell + +**Files:** +- Modify: `extension/src/vault/vault.html` +- Modify: `extension/src/vault/vault.ts` + +- [ ] **Step 1: Apply `.surface-backdrop` to body in vault.html** + +In `extension/src/vault/vault.html`, change `` to: + +```html + +``` + +- [ ] **Step 2: Build and check the existing layout still works** + +```bash +cd extension && npm run build 2>&1 | tail -3 +``` + +The existing vault layout has its own panes / sidebar; the backdrop sits behind everything via the `::before` pseudo-element. + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/vault/vault.html +git commit -m "$(cat <<'EOF' +style(ext/vault): apply .surface-backdrop to fullscreen body + +Subtle radial top-glow + grid texture behind the existing vault shell. +No layout changes — existing panes sit above the backdrop's ::before. +EOF +)" +``` + +--- + +## Task 10: Add `surface` flag to renderForm and per-surface column wrapping + +**Files:** +- Modify: `extension/src/popup/components/types/login.ts:238` +- Modify: `extension/src/popup/popup.ts` (callers of renderForm) +- Modify: `extension/src/vault/vault.ts` (callers of renderForm) +- Test: `extension/src/popup/components/types/__tests__/login.test.ts` + +- [ ] **Step 1: Read the current renderForm signature** + +```bash +grep -n "renderForm" extension/src/popup/components/types/login.ts | head -10 +``` + +The current signature at line 238 is: + +```typescript +export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void +``` + +- [ ] **Step 2: Write the surface-flag test** + +In `extension/src/popup/components/types/__tests__/login.test.ts`, add a test: + +```typescript +describe('renderForm surface flag', () => { + let app: HTMLElement; + beforeEach(() => { + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + // ... existing test setup mocks should already be in place above + }); + + it('renders single-column when surface is "popup" (default)', () => { + renderForm(app, 'add', null); + expect(app.querySelector('.form-grid')).toBeNull(); + }); + + it('renders two-column .form-grid wrapper when surface is "fullscreen"', () => { + renderForm(app, 'add', null, { surface: 'fullscreen' }); + const grid = app.querySelector('.form-grid'); + expect(grid).toBeTruthy(); + expect(grid!.querySelector('[data-form-section="identity"]')).toBeTruthy(); + expect(grid!.querySelector('[data-form-section="credentials"]')).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +cd extension && npx vitest run src/popup/components/types/__tests__/login.test.ts -t "surface flag" +``` + +Expected: FAIL — type error on `{ surface }` arg. + +- [ ] **Step 4: Update renderForm signature with optional surface flag** + +In `extension/src/popup/components/types/login.ts:238`, change the signature: + +```typescript +export interface RenderFormOptions { + surface?: 'popup' | 'fullscreen'; +} + +export function renderForm( + app: HTMLElement, + mode: 'add' | 'edit', + existing: Item | null, + opts: RenderFormOptions = {} +): void { + const surface = opts.surface ?? 'popup'; + // ... existing function body, with section wrapping below +``` + +- [ ] **Step 5: Wrap Identity / Credentials sections** + +Inside `renderForm`, locate the existing form-fields render block (search for the `` / `` blocks). The current implementation builds the form HTML by concatenating field strings or calling `renderRow(...)` per field. Identify the title / url / group fields (Identity) and username / password / totp fields (Credentials), then wrap them in column containers that only become a grid in fullscreen. + +Below, `<>` etc. are placeholders for whatever the existing code generates for that field — leave that code unchanged, just relocate it inside the new wrappers: + +```typescript +const identityHtml = ` + <div data-form-section="identity" class="${surface === 'fullscreen' ? 'glass form-col' : ''}"> + ${surface === 'fullscreen' ? '<div class="col-header">Identity</div>' : ''} + <<title field render>> + <<url field render>> + <<group field render>> + </div> +`; + +const credentialsHtml = ` + <div data-form-section="credentials" class="${surface === 'fullscreen' ? 'glass form-col' : ''}"> + ${surface === 'fullscreen' ? '<div class="col-header">Credentials</div>' : ''} + <<username field render>> + <<password field render>> + <<totp field render>> + </div> +`; + +const sectionsHtml = surface === 'fullscreen' + ? `<div class="form-grid">${identityHtml}${credentialsHtml}</div>` + : `${identityHtml}${credentialsHtml}`; +``` + +The notes / custom-sections / attachments blocks must remain *outside* the grid — they should sit below the column wrapper as full-width siblings, regardless of surface. + +- [ ] **Step 6: Add `.form-grid`, `.form-col`, `.col-header` to vault.css** + +Append to `extension/src/vault/vault.css`: + +```css +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + max-width: 960px; + margin: 0 auto; +} +@media (max-width: 720px) { + .form-grid { grid-template-columns: 1fr; } +} +.form-col { + padding: 14px 16px; +} +.col-header { + text-transform: uppercase; + letter-spacing: 1.2px; + font-weight: 500; + color: var(--text-muted); + font-size: 10px; + border-bottom: 1px solid var(--border-mid); + padding-bottom: 6px; + margin-bottom: 12px; +} +``` + +- [ ] **Step 7: Update the vault.ts caller to pass `surface: 'fullscreen'`** + +```bash +grep -n "renderForm" extension/src/vault/vault.ts +``` + +For each call site in vault.ts, add the surface flag: + +```typescript +renderForm(app, mode, existing, { surface: 'fullscreen' }); +``` + +The popup.ts callers stay unchanged (default `'popup'`). + +- [ ] **Step 8: Run tests** + +```bash +cd extension && npx vitest run src/popup/components/types/__tests__/login.test.ts +``` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add extension/src/popup/components/types/login.ts extension/src/popup/components/types/__tests__/login.test.ts extension/src/vault/vault.ts extension/src/vault/vault.css +git commit -m "$(cat <<'EOF' +feat(ext/login): add surface flag for two-column fullscreen form + +renderForm() takes an optional { surface: 'popup' | 'fullscreen' } +parameter. When 'fullscreen', the Identity and Credentials field +groups render as glass cards inside a .form-grid (two columns, +stacks at <=720px). Popup keeps its single-column layout. +EOF +)" +``` + +--- + +## Task 11: Add sticky save bar in fullscreen forms + +**Files:** +- Modify: `extension/src/vault/vault.css` (append) +- Modify: `extension/src/vault/vault.ts` (form rendering wrapper) + +- [ ] **Step 1: Append sticky save bar CSS to vault.css** + +```css +.form-pane { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} +.form-scroll { + flex: 1; + overflow-y: auto; + padding: 20px 24px; +} +.sticky-save-bar { + position: sticky; + bottom: 0; + background: rgba(17, 22, 30, 0.7); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border-top: 1px solid var(--border-mid); + padding: 12px 24px; + display: flex; + justify-content: flex-end; + gap: 8px; + z-index: 10; +} +.sticky-save-bar::before { + content: ''; + position: absolute; + top: -24px; + left: 0; + right: 0; + height: 24px; + background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent); + pointer-events: none; +} +``` + +- [ ] **Step 2: Wrap fullscreen form rendering with `.form-pane` + `.sticky-save-bar`** + +In `extension/src/vault/vault.ts`, wherever the form is rendered (look for the call to `renderForm`), wrap with the pane structure: + +```typescript +function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null) { + const wrapper = document.createElement('div'); + wrapper.className = 'form-pane'; + wrapper.innerHTML = ` + <div class="form-scroll" id="form-scroll"></div> + <div class="sticky-save-bar"> + <button class="btn-secondary" id="form-cancel">cancel</button> + <button class="btn-primary" id="form-save">save</button> + </div> + `; + app.replaceChildren(wrapper); + + const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement; + renderForm(scrollEl, mode, existing, { surface: 'fullscreen' }); + + wrapper.querySelector('#form-cancel')?.addEventListener('click', () => { + // dispatch existing cancel handler — wire up to existing flow + document.getElementById('form-cancel-existing')?.click(); + }); + wrapper.querySelector('#form-save')?.addEventListener('click', () => { + document.getElementById('form-save-existing')?.click(); + }); +} +``` + +The exact wiring depends on how the existing form save/cancel buttons are structured. The save bar buttons should trigger the same handlers — easiest path is to make the existing form's save/cancel buttons hidden when `surface === 'fullscreen'` and let the sticky bar trigger them. + +A cleaner alternative: have `renderForm` accept a flag to skip rendering its own action buttons when the wrapper is providing them. Add to `RenderFormOptions`: + +```typescript +export interface RenderFormOptions { + surface?: 'popup' | 'fullscreen'; + /** When true, renderForm skips its own save/cancel buttons (caller provides them in a sticky bar). */ + externalActions?: boolean; +} +``` + +And inside renderForm, gate the existing save/cancel button render on `!opts.externalActions`. Then the sticky bar buttons can call the existing save/cancel functions directly (export them from `login.ts` if necessary). + +- [ ] **Step 3: Build to verify CSS / TS compile** + +```bash +cd extension && npm run build 2>&1 | tail -3 +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/vault/vault.css extension/src/vault/vault.ts extension/src/popup/components/types/login.ts +git commit -m "$(cat <<'EOF' +feat(ext/vault): sticky save bar in fullscreen forms + +The form pane gets a flex column layout: scrollable content above, +sticky save bar at bottom. Bar uses translucent fill with backdrop-blur +and a 24px gradient fade so content scrolls under it. Save / cancel +buttons reuse the form's existing handlers via externalActions flag. +EOF +)" +``` + +--- + +## Task 12: Header treatment with dirty-state subtitle + +**Files:** +- Modify: `extension/src/vault/vault.css` (append) +- Modify: `extension/src/vault/vault.ts` + +- [ ] **Step 1: Append form header styles to vault.css** + +```css +.fullscreen-form-header { + padding: 14px 24px; + border-bottom: 1px solid var(--border-mid); + display: flex; + justify-content: space-between; + align-items: flex-start; +} +.fullscreen-form-header .title { + font-size: 16px; + font-weight: 500; + color: var(--text); +} +.fullscreen-form-header .sub { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} +.fullscreen-form-header .hint { + font-size: 11px; + color: var(--text-dim); +} +``` + +- [ ] **Step 2: Detect platform for keyboard hint label** + +Add a small helper near the top of `vault.ts`: + +```typescript +const isMac = navigator.platform.toLowerCase().includes('mac'); +const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save'; +``` + +- [ ] **Step 3: Render the header above `.form-pane`** + +Update `renderFormWrapped` from Task 11 to include the header: + +```typescript +function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null) { + const titleText = mode === 'add' ? 'new login' : 'edit login'; + const wrapper = document.createElement('div'); + wrapper.className = 'form-pane'; + wrapper.innerHTML = ` + <div class="fullscreen-form-header"> + <div> + <div class="title">${titleText}</div> + <div class="sub" id="form-dirty-sub">no changes</div> + </div> + <div class="hint">${SAVE_HINT}</div> + </div> + <div class="form-scroll" id="form-scroll"></div> + <div class="sticky-save-bar"> + <button class="btn-secondary" id="form-cancel">cancel</button> + <button class="btn-primary" id="form-save">save</button> + </div> + `; + app.replaceChildren(wrapper); + // ... rest of wiring from Task 11 +} +``` + +- [ ] **Step 4: Wire dirty-state subscription** + +Add a small dirty-tracker that listens for `input`/`change` events on the form scroll element. When any input fires, set the subtitle to "unsaved · esc to cancel"; on save/cancel, reset to "no changes": + +```typescript +const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement; +const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement; +let isDirty = false; +const markDirty = () => { + if (isDirty) return; + isDirty = true; + subEl.textContent = 'unsaved · esc to cancel'; +}; +const markClean = () => { + isDirty = false; + subEl.textContent = 'no changes'; +}; +scrollEl.addEventListener('input', markDirty, true); +scrollEl.addEventListener('change', markDirty, true); + +wrapper.querySelector('#form-cancel')?.addEventListener('click', () => { + markClean(); + // ... existing cancel logic +}); +wrapper.querySelector('#form-save')?.addEventListener('click', () => { + markClean(); + // ... existing save logic +}); +``` + +- [ ] **Step 5: Write a happy-dom test for the dirty subtitle** + +In `extension/src/vault/__tests__/`, create or extend a test file: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; + +// Pseudo-test — the actual test mounts renderFormWrapped with real wiring. +// If renderFormWrapped is unexported, export it from vault.ts for testing. + +describe('fullscreen form dirty subtitle', () => { + let app: HTMLElement; + beforeEach(() => { + document.body.innerHTML = '<div id="app"></div>'; + app = document.getElementById('app')!; + }); + + it('starts pristine and switches to dirty on first input', () => { + // Mount renderFormWrapped(app, 'add', null); + // const sub = app.querySelector('#form-dirty-sub')!; + // expect(sub.textContent).toBe('no changes'); + // const titleInput = app.querySelector('input[type=text]') as HTMLInputElement; + // titleInput.value = 'x'; + // titleInput.dispatchEvent(new Event('input', { bubbles: true })); + // expect(sub.textContent).toContain('unsaved'); + }); +}); +``` + +If `renderFormWrapped` is internal, expose it via `__test__` export gated on test env: + +```typescript +// vault.ts +export const __test__ = { renderFormWrapped }; +``` + +- [ ] **Step 6: Build to verify** + +```bash +cd extension && npm run build 2>&1 | tail -3 +``` + +- [ ] **Step 7: Commit** + +```bash +git add extension/src/vault/vault.css extension/src/vault/vault.ts extension/src/vault/__tests__/ +git commit -m "$(cat <<'EOF' +feat(ext/vault): fullscreen form header with dirty-state subtitle + +Title left ('new login' / 'edit login'), subtitle below cycles between +'no changes' and 'unsaved · esc to cancel' on input events. Right side +shows the platform-aware save hint ('⌘+S to save' / 'Ctrl+S to save'). +The actual ⌘+S keymap arrives in Phase 3 — this is a visual hint only. +EOF +)" +``` + +--- + +## Task 13: Final verification + +- [ ] **Step 1: Run full test suite** + +```bash +cd extension && npx vitest run +``` + +Expected: all tests pass. If any test fails, fix and recommit before proceeding. + +- [ ] **Step 2: Build for production (Chrome + Firefox)** + +```bash +cd extension && npm run build:all +``` + +Expected: webpack compiles both targets with no errors (only the existing 4MB WASM warning). + +- [ ] **Step 3: Verify rebuilt extension manifest** + +```bash +grep '"name"' extension/dist/manifest.json extension/dist-firefox/manifest.json +``` + +Expected: both show `"name": "Relicario"`. + +- [ ] **Step 4: Manual smoke test (load unpacked dist into Chrome)** + +Open `chrome://extensions`, enable Developer Mode, "Load unpacked" → `extension/dist/`. Verify: + +- Click the extension icon: popup opens, shows logo lockup, glass card with passphrase input, primary "unlock vault" button, secondary buttons below. +- Open the setup wizard (e.g., via `extension/dist/setup.html`): each step renders inside a glass card, "next" buttons show `▸` glyph. +- Open the fullscreen vault (extension menu → "Open Relicario vault"): backdrop visible behind the existing pane layout. +- Add a new login item: form renders two-column with Identity and Credentials cards, sticky save bar at bottom, header subtitle changes from "no changes" to "unsaved · esc to cancel" on first input. + +- [ ] **Step 5: Commit verification log if anything was missed** + +If the smoke test reveals follow-up tweaks, fix them and commit with message `style(ext): polish smoke-test fixes`. Otherwise, no commit. + +--- + +## Completion Checklist + +- [ ] Task 1: Patina tokens in popup/styles.css +- [ ] Task 2: Patina tokens in vault.css +- [ ] Task 3: `.surface-backdrop` class +- [ ] Task 4: `.glass` card class +- [ ] Task 5: `.btn-primary` / `.btn-secondary` +- [ ] Task 6: `GLYPH_NEXT` + arrow replacements +- [ ] Task 7: Unlock view restructure +- [ ] Task 8: Setup wizard polish +- [ ] Task 9: Vault shell backdrop +- [ ] Task 10: Surface flag + two-column form +- [ ] Task 11: Sticky save bar +- [ ] Task 12: Header dirty subtitle +- [ ] Task 13: Final verification