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:
@@ -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.
|
||||||
|
|||||||
24
extension/src/popup/components/form-header.ts
Normal file
24
extension/src/popup/components/form-header.ts
Normal 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>' : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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}">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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],
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
21
extension/src/shared/__tests__/glyphs.test.ts
Normal file
21
extension/src/shared/__tests__/glyphs.test.ts
Normal 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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
25
extension/src/shared/glyphs.ts
Normal file
25
extension/src/shared/glyphs.ts
Normal 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>';
|
||||||
29
extension/src/vault/__tests__/sidebar-glyphs.test.ts
Normal file
29
extension/src/vault/__tests__/sidebar-glyphs.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user