Merge feature/fullscreen-ux-phase-1: Phase 1 visual foundation

14 commits establishing the shared visual language for the fullscreen UX
redesign:

- New shared/glyphs.ts (10 monochrome glyph constants + REQUIRED_PILL_HTML).
- Color tokens (:root vars), :focus-visible ring, .req-pill, .form-header,
  .form-subtitle in both popup/styles.css and vault/vault.css (kept identical).
- All 10 required-marker sites migrated from <span class="req">*</span> to
  REQUIRED_PILL_HTML across the 7 type forms.
- Sidebar nav emoji replaced with glyph constants (vault sidebar + popup
  settings panel).
- Popout-to-tab button gated on !isInTab() across 8 form files.
- Static "esc to cancel" subtitle below fullscreen form headers (suppressed
  in popup); .form-header CSS owns spacing via :has(+ .form-subtitle).
- renderFormHeader({ titleText }) shared helper consumed by all 7 type forms.
- TYPED_FORMS shared list parameterizes 5 it.each test files for automatic
  coverage of any new typed form.

268/268 tests pass; webpack production build clean. Foundation for Phase 2
(smart inputs), Phase 3 (three-pane shell + keymap + unsaved guard), and
Phase 4 (command palette + multi-select + drag-drop).

Plan: docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md
Spec: docs/superpowers/specs/2026-04-30-relicario-fullscreen-ux-redesign-design.md
This commit is contained in:
adlee-was-taken
2026-05-01 14:36:36 -04:00
23 changed files with 465 additions and 85 deletions

View File

@@ -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. **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 `<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). **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. **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('⏻'); expect(glyphs.GLYPH_LOCK).toBe('⏻');
}); });
it('exports REQUIRED_PILL as an HTML snippet', () => { it('exports REQUIRED_PILL_HTML as an HTML snippet', () => {
expect(glyphs.REQUIRED_PILL).toBe('<span class="req-pill">required</span>'); expect(glyphs.REQUIRED_PILL_HTML).toBe('<span class="req-pill">required</span>');
}); });
}); });
``` ```
@@ -74,8 +74,8 @@ export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
export const GLYPH_LOCK = '⏻'; // sidebar lock nav export const GLYPH_LOCK = '⏻'; // sidebar lock nav
/// Inline HTML snippet for the required-field pill. Use after a label's text: /// Inline HTML snippet for the required-field pill. Use after a label's text:
/// `<label class="label" for="f-title">title ${REQUIRED_PILL}</label>` /// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`
export const REQUIRED_PILL = '<span class="req-pill">required</span>'; export const REQUIRED_PILL_HTML = '<span class="req-pill">required</span>';
``` ```
- [ ] **Step 4: Run test to verify it passes** - [ ] **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 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 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. snippet used to replace the trailing-asterisk required-field marker.
Plan 2026-04-30 fullscreen UX phase 1 task 1. Plan 2026-04-30 fullscreen UX phase 1 task 1.
@@ -330,7 +330,7 @@ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
--- ---
## 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):** **Files (10 sites across 7 files):**
- Modify: `extension/src/popup/components/types/card.ts:182` - 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): Add an import near the top (after the existing imports):
```typescript ```typescript
import { REQUIRED_PILL } from '../../../shared/glyphs'; import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
``` ```
Find line 252: Find line 252:
@@ -402,7 +402,7 @@ Find line 252:
Replace with: Replace with:
```typescript ```typescript
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL}</label> <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** - [ ] **Step 4: Run the test to verify it passes for login**
@@ -413,8 +413,8 @@ Expected: PASS.
- [ ] **Step 5: Migrate the remaining six files** - [ ] **Step 5: Migrate the remaining six files**
Apply the same pattern to each of these six files. For each: Apply the same pattern to each of these six files. For each:
1. Add `import { REQUIRED_PILL } from '../../../shared/glyphs';` 1. Add `import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';`
2. Replace each `<span class="req">*</span>` with `${REQUIRED_PILL}` 2. Replace each `<span class="req">*</span>` with `${REQUIRED_PILL_HTML}`
| File | Line(s) | | File | Line(s) |
|---|---| |---|---|
@@ -444,10 +444,10 @@ Expected: `compiled with 2 warnings`.
```bash ```bash
git -C /home/alee/Sources/relicario add extension/src/popup/components/types/ extension/src/shared/ 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 <span class=\"req\">*</span> sites across all seven type Replaces ten <span class=\"req\">*</span> 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. regression test pinning the new HTML in the login form.
Plan 2026-04-30 fullscreen UX phase 1 task 4. Plan 2026-04-30 fullscreen UX phase 1 task 4.

View File

@@ -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 `
<div class="form-header">
<div class="detail-title">${opts.titleText}</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>' : ''}
`;
}

View File

@@ -1,7 +1,7 @@
/// Typed-item add/edit form dispatcher. Each type's renderForm lives in /// Typed-item add/edit form dispatcher. Each type's renderForm lives in
/// its own module under ./types/. Document stays "coming soon" until γ. /// 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'; import type { Item, ItemType } from '../../shared/types';
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [ 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 { function renderTypeSelection(app: HTMLElement): void {
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div style="display:flex; align-items:center; gap:12px; margin-bottom:16px;"> <div style="display:flex; align-items:center; gap:12px;">
<button class="btn" id="back-btn">← back</button> <button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">new item</h3> <h3 style="margin:0;">new item</h3>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button> ${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
</div> </div>
${isInTab() ? '<div class="form-subtitle">esc to cancel</div>' : '<div style="margin-bottom:16px;"></div>'}
<div class="type-select-list"> <div class="type-select-list">
${TYPE_OPTIONS.map((opt) => ` ${TYPE_OPTIONS.map((opt) => `
<button class="type-select-row" data-type="${opt.type}"> <button class="type-select-row" data-type="${opt.type}">

View File

@@ -2,6 +2,7 @@
import { sendMessage, navigate, escapeHtml } from '../../shared/state'; import { sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { DeviceSettings } from '../../shared/types'; import type { DeviceSettings } from '../../shared/types';
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
export async function renderSettings(app: HTMLElement): Promise<void> { export async function renderSettings(app: HTMLElement): Promise<void> {
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>'; app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
@@ -55,8 +56,8 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
</div> </div>
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">🗑️ Trash</button> <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;">🔐 Devices</button> <button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button>
<button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">📤 Sync now</button> <button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">📤 Sync now</button>
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div> <div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
</div> </div>

View File

@@ -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],
];

View File

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

View File

@@ -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 = '<div id="app"></div>'; });
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();
});
});

View File

@@ -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 = '<div id="app"></div>'; });
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();
});
});

View File

@@ -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 = '<div id="app"></div>'; });
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();
});
});

View File

@@ -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 = '<div id="app"></div>'; });
it.each(TYPED_FORMS)('%s form has no legacy <span class="req"> 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</span>');
});
});

View File

@@ -2,6 +2,8 @@
/// Detail view has a styled card-silhouette signature block. /// Detail view has a styled card-silhouette signature block.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; 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 type { Item, ItemId, ManifestEntry, CardKind, Section, AttachmentRef } from '../../../shared/types';
import { import {
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
@@ -173,13 +175,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div style="display:flex; align-items:center; margin-bottom:16px;"> ${renderFormHeader({ titleText: mode === 'add' ? 'new card' : 'edit card' })}
<div class="detail-title">${mode === 'add' ? 'new card' : 'edit card'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label> <div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Amex Gold"></div> <input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Amex Gold"></div>
<div class="form-group"><label class="label" for="f-number">number</label> <div class="form-group"><label class="label" for="f-number">number</label>
<input id="f-number" type="text" inputmode="numeric" value="${escapeHtml(c?.number ?? '')}" placeholder="378282246310005"></div> <input id="f-number" type="text" inputmode="numeric" value="${escapeHtml(c?.number ?? '')}" placeholder="378282246310005"></div>

View File

@@ -3,6 +3,8 @@
/// Primary attachment is referenced by ID from the item's attachments array. /// Primary attachment is referenced by ID from the item's attachments array.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; 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 type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import { import {
renderSectionsEditor, wireSectionsEditor, renderSectionsEditor, wireSectionsEditor,
@@ -84,18 +86,14 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div style="display:flex; align-items:center; margin-bottom:16px;"> ${renderFormHeader({ titleText: isEdit ? 'edit document' : 'new document' })}
<div class="detail-title">${isEdit ? 'edit document' : 'new document'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"> <div class="form-group">
<label class="label" for="f-title">title <span class="req">*</span></label> <label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
<input id="f-title" type="text" value="${escapeHtml(existing?.title ?? '')}" placeholder="Passport, Lease, etc."> <input id="f-title" type="text" value="${escapeHtml(existing?.title ?? '')}" placeholder="Passport, Lease, etc.">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="label">primary attachment <span class="req">*</span></label> <label class="label">primary attachment ${REQUIRED_PILL_HTML}</label>
${renderPrimary()} ${renderPrimary()}
<input type="file" id="primary-file-input" hidden /> <input type="file" id="primary-file-input" hidden />
</div> </div>

View File

@@ -2,6 +2,8 @@
/// Detail view shows a "profile card" signature block + plain rows. /// Detail view shows a "profile card" signature block + plain rows.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; 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 type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import { import {
renderRow, renderSignatureBlock, wireFieldHandlers, renderSections, renderRow, renderSignatureBlock, wireFieldHandlers, renderSections,
@@ -133,13 +135,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div style="display:flex; align-items:center; margin-bottom:16px;"> ${renderFormHeader({ titleText: mode === 'add' ? 'new identity' : 'edit identity' })}
<div class="detail-title">${mode === 'add' ? 'new identity' : 'edit identity'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label> <div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Aaron Lee · personal"></div> <input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Aaron Lee · personal"></div>
<div class="form-group"><label class="label" for="f-full-name">full name</label> <div class="form-group"><label class="label" for="f-full-name">full name</label>
<input id="f-full-name" type="text" value="${escapeHtml(c?.full_name ?? '')}" placeholder="Aaron Lee"></div> <input id="f-full-name" type="text" value="${escapeHtml(c?.full_name ?? '')}" placeholder="Aaron Lee"></div>

View File

@@ -3,6 +3,8 @@
/// since <textarea type="password"> isn't a thing. /// since <textarea type="password"> isn't a thing.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; 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 type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import { import {
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections, renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
@@ -122,15 +124,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div style="display:flex; align-items:center; margin-bottom:16px;"> ${renderFormHeader({ titleText: mode === 'add' ? 'new key' : 'edit key' })}
<div class="detail-title">${mode === 'add' ? 'new key' : 'edit key'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label> <div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="github ssh"></div> <input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="github ssh"></div>
<div class="form-group"><label class="label" for="f-key-material">key material <span class="req">*</span></label> <div class="form-group"><label class="label" for="f-key-material">key material ${REQUIRED_PILL_HTML}</label>
<div style="position:relative;"> <div style="position:relative;">
<textarea id="f-key-material" rows="8" style="font-family:monospace;-webkit-text-security:disc;" placeholder="paste key here">${escapeHtml(c?.key_material ?? '')}</textarea> <textarea id="f-key-material" rows="8" style="font-family:monospace;-webkit-text-security:disc;" placeholder="paste key here">${escapeHtml(c?.key_material ?? '')}</textarea>
<button type="button" id="key-show-btn" class="btn" style="position:absolute;right:6px;top:6px;font-size:10px;padding:2px 8px;">show</button> <button type="button" id="key-show-btn" class="btn" style="position:absolute;right:6px;top:6px;font-size:10px;padding:2px 8px;">show</button>

View File

@@ -2,6 +2,8 @@
/// field helpers introduced in Slice 2. /// field helpers introduced in Slice 2.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../../shared/state'; 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 type { Item, ItemId, LoginCore, ManifestEntry, Section, TotpConfig, AttachmentRef } from '../../../shared/types';
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types'; import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32'; import { base32Decode, base32Encode } from '../../../shared/base32';
@@ -243,13 +245,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div style="display:flex; align-items:center; margin-bottom:16px;"> ${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
<div class="detail-title">${mode === 'add' ? 'new login' : 'edit login'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label> <div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div> <input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
<div class="form-group"><label class="label" for="f-url">url</label> <div class="form-group"><label class="label" for="f-url">url</label>
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login"></div> <input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login"></div>

View File

@@ -2,6 +2,8 @@
/// detail view; the form is just a big <textarea>. /// detail view; the form is just a big <textarea>.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../../shared/state'; 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, ManifestEntry, Section, AttachmentRef } from '../../../shared/types'; import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import { import {
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
@@ -111,13 +113,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div style="display:flex; align-items:center; margin-bottom:16px;"> ${renderFormHeader({ titleText: mode === 'add' ? 'new secure note' : 'edit secure note' })}
<div class="detail-title">${mode === 'add' ? 'new secure note' : 'edit secure note'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label> <div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="My recovery codes"></div> <input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="My recovery codes"></div>
<div class="form-group"><label class="label" for="f-body">body</label> <div class="form-group"><label class="label" for="f-body">body</label>
<textarea id="f-body" rows="10" placeholder="paste secrets here">${escapeHtml(body)}</textarea></div> <textarea id="f-body" rows="10" placeholder="paste secrets here">${escapeHtml(body)}</textarea></div>

View File

@@ -3,6 +3,8 @@
/// (TOTP vs Steam Guard) and a single secret input. /// (TOTP vs Steam Guard) and a single secret input.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; 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 type { Item, ItemId, ManifestEntry, Section, TotpKind, AttachmentRef } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32'; import { base32Decode, base32Encode } from '../../../shared/base32';
import { import {
@@ -212,13 +214,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
const renderInner = (): string => ` const renderInner = (): string => `
<div class="pad"> <div class="pad">
<div style="display:flex; align-items:center; margin-bottom:16px;"> ${renderFormHeader({ titleText: mode === 'add' ? 'new totp' : 'edit totp' })}
<div class="detail-title">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label> <div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div> <input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
<div class="form-group"><label class="label">kind</label> <div class="form-group"><label class="label">kind</label>
<div class="inline-row"> <div class="inline-row">
@@ -227,7 +225,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
</div> </div>
<p class="muted" style="font-size:11px;margin-top:4px;" id="kind-blurb">${formKind === 'steam' ? 'Steam Mobile Authenticator (5-char alphanumeric)' : 'Standard time-based codes (6 digits)'}</p> <p class="muted" style="font-size:11px;margin-top:4px;" id="kind-blurb">${formKind === 'steam' ? 'Steam Mobile Authenticator (5-char alphanumeric)' : 'Standard time-based codes (6 digits)'}</p>
</div> </div>
<div class="form-group"><label class="label" for="f-secret">secret (base32) <span class="req">*</span></label> <div class="form-group"><label class="label" for="f-secret">secret (base32) ${REQUIRED_PILL_HTML}</label>
<input id="f-secret" type="text" value="${escapeHtml(secretB32)}" placeholder="JBSWY3DPEHPK3PXP"></div> <input id="f-secret" type="text" value="${escapeHtml(secretB32)}" placeholder="JBSWY3DPEHPK3PXP"></div>
<div class="form-group"><label class="label" for="f-issuer">issuer</label> <div class="form-group"><label class="label" for="f-issuer">issuer</label>
<input id="f-issuer" type="text" value="${escapeHtml(c?.issuer ?? '')}" placeholder="GitHub"></div> <input id="f-issuer" type="text" value="${escapeHtml(c?.issuer ?? '')}" placeholder="GitHub"></div>

View File

@@ -1,5 +1,31 @@
/* relicario extension — terminal dark theme */ /* 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; margin: 0;
padding: 0; padding: 0;
@@ -55,10 +81,18 @@ body {
margin-bottom: 4px; margin-bottom: 4px;
} }
.label .req { .req-pill {
color: #aa812a; display: inline-block;
margin-left: 2px; font-size: 9px;
font-weight: 600; 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 { .secondary {
@@ -70,6 +104,26 @@ body {
font-size: 11px; 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 { .error {
color: #ab2b20; color: #ab2b20;
font-size: 12px; font-size: 12px;
@@ -94,9 +148,9 @@ body {
background: #30363d; background: #30363d;
} }
.btn:focus { .btn:focus-visible {
outline: 1px solid #d2ab43; outline: none;
outline-offset: 1px; box-shadow: var(--focus-ring);
} }
.btn-primary { .btn-primary {
@@ -133,8 +187,10 @@ input, textarea, select {
transition: border-color 0.15s; transition: border-color 0.15s;
} }
input:focus, textarea:focus, select:focus { input:focus-visible, textarea:focus-visible, select:focus-visible {
border-color: #d2ab43; border-color: var(--accent);
box-shadow: var(--focus-ring);
outline: none;
} }
input::placeholder, textarea::placeholder { input::placeholder, textarea::placeholder {

View File

@@ -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('<span class="req-pill">required</span>');
});
});

View File

@@ -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:
/// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`
///
/// 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 = '<span class="req-pill">required</span>';

View File

@@ -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');
});
});

View File

@@ -1,5 +1,31 @@
/* relicario vault — terminal dark theme (tab layout) */ /* 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; margin: 0;
padding: 0; padding: 0;
@@ -55,10 +81,18 @@ body {
margin-bottom: 4px; margin-bottom: 4px;
} }
.label .req { .req-pill {
color: #aa812a; display: inline-block;
margin-left: 2px; font-size: 9px;
font-weight: 600; 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 { .secondary {
@@ -70,6 +104,26 @@ body {
font-size: 11px; 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 { .error {
color: #ab2b20; color: #ab2b20;
font-size: 12px; font-size: 12px;
@@ -94,9 +148,9 @@ body {
background: #30363d; background: #30363d;
} }
.btn:focus { .btn:focus-visible {
outline: 1px solid #d2ab43; outline: none;
outline-offset: 1px; box-shadow: var(--focus-ring);
} }
.btn-primary { .btn-primary {
@@ -133,8 +187,10 @@ input, textarea, select {
transition: border-color 0.15s; transition: border-color 0.15s;
} }
input:focus, textarea:focus, select:focus { input:focus-visible, textarea:focus-visible, select:focus-visible {
border-color: #d2ab43; border-color: var(--accent);
box-shadow: var(--focus-ring);
outline: none;
} }
input::placeholder, textarea::placeholder { input::placeholder, textarea::placeholder {

View File

@@ -9,6 +9,7 @@ import type {
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest, ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
} from '../shared/types'; } from '../shared/types';
import { registerHost } from '../shared/state'; 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 { renderItemDetail } from '../popup/components/item-detail';
import { renderItemForm } from '../popup/components/item-form'; import { renderItemForm } from '../popup/components/item-form';
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash'; import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
@@ -248,10 +249,10 @@ function renderShell(app: HTMLElement): void {
<div class="vault-sidebar__list" id="vault-sidebar-list"></div> <div class="vault-sidebar__list" id="vault-sidebar-list"></div>
<div class="vault-sidebar__nav"> <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="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="trash">${GLYPH_TRASH} trash</button>
<button class="vault-sidebar__nav-item" data-nav="devices">\u{1F4F1} devices</button> <button class="vault-sidebar__nav-item" data-nav="devices">${GLYPH_DEVICES} devices</button>
<button class="vault-sidebar__nav-item" data-nav="settings"> settings</button> <button class="vault-sidebar__nav-item" data-nav="settings">${GLYPH_SETTINGS} settings</button>
<button class="vault-sidebar__nav-item" data-nav="lock">\u{1F512} lock</button> <button class="vault-sidebar__nav-item" data-nav="lock">${GLYPH_LOCK} lock</button>
</div> </div>
</div> </div>
<div class="vault-pane vault-pane--empty" id="vault-pane"> <div class="vault-pane vault-pane--empty" id="vault-pane">