Files
relicario/docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md
adlee-was-taken 506ad9711d refactor(ext/shared): rename REQUIRED_PILL → REQUIRED_PILL_HTML
Code-review feedback on Task 1: the _HTML suffix makes the 'this is raw
HTML, do not escape' contract obvious at every call site. Cheap to do
now (zero consumers); would be 8 diffs once Tasks 4-6 wire the constant
into the type forms.

Plan updated in lockstep so Task 4 references the new name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:29:49 -04:00

33 KiB

Fullscreen UX redesign — Phase 1: Visual Foundation

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: Establish the shared visual language (glyph constants, color tokens, focus ring, required pill, header subtitle) and clean up vestigial popup-only UI in the fullscreen vault. No structural or behavioral changes; pure visual foundation that the next three phases will build on.

Architecture: A new extension/src/shared/glyphs.ts module exports unicode glyph constants and a REQUIRED_PILL_HTML HTML snippet, consumed by both popup and fullscreen surfaces. CSS custom properties added to popup/styles.css and vault/vault.css provide the shared color/focus tokens. All eight type forms migrate from <span class="req">*</span> to the pill; sidebar nav buttons replace emoji with glyph constants; the popout-to-tab button is gated behind !isInTab() so it disappears in fullscreen. Fullscreen forms gain a static "esc to cancel" subtitle (dynamic dirty-state lands in Phase 3).

Tech stack: TypeScript, vanilla DOM (no framework), Vitest + happy-dom for unit tests. No new runtime dependencies.

Spec: docs/superpowers/specs/2026-04-30-relicario-fullscreen-ux-redesign-design.md


Task 1: shared/glyphs.ts module + snapshot test

Files:

  • Create: extension/src/shared/glyphs.ts

  • Create: extension/src/shared/__tests__/glyphs.test.ts

  • Step 1: Write the failing test

// extension/src/shared/__tests__/glyphs.test.ts
import { describe, it, expect } from 'vitest';
import * as glyphs from '../glyphs';

describe('glyphs', () => {
  it('exports the documented glyph constants', () => {
    expect(glyphs.GLYPH_REVEAL).toBe('⊙');
    expect(glyphs.GLYPH_HIDE).toBe('⊘');
    expect(glyphs.GLYPH_GENERATE).toBe('↻');
    expect(glyphs.GLYPH_FILL_FROM_TAB).toBe('⤓');
    expect(glyphs.GLYPH_QR).toBe('◫');
    expect(glyphs.GLYPH_MONO).toBe('≡');
    expect(glyphs.GLYPH_TRASH).toBe('▦');
    expect(glyphs.GLYPH_DEVICES).toBe('⌬');
    expect(glyphs.GLYPH_SETTINGS).toBe('⚙');
    expect(glyphs.GLYPH_LOCK).toBe('⏻');
  });

  it('exports REQUIRED_PILL_HTML as an HTML snippet', () => {
    expect(glyphs.REQUIRED_PILL_HTML).toBe('<span class="req-pill">required</span>');
  });
});
  • Step 2: Run test to verify it fails

Run: cd extension && ./node_modules/.bin/vitest run src/shared/__tests__/glyphs.test.ts Expected: FAIL with module-not-found / unresolved-import error.

  • Step 3: Create the glyphs module
// extension/src/shared/glyphs.ts
//
// Unicode glyph constants used across popup and fullscreen surfaces. All
// glyphs are monochrome unicode (no emoji) so they render identically in the
// codebase's monospace font. Pair each button glyph with a `title=` tooltip
// at the call site for accessibility — the constants here are the visual,
// not the affordance.

export const GLYPH_REVEAL        = '⊙';   // password reveal toggle (hidden state)
export const GLYPH_HIDE          = '⊘';   // password reveal toggle (revealed state)
export const GLYPH_GENERATE      = '↻';   // password / passphrase generate
export const GLYPH_FILL_FROM_TAB = '⤓';   // pull URL from active browser tab
export const GLYPH_QR            = '◫';   // paste / upload QR image (TOTP)
export const GLYPH_MONO          = '≡';   // toggle notes monospace font

export const GLYPH_TRASH         = '▦';   // sidebar trash nav
export const GLYPH_DEVICES       = '⌬';   // sidebar devices nav
export const GLYPH_SETTINGS      = '⚙';   // sidebar settings nav
export const GLYPH_LOCK          = '⏻';   // sidebar lock nav

/// Inline HTML snippet for the required-field pill. Use after a label's text:
///   `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`
export const REQUIRED_PILL_HTML = '<span class="req-pill">required</span>';
  • Step 4: Run test to verify it passes

Run: cd extension && ./node_modules/.bin/vitest run src/shared/__tests__/glyphs.test.ts Expected: PASS, 2/2 tests green.

  • Step 5: Commit
git -C /home/alee/Sources/relicario add extension/src/shared/glyphs.ts extension/src/shared/__tests__/glyphs.test.ts
git -C /home/alee/Sources/relicario commit -m "feat(ext/shared): glyph constants module for unified icon language

Centralizes the unicode glyphs used by sidebar nav and form action buttons
so popup and fullscreen surfaces stay in sync. Includes the REQUIRED_PILL_HTML
snippet used to replace the trailing-asterisk required-field marker.

Plan 2026-04-30 fullscreen UX phase 1 task 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 2: Color tokens + focus ring (popup styles.css)

Files:

  • Modify: extension/src/popup/styles.css:1-150

  • Step 1: Add color tokens at the top of the file

Open extension/src/popup/styles.css and add a :root block immediately after the leading comment (before the * reset on line 3):

/* relicario extension — terminal dark theme */

:root {
  /* Brand */
  --accent:        #d2ab43;
  --accent-soft:   rgba(210, 171, 67, 0.18);
  --accent-strong: #aa812a;

  /* Surfaces */
  --bg-page:       #0d1117;
  --bg-pane:       #161b22;
  --bg-elevated:   #21262d;
  --border-subtle: #30363d;

  /* Text */
  --text:          #c9d1d9;
  --text-muted:    #8b949e;
  --text-dim:      #484f58;

  /* Status */
  --danger:        #ab2b20;
  --danger-bg:     #791111;
  --success:       #6cb37a;

  /* Focus */
  --focus-ring:    0 0 0 2px rgba(210, 171, 67, 0.35);
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
  • Step 2: Update input focus to use the ring token

Find the existing input focus rule (around line 136) and replace it:

Before:

input:focus, textarea:focus, select:focus {
  border-color: #d2ab43;
}

After:

input:focus-visible, textarea:focus-visible, select:focus-visible {
  border-color: var(--accent);
  box-shadow: var(--focus-ring);
  outline: none;
}
  • Step 3: Update button focus to match

Find the .btn:focus rule (around line 97) and replace:

Before:

.btn:focus {
  outline: 1px solid #d2ab43;
  outline-offset: 1px;
}

After:

.btn:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
}
  • Step 4: Add the required-field pill style

Find the .label .req rule (around line 58) and add the pill rule immediately after it:

.label .req {
  color: var(--accent-strong);
  margin-left: 2px;
  font-weight: 600;
}

.req-pill {
  display: inline-block;
  font-size: 9px;
  padding: 1px 5px;
  background: var(--accent-soft);
  color: var(--accent);
  border-radius: 2px;
  margin-left: 6px;
  vertical-align: middle;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  font-weight: 500;
}
  • Step 5: Build the popup to verify CSS parses

Run: cd /home/alee/Sources/relicario/extension && ./node_modules/.bin/webpack --mode production 2>&1 | tail -5 Expected: webpack ... compiled with 2 warnings (the existing wasm size warnings; no CSS errors).

  • Step 6: Commit
git -C /home/alee/Sources/relicario add extension/src/popup/styles.css
git -C /home/alee/Sources/relicario commit -m "style(ext/popup): add color tokens, focus ring, required-pill class

Establishes :root CSS custom properties (accent, surfaces, status, focus
ring) and applies the focus ring to inputs/buttons via :focus-visible.
Adds .req-pill class used by Task 4 to replace the bare-asterisk required
marker. Existing .label .req kept for backward compatibility during the
migration window.

Plan 2026-04-30 fullscreen UX phase 1 task 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 3: Color tokens + focus ring (vault.css)

Files:

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

  • Step 1: Add the same :root block to vault.css

Open extension/src/vault/vault.css and add the same :root block at the top (above any existing content). Use the identical token block from Task 2 Step 1 so the two stylesheets stay in sync:

:root {
  /* Brand */
  --accent:        #d2ab43;
  --accent-soft:   rgba(210, 171, 67, 0.18);
  --accent-strong: #aa812a;

  /* Surfaces */
  --bg-page:       #0d1117;
  --bg-pane:       #161b22;
  --bg-elevated:   #21262d;
  --border-subtle: #30363d;

  /* Text */
  --text:          #c9d1d9;
  --text-muted:    #8b949e;
  --text-dim:      #484f58;

  /* Status */
  --danger:        #ab2b20;
  --danger-bg:     #791111;
  --success:       #6cb37a;

  /* Focus */
  --focus-ring:    0 0 0 2px rgba(210, 171, 67, 0.35);
}
  • Step 2: Find existing input focus rule and migrate it

Run: grep -n "input:focus\|textarea:focus\|:focus" extension/src/vault/vault.css | head -10

For each focus rule that sets border-color: #d2ab43 (or similar accent-color border), update it to use :focus-visible and add the ring:

input:focus-visible, textarea:focus-visible, select:focus-visible {
  border-color: var(--accent);
  box-shadow: var(--focus-ring);
  outline: none;
}

(If no equivalent rule exists in vault.css today, add the rule above; vault inputs currently inherit popup styles or have their own — check what grep returns.)

  • Step 3: Add the .req-pill rule

Append to vault.css (anywhere; group near .label if present):

.req-pill {
  display: inline-block;
  font-size: 9px;
  padding: 1px 5px;
  background: var(--accent-soft);
  color: var(--accent);
  border-radius: 2px;
  margin-left: 6px;
  vertical-align: middle;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  font-weight: 500;
}
  • Step 4: Build to verify

Run: cd /home/alee/Sources/relicario/extension && ./node_modules/.bin/webpack --mode production 2>&1 | tail -5 Expected: webpack ... compiled with 2 warnings.

  • Step 5: Commit
git -C /home/alee/Sources/relicario add extension/src/vault/vault.css
git -C /home/alee/Sources/relicario commit -m "style(ext/vault): mirror color tokens, focus ring, required-pill class

Same :root block and .req-pill rule as popup/styles.css so the two
stylesheets share visual tokens. Vault input focus migrated to
:focus-visible + box-shadow ring.

Plan 2026-04-30 fullscreen UX phase 1 task 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 4: Migrate required-marker sites to REQUIRED_PILL_HTML

Files (10 sites across 7 files):

  • Modify: extension/src/popup/components/types/card.ts:182

  • Modify: extension/src/popup/components/types/document.ts:94, 98

  • Modify: extension/src/popup/components/types/identity.ts:142

  • Modify: extension/src/popup/components/types/key.ts:131, 133

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

  • Modify: extension/src/popup/components/types/secure-note.ts:120

  • Modify: extension/src/popup/components/types/totp.ts:221, 230

  • Step 1: Create a regression test for the login form's title label

Create extension/src/popup/components/types/__tests__/required-pill.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('../../../../shared/state', () => ({
  sendMessage: vi.fn(),
  getState: () => ({ newType: 'login', generatorDefaults: null, error: null, loading: false, vaultSettings: null, entries: [] }),
  setState: vi.fn(),
  navigate: vi.fn(),
  escapeHtml: (s: string) => s,
  popOutToTab: vi.fn(),
  isInTab: () => false,
  openVaultTab: vi.fn(),
  registerHost: vi.fn(),
}));

vi.mock('../../generator-panel', () => ({
  openGeneratorPanel: vi.fn(),
  closeGeneratorPanel: vi.fn(),
  isGeneratorPanelOpen: () => false,
}));

import { renderForm } from '../login';

describe('required-pill migration', () => {
  beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });

  it('login form title uses the required pill', () => {
    renderForm(document.getElementById('app')!, 'add', null);
    const titleLabel = document.querySelector('label[for="f-title"]');
    expect(titleLabel?.innerHTML).toContain('required');
    expect(titleLabel?.innerHTML).not.toContain('<span class="req">*</span>');
  });
});
  • Step 2: Run the test to verify it fails

Run: cd extension && ./node_modules/.bin/vitest run src/popup/components/types/__tests__/required-pill.test.ts Expected: FAIL — <span class="req">*</span> is currently present, required text is not.

  • Step 3: Migrate login.ts

In extension/src/popup/components/types/login.ts:

Add an import near the top (after the existing imports):

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

Find line 252:

      <div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>

Replace with:

      <div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
  • Step 4: Run the test to verify it passes for login

Run: cd extension && ./node_modules/.bin/vitest run src/popup/components/types/__tests__/required-pill.test.ts Expected: PASS.

  • Step 5: Migrate the remaining six files

Apply the same pattern to each of these six files. For each:

  1. Add import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
  2. Replace each <span class="req">*</span> with ${REQUIRED_PILL_HTML}
File Line(s)
extension/src/popup/components/types/card.ts 182
extension/src/popup/components/types/document.ts 94, 98
extension/src/popup/components/types/identity.ts 142
extension/src/popup/components/types/key.ts 131, 133
extension/src/popup/components/types/secure-note.ts 120
extension/src/popup/components/types/totp.ts 221, 230

After editing each file, verify no remaining <span class="req">*</span> strings exist:

Run: grep -rn 'class="req"' extension/src --include="*.ts" 2>/dev/null Expected: empty output.

  • Step 6: Run the full extension test suite

Run: cd extension && ./node_modules/.bin/vitest run Expected: all 220+ tests pass (the new test brings it to 221+; no regressions).

  • Step 7: Build to verify TypeScript compiles

Run: cd /home/alee/Sources/relicario/extension && ./node_modules/.bin/webpack --mode production 2>&1 | tail -5 Expected: compiled with 2 warnings.

  • Step 8: Commit
git -C /home/alee/Sources/relicario add extension/src/popup/components/types/ extension/src/shared/
git -C /home/alee/Sources/relicario commit -m "refactor(ext/popup): migrate required-field markers to REQUIRED_PILL_HTML

Replaces ten <span class=\"req\">*</span> sites across all seven type
forms with the shared REQUIRED_PILL_HTML snippet ('required' badge). Adds a
regression test pinning the new HTML in the login form.

Plan 2026-04-30 fullscreen UX phase 1 task 4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 5: Migrate vault sidebar nav glyphs

Files:

  • Modify: extension/src/vault/vault.ts:251-254

  • Step 1: Write a regression test

Open extension/src/vault/components/__tests__/import-panel.test.ts for reference on how vault tests mock state. Create a new test file:

extension/src/vault/__tests__/sidebar-glyphs.test.ts:

import { describe, it, expect } from 'vitest';
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../../shared/glyphs';

// vault.ts injects HTML into document.getElementById('vault-app'); we
// don't need to invoke render() — we just need to scan the source for the
// emoji we removed.
import * as fs from 'fs';
import * as path from 'path';

describe('vault sidebar glyphs', () => {
  const vaultSrc = fs.readFileSync(
    path.resolve(__dirname, '../vault.ts'),
    'utf-8',
  );

  it('uses GLYPH_TRASH instead of the trash emoji', () => {
    expect(vaultSrc).not.toMatch(/\u{1F5D1}/u);
    expect(vaultSrc).toContain('GLYPH_TRASH');
  });

  it('uses GLYPH_DEVICES instead of the devices emoji', () => {
    expect(vaultSrc).not.toMatch(/\u{1F4F1}/u);
    expect(vaultSrc).toContain('GLYPH_DEVICES');
  });

  it('uses GLYPH_LOCK instead of the lock emoji', () => {
    expect(vaultSrc).not.toMatch(/\u{1F512}/u);
    expect(vaultSrc).toContain('GLYPH_LOCK');
  });

  it('uses GLYPH_SETTINGS for the settings nav', () => {
    expect(vaultSrc).toContain('GLYPH_SETTINGS');
  });
});
  • Step 2: Run the test to verify it fails

Run: cd extension && ./node_modules/.bin/vitest run src/vault/__tests__/sidebar-glyphs.test.ts Expected: FAIL — the emojis are still present, the GLYPH constants are not.

  • Step 3: Add the import to vault.ts

In extension/src/vault/vault.ts, add to the imports section (near the top, after other shared imports):

import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs';
  • Step 4: Replace the sidebar nav buttons

Find the block at lines 249-255 in vault.ts:

        <div class="vault-sidebar__nav">
          <button class="vault-sidebar__nav-item" data-nav="add">+ new item</button>
          <button class="vault-sidebar__nav-item" data-nav="trash">\u{1F5D1} trash</button>
          <button class="vault-sidebar__nav-item" data-nav="devices">\u{1F4F1} devices</button>
          <button class="vault-sidebar__nav-item" data-nav="settings"> settings</button>
          <button class="vault-sidebar__nav-item" data-nav="lock">\u{1F512} lock</button>
        </div>

Replace with:

        <div class="vault-sidebar__nav">
          <button class="vault-sidebar__nav-item" data-nav="add">+ new item</button>
          <button class="vault-sidebar__nav-item" data-nav="trash">${GLYPH_TRASH} trash</button>
          <button class="vault-sidebar__nav-item" data-nav="devices">${GLYPH_DEVICES} devices</button>
          <button class="vault-sidebar__nav-item" data-nav="settings">${GLYPH_SETTINGS} settings</button>
          <button class="vault-sidebar__nav-item" data-nav="lock">${GLYPH_LOCK} lock</button>
        </div>
  • Step 5: Run the test to verify it passes

Run: cd extension && ./node_modules/.bin/vitest run src/vault/__tests__/sidebar-glyphs.test.ts Expected: PASS, 4/4 tests green.

  • Step 6: Run the full suite + build
cd /home/alee/Sources/relicario/extension
./node_modules/.bin/vitest run 2>&1 | tail -5
./node_modules/.bin/webpack --mode production 2>&1 | tail -5

Expected: all tests pass; webpack compiles with 2 warnings.

  • Step 7: Commit
git -C /home/alee/Sources/relicario add extension/src/vault/vault.ts extension/src/vault/__tests__/sidebar-glyphs.test.ts
git -C /home/alee/Sources/relicario commit -m "style(ext/vault): replace sidebar emoji nav with monochrome glyphs

▦ trash · ⌬ devices · ⚙ settings · ⏻ lock — all imported from the new
shared/glyphs module so popup and fullscreen stay in sync. Regression
test scans the source for the old escape-coded emoji to prevent
backsliding.

Plan 2026-04-30 fullscreen UX phase 1 task 5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 6: Migrate popup settings nav glyphs

Files:

  • Modify: extension/src/popup/components/settings.ts:58-59

  • Step 1: Verify the existing emojis

Run: grep -n "🗑\|🔐" extension/src/popup/components/settings.ts Expected output (line 58 trash, line 59 devices):

58:        <button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">🗑️ Trash</button>
59:        <button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">🔐 Devices</button>
  • Step 2: Add the import

In extension/src/popup/components/settings.ts, add to the imports near the top:

import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
  • Step 3: Replace the buttons

Replace lines 58-59:

Before:

        <button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">🗑️ Trash</button>
        <button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">🔐 Devices</button>

After:

        <button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">${GLYPH_TRASH} trash</button>
        <button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button>

(Lowercased "trash" / "devices" to match the brand's lowercase aesthetic established in Phase 1.)

  • Step 4: Verify no emojis remain

Run: grep -n "🗑\|🔐\|🔒\|📺" extension/src/popup/components/settings.ts Expected: empty output.

  • Step 5: Run tests + build
cd /home/alee/Sources/relicario/extension
./node_modules/.bin/vitest run 2>&1 | tail -5
./node_modules/.bin/webpack --mode production 2>&1 | tail -5

Expected: all tests pass; webpack compiles with 2 warnings.

  • Step 6: Commit
git -C /home/alee/Sources/relicario add extension/src/popup/components/settings.ts
git -C /home/alee/Sources/relicario commit -m "style(ext/popup): replace settings nav emoji with shared glyphs

▦ trash and ⌬ devices in the popup settings panel now match the
fullscreen sidebar's glyph language. Lowercased labels match the brand.

Plan 2026-04-30 fullscreen UX phase 1 task 6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 7: Hide popout-to-tab button in fullscreen forms

Files (8 sites):

  • Modify: extension/src/popup/components/item-form.ts:61

  • Modify: extension/src/popup/components/types/card.ts:179

  • Modify: extension/src/popup/components/types/document.ts:90

  • Modify: extension/src/popup/components/types/identity.ts:139

  • Modify: extension/src/popup/components/types/key.ts:128

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

  • Modify: extension/src/popup/components/types/secure-note.ts:117

  • Modify: extension/src/popup/components/types/totp.ts:218

  • Step 1: Confirm isInTab() is exported and used

Run: grep -n "export.*isInTab\|import.*isInTab" extension/src/shared/state.ts extension/src/popup/components/types/login.ts Expected: state.ts exports isInTab; login.ts already imports it.

  • Step 2: Write a test for the login form behavior in fullscreen

Append to extension/src/popup/components/types/__tests__/required-pill.test.ts (or create a new file popout-button.test.ts next to it):

// Append to required-pill.test.ts

describe('popout-to-tab button visibility', () => {
  beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });

  it('renders the popout button when isInTab() is false (popup context)', async () => {
    // The default mock at the top of this file sets isInTab: () => false.
    // Re-render with that.
    const { renderForm } = await import('../login');
    renderForm(document.getElementById('app')!, 'add', null);
    expect(document.getElementById('popout-btn')).not.toBeNull();
  });
});

For the fullscreen variant (isInTab → true), add a separate test file because vi.mock is module-level. Create extension/src/popup/components/types/__tests__/popout-fullscreen.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('../../../../shared/state', () => ({
  sendMessage: vi.fn(),
  getState: () => ({ newType: 'login', generatorDefaults: null, error: null, loading: false, vaultSettings: null, entries: [] }),
  setState: vi.fn(),
  navigate: vi.fn(),
  escapeHtml: (s: string) => s,
  popOutToTab: vi.fn(),
  isInTab: () => true,        // FULLSCREEN context
  openVaultTab: vi.fn(),
  registerHost: vi.fn(),
}));

vi.mock('../../generator-panel', () => ({
  openGeneratorPanel: vi.fn(),
  closeGeneratorPanel: vi.fn(),
  isGeneratorPanelOpen: () => false,
}));

import { renderForm } from '../login';

describe('popout-to-tab button (fullscreen context)', () => {
  beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });

  it('does NOT render the popout button when isInTab() is true', () => {
    renderForm(document.getElementById('app')!, 'add', null);
    expect(document.getElementById('popout-btn')).toBeNull();
  });
});
  • Step 3: Run tests to verify the fullscreen test fails

Run: cd extension && ./node_modules/.bin/vitest run src/popup/components/types/__tests__/popout-fullscreen.test.ts Expected: FAIL — popout button is currently rendered unconditionally.

  • Step 4: Gate the popout button in login.ts

In extension/src/popup/components/types/login.ts, find line 249:

        <button class="btn" id="popout-btn" title="Open in tab"></button>

Replace with:

        ${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
  • Step 5: Repeat for the other seven files

Apply the same conditional wrap to each remaining popout button site. For each, the surrounding context is <button class="btn" id="popout-btn" title="Open in tab">⤴</button> — wrap that single line with the ternary.

For extension/src/popup/components/item-form.ts:61 (the type-selection screen's popout button), use the same pattern:

        ${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}

If isInTab is not already imported in a given file, add it to the existing import from ../../../shared/state (or ../../shared/state for item-form.ts).

After editing each file, also remove or guard the corresponding document.getElementById('popout-btn')?.addEventListener('click', popOutToTab); line — or leave it as-is since getElementById returns null and the optional-chain handles it. Leave the listener wiring untouched to keep the diff minimal; it's a no-op when the button isn't present.

  • Step 6: Run all popout tests + full suite
cd /home/alee/Sources/relicario/extension
./node_modules/.bin/vitest run 2>&1 | tail -8

Expected: all tests pass, including both popout-button and popout-fullscreen cases.

  • Step 7: Build to verify

Run: cd /home/alee/Sources/relicario/extension && ./node_modules/.bin/webpack --mode production 2>&1 | tail -5 Expected: compiled with 2 warnings.

  • Step 8: Commit
git -C /home/alee/Sources/relicario add extension/src/popup/
git -C /home/alee/Sources/relicario commit -m "feat(ext/popup): hide popout-to-tab button in fullscreen forms

The ⤴ popout button is meaningless when the form is already in
vault.html — gate it on !isInTab(). Affects all seven type forms plus
the type-selection screen. Regression tests cover both popup (button
present) and fullscreen (button absent) contexts.

Plan 2026-04-30 fullscreen UX phase 1 task 7.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 8: Static "esc to cancel" subtitle in fullscreen forms

Files:

  • Modify: same eight files as Task 7 (header markup region, ~3-4 lines above the popout button site)

  • Modify: extension/src/popup/styles.css (one new CSS class — shared, since the fullscreen inherits popup styles via vault's own stylesheet only loading vault.css)

  • Modify: extension/src/vault/vault.css (one new CSS class)

  • Step 1: Add the .form-subtitle CSS class to popup/styles.css

Append to extension/src/popup/styles.css (anywhere — group near .muted):

.form-subtitle {
  font-size: 11px;
  color: var(--text-dim);
  margin-top: 2px;
  margin-bottom: 14px;
  letter-spacing: 0.02em;
}
  • Step 2: Add the same class to vault.css

Append the identical .form-subtitle rule to extension/src/vault/vault.css.

  • Step 3: Write a test for the subtitle in fullscreen context

Append to extension/src/popup/components/types/__tests__/popout-fullscreen.test.ts:

describe('form subtitle (fullscreen context)', () => {
  beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });

  it('renders "esc to cancel" subtitle in the login form header', () => {
    renderForm(document.getElementById('app')!, 'add', null);
    const subtitle = document.querySelector('.form-subtitle');
    expect(subtitle).not.toBeNull();
    expect(subtitle?.textContent).toContain('esc to cancel');
  });
});

And add a negative test in required-pill.test.ts (popup context):

describe('form subtitle (popup context)', () => {
  beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });

  it('does NOT render the "esc to cancel" subtitle in popup context', async () => {
    const { renderForm } = await import('../login');
    renderForm(document.getElementById('app')!, 'add', null);
    expect(document.querySelector('.form-subtitle')).toBeNull();
  });
});
  • Step 4: Run tests to verify the fullscreen subtitle test fails

Run: cd extension && ./node_modules/.bin/vitest run src/popup/components/types/__tests__/popout-fullscreen.test.ts Expected: FAIL — no .form-subtitle element rendered today.

  • Step 5: Update login.ts header

In extension/src/popup/components/types/login.ts, find the header markup (lines 246-250):

      <div style="display:flex; align-items:center; margin-bottom:16px;">
        <div class="detail-title">${mode === 'add' ? 'new login' : 'edit login'}</div>
        <span style="flex:1;"></span>
        ${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
      </div>

Replace with:

      <div style="display:flex; align-items:center;">
        <div class="detail-title">${mode === 'add' ? 'new login' : 'edit login'}</div>
        <span style="flex:1;"></span>
        ${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
      </div>
      ${isInTab() ? '<div class="form-subtitle">esc to cancel</div>' : '<div style="margin-bottom:16px;"></div>'}

(The header's margin-bottom:16px moves to the conditional spacer so the subtitle gets to sit right under the title.)

  • Step 6: Run the test to verify it passes for login

Run: cd extension && ./node_modules/.bin/vitest run src/popup/components/types/__tests__/popout-fullscreen.test.ts src/popup/components/types/__tests__/required-pill.test.ts Expected: PASS — both fullscreen and popup variants of the subtitle test.

  • Step 7: Repeat for the remaining six type forms

Apply the same header restructuring to each of:

  • card.ts (around line 179)
  • document.ts (around line 90)
  • identity.ts (around line 139)
  • key.ts (around line 128)
  • secure-note.ts (around line 117)
  • totp.ts (around line 218)

For each, find the existing header <div> block that contains the title + popout button, and add the subtitle line below it using the same conditional pattern. The title text differs per type ("new identity" / "new card" etc.) — preserve whatever the current expression is.

For extension/src/popup/components/item-form.ts (the type-selection screen), apply the same pattern around line 60-63.

  • Step 8: Run the full suite + build
cd /home/alee/Sources/relicario/extension
./node_modules/.bin/vitest run 2>&1 | tail -5
./node_modules/.bin/webpack --mode production 2>&1 | tail -5

Expected: all tests pass; webpack compiles with 2 warnings.

  • Step 9: Commit
git -C /home/alee/Sources/relicario add extension/src/popup/ extension/src/vault/
git -C /home/alee/Sources/relicario commit -m "feat(ext): static 'esc to cancel' subtitle in fullscreen form headers

All seven type forms plus the type-selection screen now show a small
'esc to cancel' subtitle under the heading when rendered in the
fullscreen vault tab (isInTab() === true). The subtitle is suppressed
in the popup, where esc has the more general meaning of closing the
popup. .form-subtitle class is shared between popup and vault
stylesheets so future hooks can reuse it.

Dynamic dirty-state ('unsaved · esc to cancel') wiring is deferred to
Phase 3 (unsaved-changes guard).

Plan 2026-04-30 fullscreen UX phase 1 task 8.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Final verification

  • Run the full extension test suite one more time
cd /home/alee/Sources/relicario/extension
./node_modules/.bin/vitest run 2>&1 | tail -10

Expected: all tests pass (count = previous baseline + the new tests added by this plan).

  • Build all variants
cd /home/alee/Sources/relicario/extension
./node_modules/.bin/webpack --mode production 2>&1 | tail -5
./node_modules/.bin/webpack --config webpack.firefox.config.js --mode production 2>&1 | tail -5

Expected: both compile with 2 warnings.

  • Manual smoke test

Load the unpacked extension in Chrome:

  1. Open the popup: confirm sidebar settings panel shows ▦ trash / ⌬ devices (no emoji), required pill on title fields, focus ring is amber.
  2. Open vault.html: confirm sidebar shows ▦ trash · ⌬ devices · ⚙ settings · ⏻ lock, no popout button on the form header, "esc to cancel" subtitle visible under "new login".
  3. Tab through fields with keyboard: confirm focus ring renders consistently.

(If anything looks off, the symptom is almost certainly a CSS specificity issue — vault.css may need an !important or scoped selector. Note the issue and fix in a follow-up commit.)