Files
relicario/docs/superpowers/plans/2026-05-02-phase-2b-polish-and-form-layout.md
adlee-was-taken d038b24c6b docs(plan): Phase 2B polish foundation + form layout
13-task plan to land patina palette, polish vocabulary (.surface-backdrop,
.glass, .btn-primary/secondary, ▸ arrow glyph), restructured login popup,
setup wizard polish, two-column login form, sticky save bar, and dirty-
state header subtitle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 13:25:35 -04:00

38 KiB

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:

: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:

npm run build 2>&1 | tail -5

Expected: webpack compiles successfully.

  • Step 6: Commit
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
cd extension && npm run build 2>&1 | tail -5
  • Step 4: Commit
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:

/* 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
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:

/* 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
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

/* 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
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:

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:

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
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:

// Line 164:
<button class="btn" id="open-backup">Backup &amp; restore </button>
// becomes:
<button class="btn" id="open-backup">Backup &amp; restore ${GLYPH_NEXT}</button>

// Line 171:
<button class="btn" id="open-import">LastPass CSV </button>
// becomes:
<button class="btn" id="open-import">LastPass CSV ${GLYPH_NEXT}</button>

Add the import at the top of the file:

import { GLYPH_NEXT } from '../../shared/glyphs';
  • Step 5: Run vitest to confirm nothing broke
cd extension && npx vitest run

Expected: all existing tests pass.

  • Step 6: Commit
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 <body> tag to:

<body class="surface-backdrop">
  • Step 2: Write the unlock view structure test

Create extension/src/popup/components/__tests__/unlock.test.ts:

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 = '<div id="app"></div>';
    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
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:

/// 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 = `
    <div class="pad" style="text-align:center; padding-top:32px;">
      <div class="logo-lockup" style="margin-bottom:24px;">
        <img class="brand-logo" src="icons/relicario-logo.svg" alt="">
        <div class="brand">Relicario</div>
        <p class="tagline">two-factor vault</p>
      </div>

      <div class="glass" style="padding:16px; text-align:left; margin-bottom:16px;">
        <div class="card-label" style="font-size:10px;text-transform:uppercase;letter-spacing:1.2px;color:var(--text-muted);margin-bottom:8px;">unlock</div>
        <div class="form-group" style="margin-bottom:10px;">
          <input
            type="password"
            id="passphrase-input"
            placeholder="passphrase"
            autocomplete="off"
            ${state.loading ? 'disabled' : ''}
          >
        </div>
        ${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
        ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
        <button class="btn-primary" id="unlock-btn" style="width:100%;justify-content:center;" ${state.loading ? 'disabled' : ''}>unlock vault</button>
      </div>

      <div style="display:flex; gap:8px; justify-content:center;">
        <button class="btn-secondary" id="vault-btn">open vault</button>
        <button class="btn-secondary" id="settings-btn">settings</button>
      </div>
    </div>
  `;

  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:

.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
cd extension && npx vitest run src/popup/components/__tests__/unlock.test.ts

Expected: PASS.

  • Step 7: Commit
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:

app.innerHTML = `
  <div class="pad" style="padding-top:12px;">
    ...
  </div>
`;

to:

app.innerHTML = `
  <div class="surface-backdrop" style="min-height:100vh;">
    <div class="pad" style="padding-top:12px;">
      ...
    </div>
  </div>
`;
  • Step 3: Wrap each step body in a .glass card

Each renderStepN() function returns a string starting with <div class="wizard-step">.... Update each to:

return `
  <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
    <h3>...</h3>
    ...
  </div>
`;

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 <button class="mode-card ${isNew ? 'active' : ''}">. Add glass to the class list:

<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
  • Step 5: Add glyph to all "next" buttons

Import GLYPH_NEXT at the top of setup.ts:

import { GLYPH_NEXT } from '../shared/glyphs';

Update each "next" button (lines 239, 445, 546, 921) to include the glyph after the label. Use btn-primary instead of btn btn-primary:

// Line 239:
<button class="btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next ${GLYPH_NEXT}</button>

// Line 445:
<button class="btn-primary" id="next-btn">next ${GLYPH_NEXT}</button>

// Line 546:
<button class="btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next ${GLYPH_NEXT}</button>

// Line 921 (continue button — also gets the glyph):
<button class="btn-primary" id="next-btn">continue ${GLYPH_NEXT}</button>

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
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
cd extension && npx vitest run

Expected: all existing tests pass.

  • Step 8: Commit
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 <body> to:

<body class="surface-backdrop">
  • Step 2: Build and check the existing layout still works
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
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

grep -n "renderForm" extension/src/popup/components/types/login.ts | head -10

The current signature at line 238 is:

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:

describe('renderForm surface flag', () => {
  let app: HTMLElement;
  beforeEach(() => {
    document.body.innerHTML = '<div id="app"></div>';
    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
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:

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 <input id="f-title"> / <input id="f-url"> 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, <<title field render>> etc. are placeholders for whatever the existing code generates for that field — leave that code unchanged, just relocate it inside the new wrappers:

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:

.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'
grep -n "renderForm" extension/src/vault/vault.ts

For each call site in vault.ts, add the surface flag:

renderForm(app, mode, existing, { surface: 'fullscreen' });

The popup.ts callers stay unchanged (default 'popup').

  • Step 8: Run tests
cd extension && npx vitest run src/popup/components/types/__tests__/login.test.ts

Expected: PASS.

  • Step 9: Commit
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

.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:

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:

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
cd extension && npm run build 2>&1 | tail -3
  • Step 4: Commit
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

.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:

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:

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":

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:

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:

// vault.ts
export const __test__ = { renderFormWrapped };
  • Step 6: Build to verify
cd extension && npm run build 2>&1 | tail -3
  • Step 7: Commit
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
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)
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
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