Merge feature/phase-2b-polish: polish foundation + form layout

This commit is contained in:
adlee-was-taken
2026-05-02 15:10:03 -04:00
15 changed files with 688 additions and 142 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

@@ -41,7 +41,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
const type: ItemType = existing?.type ?? state.newType ?? 'login'; const type: ItemType = existing?.type ?? state.newType ?? 'login';
switch (type) { 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 'secure_note': return secureNote.renderForm(app, mode, existing);
case 'identity': return identity.renderForm(app, mode, existing); case 'identity': return identity.renderForm(app, mode, existing);
case 'card': return card.renderForm(app, mode, existing); case 'card': return card.renderForm(app, mode, existing);

View File

@@ -7,6 +7,7 @@ import type {
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
} from '../../shared/types'; } from '../../shared/types';
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel'; import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
import { GLYPH_NEXT } from '../../shared/glyphs';
let pendingSettings: VaultSettings | null = null; let pendingSettings: VaultSettings | null = null;
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
@@ -161,14 +162,14 @@ export function renderVaultSettings(app: HTMLElement): void {
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__title">backup &amp; restore</div> <div class="settings-section__title">backup &amp; restore</div>
<div class="settings-row"> <div class="settings-row">
<button class="btn" id="open-backup">Backup &amp; restore </button> <button class="btn" id="open-backup">Backup &amp; restore ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__title">import</div> <div class="settings-section__title">import</div>
<div class="settings-row"> <div class="settings-row">
<button class="btn" id="open-import">LastPass CSV </button> <button class="btn" id="open-import">LastPass CSV ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>

View File

@@ -63,6 +63,40 @@ describe('login form smart inputs', () => {
}); });
}); });
describe('renderForm surface flag', () => {
let app: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
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', () => { describe('Login save shape', () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>'; document.body.innerHTML = '<div id="app"></div>';

View File

@@ -235,7 +235,20 @@ function startTotpTicker(id: ItemId): void {
// Form (add / edit) // 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 state = getState();
const existingCore = (existing?.core.type === 'login') const existingCore = (existing?.core.type === 'login')
? (existing.core as LoginCore & { type: 'login' }) ? (existing.core as LoginCore & { type: 'login' })
@@ -254,14 +267,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
: []; : [];
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? []; let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
app.innerHTML = ` const titleFieldHtml = `
<div class="pad">
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</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>`;
const urlFieldHtml = `
<div class="form-group"> <div class="form-group">
<label class="label" for="f-url">url</label> <label class="label" for="f-url">url</label>
<div class="inline-row"> <div class="inline-row">
@@ -269,11 +279,17 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button> <button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
</div> </div>
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div> <div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
</div> </div>`;
const groupFieldHtml = `
<div class="form-group"><label class="label" for="f-group">group</label>
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>`;
const usernameFieldHtml = `
<div class="form-group"><label class="label" for="f-username">username</label> <div class="form-group"><label class="label" for="f-username">username</label>
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div> <input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>`;
const passwordFieldHtml = `
<div class="form-group"> <div class="form-group">
<label class="label" for="f-password">password</label> <label class="label" for="f-password">password</label>
<div class="inline-row"> <div class="inline-row">
@@ -285,8 +301,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div> <div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
<div class="strength-label"></div> <div class="strength-label"></div>
</div> </div>
</div> </div>`;
const totpFieldHtml = `
<div class="form-group"> <div class="form-group">
<label class="label" for="f-totp">totp secret (base32)</label> <label class="label" for="f-totp">totp secret (base32)</label>
<div class="inline-row"> <div class="inline-row">
@@ -302,10 +319,34 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div> <div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
<div id="totp-qr-error" class="totp-qr-error"></div> <div id="totp-qr-error" class="totp-qr-error"></div>
</div> </div>
</div> </div>`;
<div class="form-group"><label class="label" for="f-group">group</label> const identityHtml = `
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div> <div data-form-section="identity" class="${surface === 'fullscreen' ? 'glass form-col' : ''}">
${surface === 'fullscreen' ? '<div class="col-header">Identity</div>' : ''}
${titleFieldHtml}
${urlFieldHtml}
${groupFieldHtml}
</div>`;
const credentialsHtml = `
<div data-form-section="credentials" class="${surface === 'fullscreen' ? 'glass form-col' : ''}">
${surface === 'fullscreen' ? '<div class="col-header">Credentials</div>' : ''}
${usernameFieldHtml}
${passwordFieldHtml}
${totpFieldHtml}
</div>`;
const sectionsHtml = surface === 'fullscreen'
? `<div class="form-grid">${identityHtml}${credentialsHtml}</div>`
: `${identityHtml}${credentialsHtml}`;
app.innerHTML = `
<div class="pad">
${surface === 'popup' ? renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' }) : ''}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
${sectionsHtml}
<div class="form-group"> <div class="form-group">
<div class="notes-with-toggle"> <div class="notes-with-toggle">
@@ -317,7 +358,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
${renderSectionsEditor(sectionsDraft, sectionsExpanded)} ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''} ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
<div class="form-actions"> <div class="form-actions" ${externalActions ? 'hidden' : ''}>
<button class="btn" id="cancel-btn">cancel</button> <button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button> <button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
</div> </div>
@@ -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<void> { export async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
const state = getState(); const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value; const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;

View File

@@ -7,11 +7,16 @@ export function renderUnlock(app: HTMLElement): void {
const state = getState(); const state = getState();
app.innerHTML = ` app.innerHTML = `
<div class="pad" style="text-align:center; padding-top:40px;"> <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=""> <img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<div class="brand">Relicario</div> <div class="brand">Relicario</div>
<p class="muted" style="margin:8px 0 24px;">two-factor vault</p> <p class="tagline">two-factor vault</p>
<div class="form-group"> </div>
<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 <input
type="password" type="password"
id="passphrase-input" id="passphrase-input"
@@ -22,18 +27,20 @@ export function renderUnlock(app: HTMLElement): void {
</div> </div>
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''} ${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div style="margin-top:24px;"> <button class="btn-primary" id="unlock-btn" style="width:100%;justify-content:center;" ${state.loading ? 'disabled' : ''}>unlock vault</button>
<button class="btn" id="vault-btn" style="font-size:11px;">open vault</button> </div>
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
<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>
</div> </div>
`; `;
const input = document.getElementById('passphrase-input') as HTMLInputElement; const input = document.getElementById('passphrase-input') as HTMLInputElement;
if (input && !state.loading) { const unlockBtn = document.getElementById('unlock-btn') as HTMLButtonElement | null;
input.focus();
input.addEventListener('keydown', async (e) => { const submit = async () => {
if (e.key === 'Enter') {
const passphrase = input.value; const passphrase = input.value;
if (!passphrase) return; if (!passphrase) return;
setState({ loading: true, error: null }); setState({ loading: true, error: null });
@@ -49,12 +56,14 @@ export function renderUnlock(app: HTMLElement): void {
} else { } else {
setState({ loading: false, error: resp.error }); setState({ loading: false, error: resp.error });
} }
};
if (input && !state.loading) {
input.focus();
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
} }
}); unlockBtn?.addEventListener('click', submit);
}
document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab()); document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab());
document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings'));
const settingsBtn = document.getElementById('settings-btn');
settingsBtn?.addEventListener('click', () => navigate('settings'));
} }

View File

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

View File

@@ -1,22 +1,35 @@
/* Relicario extension — terminal dark theme */ /* Relicario extension — terminal dark theme */
:root { :root {
/* Brand */ /* Patina gold (Phase 2B) */
--accent: #d2ab43; --gold-base: #a88a4a;
--accent-soft: rgba(210, 171, 67, 0.18); --gold-mid: #cdb47a;
--accent-strong: #aa812a; --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 */ /* Surfaces */
--bg-page: #0d1117; --bg-page: #0a0e14;
--bg-pane: #161b22; --bg-pane: #11161e;
--bg-elevated: #21262d; --bg-elevated: #1c2330;
--bg-input: #161b22; --bg-card: rgba(22, 27, 34, 0.55);
--border-subtle: #30363d; --bg-input: #0a0e14;
--border-soft: rgba(255, 255, 255, 0.05);
--border-mid: #262d36;
--border-subtle: var(--border-mid);
/* Text */ /* Text */
--text: #c9d1d9; --text: #c9d1d9;
--text-muted: #8b949e; --text-muted: #8b949e;
--text-dim: #484f58; --text-dim: #6b7888;
/* Status */ /* Status */
--danger: #ab2b20; --danger: #ab2b20;
@@ -24,7 +37,7 @@
--success: #6cb37a; --success: #6cb37a;
/* Focus */ /* 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; width: 360px;
max-height: 500px; max-height: 500px;
overflow-y: auto; overflow-y: auto;
background: #0d1117; background: var(--bg-page);
color: #c9d1d9; color: #c9d1d9;
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace; font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace;
font-size: 13px; font-size: 13px;
@@ -62,7 +75,7 @@ body {
.brand { .brand {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: #d2ab43; color: var(--gold-text);
letter-spacing: 1px; letter-spacing: 1px;
} }
@@ -1457,3 +1470,87 @@ textarea {
.f-notes--mono { .f-notes--mono {
font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important; 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; }

View File

@@ -16,6 +16,7 @@ import {
STRENGTH_LABELS, STRENGTH_LABELS,
entropyText, entropyText,
} from './setup-helpers'; } from './setup-helpers';
import { GLYPH_NEXT } from '../shared/glyphs';
import type { VaultConfig } from '../shared/types'; import type { VaultConfig } from '../shared/types';
import type { SessionHandle } from 'relicario-wasm'; import type { SessionHandle } from 'relicario-wasm';
@@ -189,6 +190,7 @@ function render(): void {
} }
app.innerHTML = ` app.innerHTML = `
<div class="surface-backdrop" style="min-height:100vh;">
<div class="pad" style="padding-top:12px;"> <div class="pad" style="padding-top:12px;">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="" style="margin-bottom:12px;"> <img class="brand-logo" src="icons/relicario-logo.svg" alt="" style="margin-bottom:12px;">
<div class="brand" style="margin-bottom:4px;">Relicario vault setup</div> <div class="brand" style="margin-bottom:4px;">Relicario vault setup</div>
@@ -196,6 +198,7 @@ function render(): void {
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
${stepHtml} ${stepHtml}
</div> </div>
</div>
`; `;
switch (state.step) { switch (state.step) {
@@ -214,20 +217,20 @@ function renderStep0(): string {
const isNew = state.mode === 'new'; const isNew = state.mode === 'new';
const isAttach = state.mode === 'attach'; const isAttach = state.mode === 'attach';
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>set up Relicario</h3> <h3>set up Relicario</h3>
<p class="muted" style="margin-bottom:16px;"> <p class="muted" style="margin-bottom:16px;">
How are you using Relicario on this device? How are you using Relicario on this device?
</p> </p>
<div class="mode-cards"> <div class="mode-cards">
<button class="mode-card ${isNew ? 'active' : ''}" data-mode="new"> <button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
<div class="mode-card-title">create new vault</div> <div class="mode-card-title">create new vault</div>
<p class="mode-card-blurb"> <p class="mode-card-blurb">
I'm setting up Relicario for the first time. This will create a fresh I'm setting up Relicario for the first time. This will create a fresh
encrypted vault on a new or empty git repository. encrypted vault on a new or empty git repository.
</p> </p>
</button> </button>
<button class="mode-card ${isAttach ? 'active' : ''}" data-mode="attach"> <button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
<div class="mode-card-title">attach this device</div> <div class="mode-card-title">attach this device</div>
<p class="mode-card-blurb"> <p class="mode-card-blurb">
I already have a vault on another device. Connect this browser to it I already have a vault on another device. Connect this browser to it
@@ -236,7 +239,7 @@ function renderStep0(): string {
</button> </button>
</div> </div>
<div class="form-actions" style="margin-top:24px;"> <div class="form-actions" style="margin-top:24px;">
<button class="btn btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next</button> <button class="btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
`; `;
@@ -267,7 +270,7 @@ function renderStep3Attach(): string {
const gateDisabled = state.attaching || !p || !hasImage; const gateDisabled = state.attaching || !p || !hasImage;
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>attach this device</h3> <h3>attach this device</h3>
<p class="muted" style="margin-bottom:12px;"> <p class="muted" style="margin-bottom:12px;">
Use your existing passphrase and reference image to attach this browser Use your existing passphrase and reference image to attach this browser
@@ -430,7 +433,7 @@ function renderStep1(): string {
`; `;
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>choose host</h3> <h3>choose host</h3>
<div class="form-group"> <div class="form-group">
<label class="label">host type</label> <label class="label">host type</label>
@@ -442,7 +445,7 @@ function renderStep1(): string {
${state.hostType === 'gitea' ? giteaInstructions : githubInstructions} ${state.hostType === 'gitea' ? giteaInstructions : githubInstructions}
<div class="form-actions"> <div class="form-actions">
<button class="btn" id="back-btn">back</button> <button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="next-btn">next</button> <button class="btn-primary" id="next-btn">next ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
`; `;
@@ -522,7 +525,7 @@ function renderStep2(): string {
!!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists)); !!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists));
const nextDisabled = !state.connectionTested || !probe || modeMismatch; const nextDisabled = !state.connectionTested || !probe || modeMismatch;
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>configure connection</h3> <h3>configure connection</h3>
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}> <div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
<label class="label" for="host-url">host url</label> <label class="label" for="host-url">host url</label>
@@ -543,7 +546,7 @@ function renderStep2(): string {
${renderProbeBanner()} ${renderProbeBanner()}
<div class="form-actions" style="margin-top:12px;"> <div class="form-actions" style="margin-top:12px;">
<button class="btn" id="back-btn">back</button> <button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next</button> <button class="btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
`; `;
@@ -643,7 +646,7 @@ function renderStep3New(): string {
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`; const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>create vault</h3> <h3>create vault</h3>
<div class="form-group"> <div class="form-group">
@@ -907,7 +910,7 @@ function renderStep4(): string {
const defaultName = state.deviceName || `${browser} on ${os}`; const defaultName = state.deviceName || `${browser} on ${os}`;
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>name this device</h3> <h3>name this device</h3>
<p class="muted" style="margin-bottom:12px;"> <p class="muted" style="margin-bottom:12px;">
This helps you identify which devices have access to your vault. This helps you identify which devices have access to your vault.
@@ -918,7 +921,7 @@ function renderStep4(): string {
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn" id="back-btn">back</button> <button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="next-btn">continue</button> <button class="btn-primary" id="next-btn">continue ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
`; `;
@@ -979,7 +982,7 @@ function renderStep5(): string {
const isAttach = state.mode === 'attach'; const isAttach = state.mode === 'attach';
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<div class="success-box"> <div class="success-box">
<h3>${isAttach ? 'device verified' : 'vault created'}</h3> <h3>${isAttach ? 'device verified' : 'vault created'}</h3>
<p class="secondary"> <p class="secondary">

View File

@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import * as glyphs from '../glyphs'; 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', () => { describe('glyphs', () => {
it('exports the documented glyph constants', () => { it('exports the documented glyph constants', () => {
@@ -19,3 +24,20 @@ describe('glyphs', () => {
expect(glyphs.REQUIRED_PILL_HTML).toBe('<span class="req-pill">required</span>'); expect(glyphs.REQUIRED_PILL_HTML).toBe('<span class="req-pill">required</span>');
}); });
}); });
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('▸');
});
});

View File

@@ -16,6 +16,7 @@ export const GLYPH_TRASH = '▦'; // sidebar trash nav
export const GLYPH_DEVICES = '⌬'; // sidebar devices nav export const GLYPH_DEVICES = '⌬'; // sidebar devices nav
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
export const GLYPH_LOCK = '⏻'; // sidebar lock 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: /// 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>` /// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`

View File

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

View File

@@ -1,22 +1,35 @@
/* Relicario vault — terminal dark theme (tab layout) */ /* Relicario vault — terminal dark theme (tab layout) */
:root { :root {
/* Brand */ /* Patina gold (Phase 2B) */
--accent: #d2ab43; --gold-base: #a88a4a;
--accent-soft: rgba(210, 171, 67, 0.18); --gold-mid: #cdb47a;
--accent-strong: #aa812a; --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 */ /* Surfaces */
--bg-page: #0d1117; --bg-page: #0a0e14;
--bg-pane: #161b22; --bg-pane: #11161e;
--bg-elevated: #21262d; --bg-elevated: #1c2330;
--bg-input: #161b22; --bg-card: rgba(22, 27, 34, 0.55);
--border-subtle: #30363d; --bg-input: #0a0e14;
--border-soft: rgba(255, 255, 255, 0.05);
--border-mid: #262d36;
--border-subtle: var(--border-mid);
/* Text */ /* Text */
--text: #c9d1d9; --text: #c9d1d9;
--text-muted: #8b949e; --text-muted: #8b949e;
--text-dim: #484f58; --text-dim: #6b7888;
/* Status */ /* Status */
--danger: #ab2b20; --danger: #ab2b20;
@@ -24,7 +37,7 @@
--success: #6cb37a; --success: #6cb37a;
/* Focus */ /* 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 { body {
background: #0d1117; background: var(--bg-page);
color: #c9d1d9; color: #c9d1d9;
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace; font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace;
font-size: 13px; font-size: 13px;
@@ -62,7 +75,7 @@ body {
.brand { .brand {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: #d2ab43; color: var(--gold-text);
letter-spacing: 1px; letter-spacing: 1px;
} }
@@ -1487,3 +1500,167 @@ textarea {
.f-notes--mono { .f-notes--mono {
font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important; 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;
}

View File

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

View File

@@ -415,6 +415,71 @@ async function selectItem(id: ItemId): Promise<void> {
} }
} }
// ---------------------------------------------------------------------------
// 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 = `
<div class="fullscreen-form-header">
<div>
<div class="title">${titleText}</div>
<div class="sub" id="form-dirty-sub">no changes</div>
</div>
<div class="hint">${SAVE_HINT}</div>
</div>
<div class="form-scroll" id="form-scroll"></div>
<div class="sticky-save-bar">
<button class="btn-secondary" id="form-cancel">cancel</button>
<button class="btn-primary" id="form-save">save</button>
</div>
`;
// 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 // Pane rendering — delegates to shared popup components
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -453,10 +518,16 @@ function renderPane(): void {
// set by the type-selection click handler (which calls setState → // set by the type-selection click handler (which calls setState →
// renderPane before the URL hash has been updated to include the type). // renderPane before the URL hash has been updated to include the type).
state.newType = (route.type as ItemType) ?? state.newType ?? null; state.newType = (route.type as ItemType) ?? state.newType ?? null;
// 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'); renderItemForm(pane, 'add');
}
break; break;
case 'edit': case 'edit':
renderItemForm(pane, 'edit'); renderFormWrapped(pane, 'edit');
break; break;
case 'trash': case 'trash':
renderTrash(pane); renderTrash(pane);