Merge feature/phase-2b-polish: polish foundation + form layout
This commit is contained in:
45
extension/src/popup/components/__tests__/unlock.test.ts
Normal file
45
extension/src/popup/components/__tests__/unlock.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__title">backup & restore</div>
|
||||
<div class="settings-row">
|
||||
<button class="btn" id="open-backup">Backup & restore →</button>
|
||||
<button class="btn" id="open-backup">Backup & restore ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__title">import</div>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
|
||||
@@ -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 = `
|
||||
<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>`;
|
||||
|
||||
const urlFieldHtml = `
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
||||
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
||||
</div>
|
||||
<div id="hostname-chip-row" class="hostname-chip-row" hidden></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>
|
||||
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>`;
|
||||
|
||||
const passwordFieldHtml = `
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
||||
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
|
||||
</div>
|
||||
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
||||
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="strength-label"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const totpFieldHtml = `
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
|
||||
</div>
|
||||
<div id="totp-preview-row" class="totp-preview" hidden>
|
||||
<span class="totp-code">…</span>
|
||||
<span class="totp-countdown">…</span>
|
||||
</div>
|
||||
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
||||
<input id="totp-qr-file" type="file" accept="image/*" />
|
||||
<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>
|
||||
</div>`;
|
||||
|
||||
const identityHtml = `
|
||||
<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">
|
||||
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
|
||||
${surface === 'popup' ? 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>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
||||
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
||||
</div>
|
||||
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
||||
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
|
||||
</div>
|
||||
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
||||
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="strength-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
|
||||
</div>
|
||||
<div id="totp-preview-row" class="totp-preview" hidden>
|
||||
<span class="totp-code">…</span>
|
||||
<span class="totp-countdown">…</span>
|
||||
</div>
|
||||
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
||||
<input id="totp-qr-file" type="file" accept="image/*" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="form-group"><label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
|
||||
${sectionsHtml}
|
||||
|
||||
<div class="form-group">
|
||||
<div class="notes-with-toggle">
|
||||
@@ -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' }) : ''}
|
||||
<div class="form-actions">
|
||||
<div class="form-actions" ${externalActions ? 'hidden' : ''}>
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||
</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 title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 = `
|
||||
<div class="pad" style="padding-top: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>
|
||||
${progressHtml}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
${stepHtml}
|
||||
<div class="surface-backdrop" style="min-height:100vh;">
|
||||
<div class="pad" style="padding-top: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>
|
||||
${progressHtml}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
${stepHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -214,20 +217,20 @@ function renderStep0(): string {
|
||||
const isNew = state.mode === 'new';
|
||||
const isAttach = state.mode === 'attach';
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>set up Relicario</h3>
|
||||
<p class="muted" style="margin-bottom:16px;">
|
||||
How are you using Relicario on this device?
|
||||
</p>
|
||||
<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>
|
||||
<p class="mode-card-blurb">
|
||||
I'm setting up Relicario for the first time. This will create a fresh
|
||||
encrypted vault on a new or empty git repository.
|
||||
</p>
|
||||
</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>
|
||||
<p class="mode-card-blurb">
|
||||
I already have a vault on another device. Connect this browser to it
|
||||
@@ -236,7 +239,7 @@ function renderStep0(): string {
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
`;
|
||||
@@ -267,7 +270,7 @@ function renderStep3Attach(): string {
|
||||
const gateDisabled = state.attaching || !p || !hasImage;
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>attach this device</h3>
|
||||
<p class="muted" style="margin-bottom:12px;">
|
||||
Use your existing passphrase and reference image to attach this browser
|
||||
@@ -430,7 +433,7 @@ function renderStep1(): string {
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>choose host</h3>
|
||||
<div class="form-group">
|
||||
<label class="label">host type</label>
|
||||
@@ -442,7 +445,7 @@ function renderStep1(): string {
|
||||
${state.hostType === 'gitea' ? giteaInstructions : githubInstructions}
|
||||
<div class="form-actions">
|
||||
<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>
|
||||
`;
|
||||
@@ -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 `
|
||||
<div class="wizard-step">
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>configure connection</h3>
|
||||
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
|
||||
<label class="label" for="host-url">host url</label>
|
||||
@@ -543,7 +546,7 @@ function renderStep2(): string {
|
||||
${renderProbeBanner()}
|
||||
<div class="form-actions" style="margin-top:12px;">
|
||||
<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>
|
||||
`;
|
||||
@@ -643,7 +646,7 @@ function renderStep3New(): string {
|
||||
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>create vault</h3>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -907,7 +910,7 @@ function renderStep4(): string {
|
||||
const defaultName = state.deviceName || `${browser} on ${os}`;
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>name this device</h3>
|
||||
<p class="muted" style="margin-bottom:12px;">
|
||||
This helps you identify which devices have access to your vault.
|
||||
@@ -918,7 +921,7 @@ function renderStep4(): string {
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<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>
|
||||
`;
|
||||
@@ -979,7 +982,7 @@ function renderStep5(): string {
|
||||
const isAttach = state.mode === 'attach';
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<div class="success-box">
|
||||
<h3>${isAttach ? 'device verified' : 'vault created'}</h3>
|
||||
<p class="secondary">
|
||||
|
||||
@@ -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('<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('▸');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
/// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`
|
||||
|
||||
45
extension/src/vault/__tests__/form-wrapper.test.ts
Normal file
45
extension/src/vault/__tests__/form-wrapper.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<title>Relicario — vault</title>
|
||||
<link rel="stylesheet" href="vault.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="surface-backdrop">
|
||||
<div id="vault-app"></div>
|
||||
<script src="vault.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user