feat(ext/popup): polish unlock view with logo lockup + glass card

Restructures the unlock screen so the form sits in a glass card with
a primary 'unlock vault' button. Logo, brand, and tagline are grouped
as a lockup. Open-vault and settings are demoted to secondary buttons.
Body gets the .surface-backdrop wrapper.
This commit is contained in:
adlee-was-taken
2026-05-02 14:21:04 -04:00
parent 308ef2c974
commit 7371eff0bb
4 changed files with 98 additions and 40 deletions

View File

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

View File

@@ -7,54 +7,63 @@ export function renderUnlock(app: HTMLElement): void {
const state = getState();
app.innerHTML = `
<div class="pad" style="text-align:center; padding-top:40px;">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<div class="brand">Relicario</div>
<p class="muted" style="margin:8px 0 24px;">two-factor vault</p>
<div class="form-group">
<input
type="password"
id="passphrase-input"
placeholder="passphrase"
autocomplete="off"
${state.loading ? 'disabled' : ''}
>
<div class="pad" style="text-align:center; padding-top:32px;">
<div class="logo-lockup" style="margin-bottom:24px;">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<div class="brand">Relicario</div>
<p class="tagline">two-factor vault</p>
</div>
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div style="margin-top:24px;">
<button class="btn" id="vault-btn" style="font-size:11px;">open vault</button>
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
<div class="glass" style="padding:16px; text-align:left; margin-bottom:16px;">
<div class="card-label" style="font-size:10px;text-transform:uppercase;letter-spacing:1.2px;color:var(--text-muted);margin-bottom:8px;">unlock</div>
<div class="form-group" style="margin-bottom:10px;">
<input
type="password"
id="passphrase-input"
placeholder="passphrase"
autocomplete="off"
${state.loading ? 'disabled' : ''}
>
</div>
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<button class="btn-primary" id="unlock-btn" style="width:100%;justify-content:center;" ${state.loading ? 'disabled' : ''}>unlock vault</button>
</div>
<div style="display:flex; gap:8px; justify-content:center;">
<button class="btn-secondary" id="vault-btn">open vault</button>
<button class="btn-secondary" id="settings-btn">settings</button>
</div>
</div>
`;
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'));
}

View File

@@ -6,7 +6,7 @@
<link rel="stylesheet" href="styles.css">
<title>Relicario</title>
</head>
<body>
<body class="surface-backdrop">
<div id="app"></div>
<script src="popup.js"></script>
</body>

View File

@@ -1550,3 +1550,7 @@ textarea {
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; }