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 {
backup & restore
- +
import
- +
diff --git a/extension/src/popup/components/types/__tests__/login.test.ts b/extension/src/popup/components/types/__tests__/login.test.ts index 50400bb..7077847 100644 --- a/extension/src/popup/components/types/__tests__/login.test.ts +++ b/extension/src/popup/components/types/__tests__/login.test.ts @@ -63,6 +63,40 @@ describe('login form smart inputs', () => { }); }); +describe('renderForm surface flag', () => { + let app: HTMLElement; + beforeEach(() => { + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + (globalThis as any).chrome = { + storage: { + local: { + get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})), + set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()), + }, + }, + runtime: { + sendMessage: vi.fn(), + }, + }; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { groups: [] } }); + }); + + it('renders single-column when surface is "popup" (default)', () => { + renderForm(app, 'add', null); + expect(app.querySelector('.form-grid')).toBeNull(); + }); + + it('renders two-column .form-grid wrapper when surface is "fullscreen"', () => { + renderForm(app, 'add', null, { surface: 'fullscreen' }); + const grid = app.querySelector('.form-grid'); + expect(grid).toBeTruthy(); + expect(grid!.querySelector('[data-form-section="identity"]')).toBeTruthy(); + expect(grid!.querySelector('[data-form-section="credentials"]')).toBeTruthy(); + }); +}); + describe('Login save shape', () => { beforeEach(() => { document.body.innerHTML = '
'; diff --git a/extension/src/popup/components/types/login.ts b/extension/src/popup/components/types/login.ts index aff7dff..f21bef7 100644 --- a/extension/src/popup/components/types/login.ts +++ b/extension/src/popup/components/types/login.ts @@ -235,7 +235,20 @@ function startTotpTicker(id: ItemId): void { // Form (add / edit) // ---------------------------------------------------------------------- -export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void { +export interface RenderFormOptions { + surface?: 'popup' | 'fullscreen'; + /** When true, renderForm skips its own save/cancel buttons (caller provides them in a sticky bar). */ + externalActions?: boolean; +} + +export function renderForm( + app: HTMLElement, + mode: 'add' | 'edit', + existing: Item | null, + opts: RenderFormOptions = {} +): void { + const surface = opts.surface ?? 'popup'; + const externalActions = opts.externalActions ?? false; const state = getState(); const existingCore = (existing?.core.type === 'login') ? (existing.core as LoginCore & { type: 'login' }) @@ -254,58 +267,86 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite : []; let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? []; + const titleFieldHtml = ` +
+
`; + + const urlFieldHtml = ` +
+ +
+ + +
+ +
`; + + const groupFieldHtml = ` +
+
`; + + const usernameFieldHtml = ` +
+
`; + + const passwordFieldHtml = ` +
+ +
+ + + +
+ +
`; + + const totpFieldHtml = ` +
+ +
+ + +
+ + +
`; + + const identityHtml = ` +
+ ${surface === 'fullscreen' ? '
Identity
' : ''} + ${titleFieldHtml} + ${urlFieldHtml} + ${groupFieldHtml} +
`; + + const credentialsHtml = ` +
+ ${surface === 'fullscreen' ? '
Credentials
' : ''} + ${usernameFieldHtml} + ${passwordFieldHtml} + ${totpFieldHtml} +
`; + + const sectionsHtml = surface === 'fullscreen' + ? `
${identityHtml}${credentialsHtml}
` + : `${identityHtml}${credentialsHtml}`; + app.innerHTML = `
- ${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })} + ${surface === 'popup' ? renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' }) : ''} ${state.error ? `
${escapeHtml(state.error)}
` : ''} -
-
- -
- -
- - -
- -
- -
-
- -
- -
- - - -
- -
- -
- -
- - -
- - -
- -
-
+ ${sectionsHtml}
@@ -317,7 +358,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite ${renderSectionsEditor(sectionsDraft, sectionsExpanded)} ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''} -
+
@@ -433,7 +474,7 @@ function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; e } } -async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise { +export async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise { const state = getState(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value; diff --git a/extension/src/popup/components/unlock.ts b/extension/src/popup/components/unlock.ts index dfcf21c..2073a11 100644 --- a/extension/src/popup/components/unlock.ts +++ b/extension/src/popup/components/unlock.ts @@ -7,54 +7,63 @@ export function renderUnlock(app: HTMLElement): void { const state = getState(); app.innerHTML = ` -
- -
Relicario
-

two-factor vault

-
- +
+
+ +
Relicario
+

two-factor vault

- ${state.loading ? '
' : ''} - ${state.error ? `
${escapeHtml(state.error)}
` : ''} -
- - + +
+
unlock
+
+ +
+ ${state.loading ? '
' : ''} + ${state.error ? `
${escapeHtml(state.error)}
` : ''} + +
+ +
+ +
`; const input = document.getElementById('passphrase-input') as HTMLInputElement; + const unlockBtn = document.getElementById('unlock-btn') as HTMLButtonElement | null; + + const submit = async () => { + const passphrase = input.value; + if (!passphrase) return; + setState({ loading: true, error: null }); + const resp = await sendMessage({ type: 'unlock', passphrase }); + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items }); + } else { + setState({ loading: false, error: listResp.error }); + } + } else { + setState({ loading: false, error: resp.error }); + } + }; + if (input && !state.loading) { input.focus(); - input.addEventListener('keydown', async (e) => { - if (e.key === 'Enter') { - const passphrase = input.value; - if (!passphrase) return; - setState({ loading: true, error: null }); - const resp = await sendMessage({ type: 'unlock', passphrase }); - if (resp.ok) { - const listResp = await sendMessage({ type: 'list_items' }); - if (listResp.ok) { - const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; - navigate('list', { entries: data.items }); - } else { - setState({ loading: false, error: listResp.error }); - } - } else { - setState({ loading: false, error: resp.error }); - } - } - }); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); } + unlockBtn?.addEventListener('click', submit); document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab()); - - const settingsBtn = document.getElementById('settings-btn'); - settingsBtn?.addEventListener('click', () => navigate('settings')); + document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings')); } diff --git a/extension/src/popup/index.html b/extension/src/popup/index.html index 8e3f018..0175394 100644 --- a/extension/src/popup/index.html +++ b/extension/src/popup/index.html @@ -6,7 +6,7 @@ Relicario - +
diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index cf99e92..d41fcd9 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -1,22 +1,35 @@ /* Relicario extension — terminal dark theme */ :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); } * { @@ -37,7 +50,7 @@ body { width: 360px; max-height: 500px; overflow-y: auto; - 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; } @@ -1457,3 +1470,87 @@ 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); +} + +.logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; } +.logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; } +.tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; } diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index 99eab89..bd49f55 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -16,6 +16,7 @@ import { STRENGTH_LABELS, entropyText, } from './setup-helpers'; +import { GLYPH_NEXT } from '../shared/glyphs'; import type { VaultConfig } from '../shared/types'; import type { SessionHandle } from 'relicario-wasm'; @@ -189,12 +190,14 @@ function render(): void { } app.innerHTML = ` -
- -
Relicario vault setup
- ${progressHtml} - ${state.error ? `
${escapeHtml(state.error)}
` : ''} - ${stepHtml} +
+
+ +
Relicario vault setup
+ ${progressHtml} + ${state.error ? `
${escapeHtml(state.error)}
` : ''} + ${stepHtml} +
`; @@ -214,20 +217,20 @@ function renderStep0(): string { const isNew = state.mode === 'new'; const isAttach = state.mode === 'attach'; return ` -
+

set up Relicario

How are you using Relicario on this device?

- -
- +
`; @@ -267,7 +270,7 @@ function renderStep3Attach(): string { const gateDisabled = state.attaching || !p || !hasImage; return ` -
+

attach this device

Use your existing passphrase and reference image to attach this browser @@ -430,7 +433,7 @@ function renderStep1(): string { `; return ` -

+

choose host

@@ -442,7 +445,7 @@ function renderStep1(): string { ${state.hostType === 'gitea' ? giteaInstructions : githubInstructions}
- +
`; @@ -522,7 +525,7 @@ function renderStep2(): string { !!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists)); const nextDisabled = !state.connectionTested || !probe || modeMismatch; return ` -
+

configure connection

@@ -543,7 +546,7 @@ function renderStep2(): string { ${renderProbeBanner()}
- +
`; @@ -643,7 +646,7 @@ function renderStep3New(): string { const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`; return ` -
+

create vault

@@ -907,7 +910,7 @@ function renderStep4(): string { const defaultName = state.deviceName || `${browser} on ${os}`; return ` -
+

name this device

This helps you identify which devices have access to your vault. @@ -918,7 +921,7 @@ function renderStep4(): string {

- +
`; @@ -979,7 +982,7 @@ function renderStep5(): string { const isAttach = state.mode === 'attach'; return ` -
+

${isAttach ? 'device verified' : 'vault created'}

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 @@ Relicario — vault - +

diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index f90449e..4f0bb92 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -415,6 +415,71 @@ async function selectItem(id: ItemId): Promise { } } +// --------------------------------------------------------------------------- +// Platform-aware save hint +// --------------------------------------------------------------------------- + +const isMac = navigator.platform.toLowerCase().includes('mac'); +const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save'; + +// --------------------------------------------------------------------------- +// Fullscreen form wrapper — sticky save bar + scrollable content + header +// --------------------------------------------------------------------------- + +function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void { + const itemType = state.selectedItem?.type ?? state.newType ?? 'login'; + const typeLabel = itemType.replace('_', ' '); + const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`; + const wrapper = document.createElement('div'); + wrapper.className = 'form-pane'; + wrapper.innerHTML = ` +
+
+
${titleText}
+
no changes
+
+
${SAVE_HINT}
+
+
+
+ + +
+ `; + // Remove pane padding so form-pane can fill height cleanly + app.style.padding = '0'; + app.style.overflow = 'hidden'; + app.replaceChildren(wrapper); + + const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement; + renderItemForm(scrollEl, mode); + + const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement; + let isDirty = false; + const markDirty = () => { + if (isDirty) return; + isDirty = true; + subEl.textContent = 'unsaved · esc to cancel'; + }; + const markClean = () => { + isDirty = false; + subEl.textContent = 'no changes'; + }; + scrollEl.addEventListener('input', markDirty, true); + scrollEl.addEventListener('change', markDirty, true); + + wrapper.querySelector('#form-cancel')?.addEventListener('click', () => { + markClean(); + (scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click(); + }); + wrapper.querySelector('#form-save')?.addEventListener('click', () => { + markClean(); + (scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click(); + }); +} + +export const __test__ = { renderFormWrapped }; + // --------------------------------------------------------------------------- // Pane rendering — delegates to shared popup components // --------------------------------------------------------------------------- @@ -453,10 +518,16 @@ function renderPane(): void { // set by the type-selection click handler (which calls setState → // renderPane before the URL hash has been updated to include the type). state.newType = (route.type as ItemType) ?? state.newType ?? null; - renderItemForm(pane, 'add'); + // Use the form wrapper (sticky bar + header) when a type is already chosen. + // Without a type the type-selection screen renders — no sticky bar needed. + if (state.newType) { + renderFormWrapped(pane, 'add'); + } else { + renderItemForm(pane, 'add'); + } break; case 'edit': - renderItemForm(pane, 'edit'); + renderFormWrapped(pane, 'edit'); break; case 'trash': renderTrash(pane);