diff --git a/extension/src/popup/components/__tests__/unlock.test.ts b/extension/src/popup/components/__tests__/unlock.test.ts new file mode 100644 index 0000000..4b3c590 --- /dev/null +++ b/extension/src/popup/components/__tests__/unlock.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderUnlock } from '../unlock'; + +vi.mock('../../../shared/state', () => ({ + getState: () => ({ loading: false, error: null }), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + openVaultTab: vi.fn(), +})); + +describe('renderUnlock', () => { + let app: HTMLElement; + beforeEach(() => { + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + }); + + it('renders the logo lockup (logo + brand + tagline)', () => { + renderUnlock(app); + expect(app.querySelector('.brand-logo')).toBeTruthy(); + expect(app.querySelector('.brand')?.textContent).toBe('Relicario'); + expect(app.querySelector('.tagline')?.textContent).toContain('two-factor'); + }); + + it('renders the unlock form inside a .glass card', () => { + renderUnlock(app); + const glass = app.querySelector('.glass'); + expect(glass).toBeTruthy(); + expect(glass!.querySelector('#passphrase-input')).toBeTruthy(); + expect(glass!.querySelector('.btn-primary')).toBeTruthy(); + }); + + it('renders open-vault and settings as secondary buttons outside the card', () => { + renderUnlock(app); + const vaultBtn = app.querySelector('#vault-btn'); + const settingsBtn = app.querySelector('#settings-btn'); + expect(vaultBtn?.classList.contains('btn-secondary')).toBe(true); + expect(settingsBtn?.classList.contains('btn-secondary')).toBe(true); + // They should NOT be inside the .glass card + const glass = app.querySelector('.glass'); + expect(glass!.contains(vaultBtn!)).toBe(false); + }); +}); diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index c9956ae..0460c7c 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -41,7 +41,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { const type: ItemType = existing?.type ?? state.newType ?? 'login'; switch (type) { - case 'login': return login.renderForm(app, mode, existing); + case 'login': return login.renderForm(app, mode, existing, { surface: isInTab() ? 'fullscreen' : 'popup', externalActions: isInTab() }); case 'secure_note': return secureNote.renderForm(app, mode, existing); case 'identity': return identity.renderForm(app, mode, existing); case 'card': return card.renderForm(app, mode, existing); diff --git a/extension/src/popup/components/settings-vault.ts b/extension/src/popup/components/settings-vault.ts index 84e7935..288c032 100644 --- a/extension/src/popup/components/settings-vault.ts +++ b/extension/src/popup/components/settings-vault.ts @@ -7,6 +7,7 @@ import type { VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, } from '../../shared/types'; import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel'; +import { GLYPH_NEXT } from '../../shared/glyphs'; let pendingSettings: VaultSettings | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; @@ -161,14 +162,14 @@ export function renderVaultSettings(app: HTMLElement): void {two-factor vault
-two-factor vault
How are you using Relicario on this device?
Use your existing passphrase and reference image to attach this browser @@ -430,7 +433,7 @@ function renderStep1(): string { `; return ` -
This helps you identify which devices have access to your vault. @@ -918,7 +921,7 @@ function renderStep4(): string {
diff --git a/extension/src/shared/__tests__/glyphs.test.ts b/extension/src/shared/__tests__/glyphs.test.ts index e0a6d78..957c28c 100644 --- a/extension/src/shared/__tests__/glyphs.test.ts +++ b/extension/src/shared/__tests__/glyphs.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; import * as glyphs from '../glyphs'; +import { + GLYPH_REVEAL, GLYPH_HIDE, GLYPH_GENERATE, GLYPH_FILL_FROM_TAB, + GLYPH_QR, GLYPH_MONO, GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, + GLYPH_LOCK, GLYPH_NEXT, +} from '../glyphs'; describe('glyphs', () => { it('exports the documented glyph constants', () => { @@ -19,3 +24,20 @@ describe('glyphs', () => { expect(glyphs.REQUIRED_PILL_HTML).toBe('required'); }); }); + +describe('glyph constants', () => { + it('uses single unicode codepoints (no emoji multi-codepoint)', () => { + const all = [ + GLYPH_REVEAL, GLYPH_HIDE, GLYPH_GENERATE, GLYPH_FILL_FROM_TAB, + GLYPH_QR, GLYPH_MONO, GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, + GLYPH_LOCK, GLYPH_NEXT, + ]; + for (const g of all) { + expect([...g].length).toBe(1); + } + }); + + it('GLYPH_NEXT is the small right triangle (U+25B8)', () => { + expect(GLYPH_NEXT).toBe('▸'); + }); +}); diff --git a/extension/src/shared/glyphs.ts b/extension/src/shared/glyphs.ts index 0266a89..a69e3fb 100644 --- a/extension/src/shared/glyphs.ts +++ b/extension/src/shared/glyphs.ts @@ -16,6 +16,7 @@ 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 +export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family) /// Inline HTML snippet for the required-field pill. Use after a label's text: /// `` diff --git a/extension/src/vault/__tests__/form-wrapper.test.ts b/extension/src/vault/__tests__/form-wrapper.test.ts new file mode 100644 index 0000000..791c34d --- /dev/null +++ b/extension/src/vault/__tests__/form-wrapper.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('fullscreen form dirty subtitle', () => { + const vaultSrc = fs.readFileSync( + path.resolve(__dirname, '../vault.ts'), + 'utf-8', + ); + + it('contains renderFormWrapped function', () => { + expect(vaultSrc).toContain('function renderFormWrapped'); + }); + + it('starts pristine: renders "no changes" subtitle', () => { + expect(vaultSrc).toContain("'no changes'"); + }); + + it('switches to dirty on first input event', () => { + expect(vaultSrc).toContain("'unsaved · esc to cancel'"); + }); + + it('listens on input and change events on the scroll element', () => { + expect(vaultSrc).toContain("scrollEl.addEventListener('input', markDirty, true)"); + expect(vaultSrc).toContain("scrollEl.addEventListener('change', markDirty, true)"); + }); + + it('marks clean on save', () => { + expect(vaultSrc).toContain('markClean()'); + }); + + it('contains platform-aware SAVE_HINT', () => { + expect(vaultSrc).toContain('SAVE_HINT'); + expect(vaultSrc).toContain('⌘+S to save'); + expect(vaultSrc).toContain('Ctrl+S to save'); + }); + + it('renders fullscreen-form-header element', () => { + expect(vaultSrc).toContain('fullscreen-form-header'); + }); + + it('renders form-dirty-sub element', () => { + expect(vaultSrc).toContain('form-dirty-sub'); + }); +}); diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 3dce143..1cfb456 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -1,22 +1,35 @@ /* Relicario vault — terminal dark theme (tab layout) */ :root { - /* Brand */ - --accent: #d2ab43; - --accent-soft: rgba(210, 171, 67, 0.18); - --accent-strong: #aa812a; + /* Patina gold (Phase 2B) */ + --gold-base: #a88a4a; + --gold-mid: #cdb47a; + --gold-shadow: #5a3f12; + --gold-text: #c9a868; + --gold-soft: rgba(184, 149, 86, 0.14); + --gold-ring: rgba(184, 149, 86, 0.18); + --gold-stroke: #b89556; + --gold-hi-end: #dac8a0; + + /* Brand alias (kept for backwards compatibility) */ + --accent: var(--gold-base); + --accent-soft: var(--gold-soft); + --accent-strong: var(--gold-shadow); /* Surfaces */ - --bg-page: #0d1117; - --bg-pane: #161b22; - --bg-elevated: #21262d; - --bg-input: #161b22; - --border-subtle: #30363d; + --bg-page: #0a0e14; + --bg-pane: #11161e; + --bg-elevated: #1c2330; + --bg-card: rgba(22, 27, 34, 0.55); + --bg-input: #0a0e14; + --border-soft: rgba(255, 255, 255, 0.05); + --border-mid: #262d36; + --border-subtle: var(--border-mid); /* Text */ --text: #c9d1d9; --text-muted: #8b949e; - --text-dim: #484f58; + --text-dim: #6b7888; /* Status */ --danger: #ab2b20; @@ -24,7 +37,7 @@ --success: #6cb37a; /* Focus */ - --focus-ring: 0 0 0 2px rgba(210, 171, 67, 0.35); + --focus-ring: 0 0 0 2px var(--gold-ring); } * { @@ -34,7 +47,7 @@ } body { - background: #0d1117; + background: var(--bg-page); color: #c9d1d9; font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace; font-size: 13px; @@ -62,7 +75,7 @@ body { .brand { font-size: 16px; font-weight: 700; - color: #d2ab43; + color: var(--gold-text); letter-spacing: 1px; } @@ -1487,3 +1500,167 @@ textarea { .f-notes--mono { font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important; } + +/* Phase 2B: surface backdrop — subtle radial top-glow + grid texture. + Apply to body or a top-level wrapper. Children must sit above the ::before. */ +.surface-backdrop { + position: relative; + background: + radial-gradient(ellipse 700px 240px at 50% -40px, rgba(184, 149, 86, 0.05), transparent 65%), + linear-gradient(180deg, #11161e 0%, #0a0e14 100%); +} +.surface-backdrop::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.012) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.012) 1px, transparent 1px); + background-size: 18px 18px; + pointer-events: none; + z-index: 0; +} +.surface-backdrop > * { + position: relative; + z-index: 1; +} + +/* Phase 2B: glass card. Translucent panel with backdrop blur for the + unlock card, setup step card, and form section panels. Falls back + gracefully on browsers without backdrop-filter (just stays translucent). */ +.glass { + background: var(--bg-card); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--border-soft); + border-radius: 10px; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.03) inset, + 0 6px 18px rgba(0, 0, 0, 0.35); +} + +/* Phase 2B: button hierarchy. Existing .btn class kept for backwards + compatibility; .btn-primary and .btn-secondary express clearer intent + and are used in updated views. */ +.btn-primary { + background: var(--gold-base); + color: var(--bg-page); + border: none; + padding: 9px 14px; + font-size: 12px; + font-weight: 600; + border-radius: 6px; + font-family: inherit; + cursor: pointer; + letter-spacing: 0.3px; + display: inline-flex; + align-items: center; + gap: 8px; + transition: background-color 0.15s; +} +.btn-primary:hover { background: var(--gold-stroke); } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-primary:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.btn-secondary { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.06); + color: var(--text-muted); + padding: 6px 12px; + font-size: 11px; + border-radius: 5px; + font-family: inherit; + cursor: pointer; +} +.btn-secondary:hover { border-color: rgba(255, 255, 255, 0.12); color: var(--text); } +.btn-secondary:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +/* Phase 2B: two-column form grid for fullscreen login */ +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + max-width: 960px; + margin: 0 auto; +} +@media (max-width: 720px) { + .form-grid { grid-template-columns: 1fr; } +} +.form-col { + padding: 14px 16px; +} +.col-header { + text-transform: uppercase; + letter-spacing: 1.2px; + font-weight: 500; + color: var(--text-muted); + font-size: 10px; + border-bottom: 1px solid var(--border-mid); + padding-bottom: 6px; + margin-bottom: 12px; +} + +/* Phase 2B: fullscreen form header */ +.fullscreen-form-header { + padding: 14px 24px; + border-bottom: 1px solid var(--border-mid); + display: flex; + justify-content: space-between; + align-items: flex-start; +} +.fullscreen-form-header .title { + font-size: 16px; + font-weight: 500; + color: var(--text); +} +.fullscreen-form-header .sub { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} +.fullscreen-form-header .hint { + font-size: 11px; + color: var(--text-dim); +} + +/* Phase 2B: sticky save bar + scrollable form pane */ +.form-pane { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} +.form-scroll { + flex: 1; + overflow-y: auto; + padding: 20px 24px; +} +.sticky-save-bar { + position: sticky; + bottom: 0; + background: rgba(17, 22, 30, 0.7); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border-top: 1px solid var(--border-mid); + padding: 12px 24px; + display: flex; + justify-content: flex-end; + gap: 8px; + z-index: 10; +} +.sticky-save-bar::before { + content: ''; + position: absolute; + top: -24px; + left: 0; + right: 0; + height: 24px; + background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent); + pointer-events: none; +} diff --git a/extension/src/vault/vault.html b/extension/src/vault/vault.html index 8e12efb..c9b4817 100644 --- a/extension/src/vault/vault.html +++ b/extension/src/vault/vault.html @@ -5,7 +5,7 @@