diff --git a/docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md b/docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md index addd42c..08f555b 100644 --- a/docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md +++ b/docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md @@ -4,7 +4,7 @@ **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 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 `*` 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). +**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 `*` 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. @@ -39,8 +39,8 @@ describe('glyphs', () => { expect(glyphs.GLYPH_LOCK).toBe('⏻'); }); - it('exports REQUIRED_PILL as an HTML snippet', () => { - expect(glyphs.REQUIRED_PILL).toBe('required'); + it('exports REQUIRED_PILL_HTML as an HTML snippet', () => { + expect(glyphs.REQUIRED_PILL_HTML).toBe('required'); }); }); ``` @@ -74,8 +74,8 @@ 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: -/// `` -export const REQUIRED_PILL = 'required'; +/// `` +export const REQUIRED_PILL_HTML = 'required'; ``` - [ ] **Step 4: Run test to verify it passes** @@ -90,7 +90,7 @@ git -C /home/alee/Sources/relicario add extension/src/shared/glyphs.ts extension 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 +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. @@ -330,7 +330,7 @@ Co-Authored-By: Claude Opus 4.7 " --- -## Task 4: Migrate required-marker sites to REQUIRED_PILL +## 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` @@ -392,7 +392,7 @@ In `extension/src/popup/components/types/login.ts`: Add an import near the top (after the existing imports): ```typescript -import { REQUIRED_PILL } from '../../../shared/glyphs'; +import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; ``` Find line 252: @@ -402,7 +402,7 @@ Find line 252: Replace with: ```typescript -
+
``` - [ ] **Step 4: Run the test to verify it passes for login** @@ -413,8 +413,8 @@ 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 } from '../../../shared/glyphs';` -2. Replace each `*` with `${REQUIRED_PILL}` +1. Add `import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';` +2. Replace each `*` with `${REQUIRED_PILL_HTML}` | File | Line(s) | |---|---| @@ -444,10 +444,10 @@ Expected: `compiled with 2 warnings`. ```bash 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 +git -C /home/alee/Sources/relicario commit -m "refactor(ext/popup): migrate required-field markers to REQUIRED_PILL_HTML Replaces ten * sites across all seven type -forms with the shared REQUIRED_PILL snippet ('required' badge). Adds a +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. diff --git a/extension/src/popup/components/form-header.ts b/extension/src/popup/components/form-header.ts new file mode 100644 index 0000000..e3f92c2 --- /dev/null +++ b/extension/src/popup/components/form-header.ts @@ -0,0 +1,24 @@ +/// Shared header chrome for typed form views (login, secure-note, identity, card, +/// key, totp, document). Renders the title row plus a fullscreen-only "esc to +/// cancel" subtitle. Use the existing `${...}` template-literal interpolation +/// at call sites: `${renderFormHeader({ titleText: 'new login' })}`. +/// +/// item-form.ts (the type-selection screen) uses a different header structure +/// and does NOT consume this helper. + +import { isInTab } from '../../shared/state'; + +export interface FormHeaderOpts { + titleText: string; +} + +export function renderFormHeader(opts: FormHeaderOpts): string { + return ` +
+
${opts.titleText}
+ + ${isInTab() ? '' : ''} +
+ ${isInTab() ? '
esc to cancel
' : ''} + `; +} diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index 6f513c2..c9956ae 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -1,7 +1,7 @@ /// Typed-item add/edit form dispatcher. Each type's renderForm lives in /// its own module under ./types/. Document stays "coming soon" until γ. -import { navigate, getState, setState, escapeHtml, popOutToTab } from '../../shared/state'; +import { navigate, getState, setState, escapeHtml, popOutToTab, isInTab } from '../../shared/state'; import type { Item, ItemType } from '../../shared/types'; const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [ @@ -54,12 +54,13 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { function renderTypeSelection(app: HTMLElement): void { app.innerHTML = `
-
+

new item

- + ${isInTab() ? '' : ''}
+ ${isInTab() ? '
esc to cancel
' : '
'}
${TYPE_OPTIONS.map((opt) => `
- - + +
diff --git a/extension/src/popup/components/types/__tests__/_typed-forms.ts b/extension/src/popup/components/types/__tests__/_typed-forms.ts new file mode 100644 index 0000000..09918f5 --- /dev/null +++ b/extension/src/popup/components/types/__tests__/_typed-forms.ts @@ -0,0 +1,23 @@ +/// Shared list of typed-form (`renderForm`) entries for it.each parameterization +/// across regression tests. Add a new typed form here ONCE — popup + fullscreen +/// suites pick it up automatically. + +import * as login from '../login'; +import * as secureNote from '../secure-note'; +import * as identity from '../identity'; +import * as card from '../card'; +import * as key from '../key'; +import * as totp from '../totp'; +import * as documentType from '../document'; + +type RenderFn = (app: HTMLElement, mode: 'add' | 'edit', existing: null) => void; + +export const TYPED_FORMS: Array<[string, RenderFn]> = [ + ['login', login.renderForm], + ['secure-note', secureNote.renderForm], + ['identity', identity.renderForm], + ['card', card.renderForm], + ['key', key.renderForm], + ['totp', totp.renderForm], + ['document', documentType.renderForm], +]; diff --git a/extension/src/popup/components/types/__tests__/form-subtitle-fullscreen.test.ts b/extension/src/popup/components/types/__tests__/form-subtitle-fullscreen.test.ts new file mode 100644 index 0000000..e31ad8e --- /dev/null +++ b/extension/src/popup/components/types/__tests__/form-subtitle-fullscreen.test.ts @@ -0,0 +1,32 @@ +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 { TYPED_FORMS } from './_typed-forms'; + +describe('form subtitle (fullscreen context)', () => { + beforeEach(() => { document.body.innerHTML = '
'; }); + + it.each(TYPED_FORMS)('%s form renders "esc to cancel" subtitle in fullscreen', (_name, render) => { + render(document.getElementById('app')!, 'add', null); + const subtitle = document.querySelector('.form-subtitle'); + expect(subtitle).not.toBeNull(); + expect(subtitle?.textContent).toContain('esc to cancel'); + }); +}); diff --git a/extension/src/popup/components/types/__tests__/form-subtitle.test.ts b/extension/src/popup/components/types/__tests__/form-subtitle.test.ts new file mode 100644 index 0000000..33d679e --- /dev/null +++ b/extension/src/popup/components/types/__tests__/form-subtitle.test.ts @@ -0,0 +1,30 @@ +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, // POPUP context + openVaultTab: vi.fn(), + registerHost: vi.fn(), +})); + +vi.mock('../../generator-panel', () => ({ + openGeneratorPanel: vi.fn(), + closeGeneratorPanel: vi.fn(), + isGeneratorPanelOpen: () => false, +})); + +import { TYPED_FORMS } from './_typed-forms'; + +describe('form subtitle (popup context)', () => { + beforeEach(() => { document.body.innerHTML = '
'; }); + + it.each(TYPED_FORMS)('%s form omits the form-subtitle in popup context', (_name, render) => { + render(document.getElementById('app')!, 'add', null); + expect(document.querySelector('.form-subtitle')).toBeNull(); + }); +}); diff --git a/extension/src/popup/components/types/__tests__/popout-button-fullscreen.test.ts b/extension/src/popup/components/types/__tests__/popout-button-fullscreen.test.ts new file mode 100644 index 0000000..e7a6773 --- /dev/null +++ b/extension/src/popup/components/types/__tests__/popout-button-fullscreen.test.ts @@ -0,0 +1,30 @@ +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 { TYPED_FORMS } from './_typed-forms'; + +describe('popout-to-tab button (fullscreen context)', () => { + beforeEach(() => { document.body.innerHTML = '
'; }); + + it.each(TYPED_FORMS)('%s form does NOT render the popout button', (_name, render) => { + render(document.getElementById('app')!, 'add', null); + expect(document.getElementById('popout-btn')).toBeNull(); + }); +}); diff --git a/extension/src/popup/components/types/__tests__/popout-button.test.ts b/extension/src/popup/components/types/__tests__/popout-button.test.ts new file mode 100644 index 0000000..1ed8809 --- /dev/null +++ b/extension/src/popup/components/types/__tests__/popout-button.test.ts @@ -0,0 +1,30 @@ +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, // POPUP context + openVaultTab: vi.fn(), + registerHost: vi.fn(), +})); + +vi.mock('../../generator-panel', () => ({ + openGeneratorPanel: vi.fn(), + closeGeneratorPanel: vi.fn(), + isGeneratorPanelOpen: () => false, +})); + +import { TYPED_FORMS } from './_typed-forms'; + +describe('popout-to-tab button (popup context)', () => { + beforeEach(() => { document.body.innerHTML = '
'; }); + + it.each(TYPED_FORMS)('%s form renders the popout button', (_name, render) => { + render(document.getElementById('app')!, 'add', null); + expect(document.getElementById('popout-btn')).not.toBeNull(); + }); +}); diff --git a/extension/src/popup/components/types/__tests__/required-pill.test.ts b/extension/src/popup/components/types/__tests__/required-pill.test.ts new file mode 100644 index 0000000..70f0236 --- /dev/null +++ b/extension/src/popup/components/types/__tests__/required-pill.test.ts @@ -0,0 +1,35 @@ +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 { TYPED_FORMS } from './_typed-forms'; + +describe('required-pill migration', () => { + beforeEach(() => { document.body.innerHTML = '
'; }); + + it.each(TYPED_FORMS)('%s form has no legacy markup', (_name, render) => { + render(document.getElementById('app')!, 'add', null); + expect(document.body.innerHTML).not.toContain('class="req"'); + }); + + it.each(TYPED_FORMS)('%s form contains the .req-pill markup on at least one label', (_name, render) => { + render(document.getElementById('app')!, 'add', null); + expect(document.body.innerHTML).toContain('class="req-pill">required'); + }); +}); diff --git a/extension/src/popup/components/types/card.ts b/extension/src/popup/components/types/card.ts index 60fc280..9413011 100644 --- a/extension/src/popup/components/types/card.ts +++ b/extension/src/popup/components/types/card.ts @@ -2,6 +2,8 @@ /// Detail view has a styled card-silhouette signature block. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; +import { renderFormHeader } from '../form-header'; +import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, CardKind, Section, AttachmentRef } from '../../../shared/types'; import { renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections, @@ -173,13 +175,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite app.innerHTML = `
-
-
${mode === 'add' ? 'new card' : 'edit card'}
- - -
+ ${renderFormHeader({ titleText: mode === 'add' ? 'new card' : 'edit card' })} ${state.error ? `
${escapeHtml(state.error)}
` : ''} -
+
diff --git a/extension/src/popup/components/types/document.ts b/extension/src/popup/components/types/document.ts index 7a25597..ba4fdab 100644 --- a/extension/src/popup/components/types/document.ts +++ b/extension/src/popup/components/types/document.ts @@ -3,6 +3,8 @@ /// Primary attachment is referenced by ID from the item's attachments array. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; +import { renderFormHeader } from '../form-header'; +import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types'; import { renderSectionsEditor, wireSectionsEditor, @@ -84,18 +86,14 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite app.innerHTML = `
-
-
${isEdit ? 'edit document' : 'new document'}
- - -
+ ${renderFormHeader({ titleText: isEdit ? 'edit document' : 'new document' })} ${state.error ? `
${escapeHtml(state.error)}
` : ''}
- +
- + ${renderPrimary()}
diff --git a/extension/src/popup/components/types/identity.ts b/extension/src/popup/components/types/identity.ts index 0f5330b..f9776c1 100644 --- a/extension/src/popup/components/types/identity.ts +++ b/extension/src/popup/components/types/identity.ts @@ -2,6 +2,8 @@ /// Detail view shows a "profile card" signature block + plain rows. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; +import { renderFormHeader } from '../form-header'; +import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types'; import { renderRow, renderSignatureBlock, wireFieldHandlers, renderSections, @@ -133,13 +135,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite app.innerHTML = `
-
-
${mode === 'add' ? 'new identity' : 'edit identity'}
- - -
+ ${renderFormHeader({ titleText: mode === 'add' ? 'new identity' : 'edit identity' })} ${state.error ? `
${escapeHtml(state.error)}
` : ''} -
+
diff --git a/extension/src/popup/components/types/key.ts b/extension/src/popup/components/types/key.ts index 50215c9..36cb3ba 100644 --- a/extension/src/popup/components/types/key.ts +++ b/extension/src/popup/components/types/key.ts @@ -3,6 +3,8 @@ /// since diff --git a/extension/src/popup/components/types/login.ts b/extension/src/popup/components/types/login.ts index b0feb52..16f2308 100644 --- a/extension/src/popup/components/types/login.ts +++ b/extension/src/popup/components/types/login.ts @@ -2,6 +2,8 @@ /// field helpers introduced in Slice 2. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../../shared/state'; +import { renderFormHeader } from '../form-header'; +import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; import type { Item, ItemId, LoginCore, ManifestEntry, Section, TotpConfig, AttachmentRef } from '../../../shared/types'; import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types'; import { base32Decode, base32Encode } from '../../../shared/base32'; @@ -243,13 +245,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite app.innerHTML = `
-
-
${mode === 'add' ? 'new login' : 'edit login'}
- - -
+ ${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })} ${state.error ? `
${escapeHtml(state.error)}
` : ''} -
+
diff --git a/extension/src/popup/components/types/secure-note.ts b/extension/src/popup/components/types/secure-note.ts index 1ecc788..ff33d4d 100644 --- a/extension/src/popup/components/types/secure-note.ts +++ b/extension/src/popup/components/types/secure-note.ts @@ -2,6 +2,8 @@ /// detail view; the form is just a big
diff --git a/extension/src/popup/components/types/totp.ts b/extension/src/popup/components/types/totp.ts index ef5edcb..abfa6af 100644 --- a/extension/src/popup/components/types/totp.ts +++ b/extension/src/popup/components/types/totp.ts @@ -3,6 +3,8 @@ /// (TOTP vs Steam Guard) and a single secret input. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; +import { renderFormHeader } from '../form-header'; +import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, Section, TotpKind, AttachmentRef } from '../../../shared/types'; import { base32Decode, base32Encode } from '../../../shared/base32'; import { @@ -212,13 +214,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite const renderInner = (): string => `
-
-
${mode === 'add' ? 'new totp' : 'edit totp'}
- - -
+ ${renderFormHeader({ titleText: mode === 'add' ? 'new totp' : 'edit totp' })} ${state.error ? `
${escapeHtml(state.error)}
` : ''} -
+
@@ -227,7 +225,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite

${formKind === 'steam' ? 'Steam Mobile Authenticator (5-char alphanumeric)' : 'Standard time-based codes (6 digits)'}

-
+
diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 362e3ae..146e72a 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -1,5 +1,31 @@ /* 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; @@ -55,10 +81,18 @@ body { margin-bottom: 4px; } -.label .req { - color: #aa812a; - 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; } .secondary { @@ -70,6 +104,26 @@ body { font-size: 11px; } +.form-header { + display: flex; + align-items: center; + margin-bottom: 16px; +} + +/* When the header is followed by a subtitle, drop the bottom margin so the + subtitle's own margin owns the spacing. */ +.form-header:has(+ .form-subtitle) { + margin-bottom: 0; +} + +.form-subtitle { + font-size: 11px; + color: var(--text-dim); + margin-top: 2px; + margin-bottom: 14px; + letter-spacing: 0.02em; +} + .error { color: #ab2b20; font-size: 12px; @@ -94,9 +148,9 @@ body { background: #30363d; } -.btn:focus { - outline: 1px solid #d2ab43; - outline-offset: 1px; +.btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); } .btn-primary { @@ -133,8 +187,10 @@ input, textarea, select { transition: border-color 0.15s; } -input:focus, textarea:focus, select:focus { - border-color: #d2ab43; +input:focus-visible, textarea:focus-visible, select:focus-visible { + border-color: var(--accent); + box-shadow: var(--focus-ring); + outline: none; } input::placeholder, textarea::placeholder { diff --git a/extension/src/shared/__tests__/glyphs.test.ts b/extension/src/shared/__tests__/glyphs.test.ts new file mode 100644 index 0000000..e0a6d78 --- /dev/null +++ b/extension/src/shared/__tests__/glyphs.test.ts @@ -0,0 +1,21 @@ +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('required'); + }); +}); diff --git a/extension/src/shared/glyphs.ts b/extension/src/shared/glyphs.ts new file mode 100644 index 0000000..0266a89 --- /dev/null +++ b/extension/src/shared/glyphs.ts @@ -0,0 +1,25 @@ +// +// 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: +/// `` +/// +/// Requires the `.req-pill` CSS rule (added in Tasks 2 and 3 to popup/styles.css +/// and vault/vault.css respectively). +export const REQUIRED_PILL_HTML = 'required'; diff --git a/extension/src/vault/__tests__/sidebar-glyphs.test.ts b/extension/src/vault/__tests__/sidebar-glyphs.test.ts new file mode 100644 index 0000000..1800bcd --- /dev/null +++ b/extension/src/vault/__tests__/sidebar-glyphs.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +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'); + }); +}); diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 6184035..5340a73 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -1,5 +1,31 @@ /* relicario vault — terminal dark theme (tab layout) */ +: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; @@ -55,10 +81,18 @@ body { margin-bottom: 4px; } -.label .req { - color: #aa812a; - 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; } .secondary { @@ -70,6 +104,26 @@ body { font-size: 11px; } +.form-header { + display: flex; + align-items: center; + margin-bottom: 16px; +} + +/* When the header is followed by a subtitle, drop the bottom margin so the + subtitle's own margin owns the spacing. */ +.form-header:has(+ .form-subtitle) { + margin-bottom: 0; +} + +.form-subtitle { + font-size: 11px; + color: var(--text-dim); + margin-top: 2px; + margin-bottom: 14px; + letter-spacing: 0.02em; +} + .error { color: #ab2b20; font-size: 12px; @@ -94,9 +148,9 @@ body { background: #30363d; } -.btn:focus { - outline: 1px solid #d2ab43; - outline-offset: 1px; +.btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); } .btn-primary { @@ -133,8 +187,10 @@ input, textarea, select { transition: border-color 0.15s; } -input:focus, textarea:focus, select:focus { - border-color: #d2ab43; +input:focus-visible, textarea:focus-visible, select:focus-visible { + border-color: var(--accent); + box-shadow: var(--focus-ring); + outline: none; } input::placeholder, textarea::placeholder { diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 32f4d0d..77e8af8 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -9,6 +9,7 @@ import type { ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest, } from '../shared/types'; import { registerHost } from '../shared/state'; +import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs'; import { renderItemDetail } from '../popup/components/item-detail'; import { renderItemForm } from '../popup/components/item-form'; import { renderTrash, teardown as teardownTrash } from '../popup/components/trash'; @@ -248,10 +249,10 @@ function renderShell(app: HTMLElement): void {
- - - - + + + +