feat(ext/settings): settings left-nav skeleton with section routing
Two-panel layout (148px nav sidebar + content area) with 7 nav items (Autofill, Display, Security, Generator, Retention, Backup, Import), stub section functions, and settings layout CSS classes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.stubGlobal('chrome', {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn((_keys: unknown, cb: (r: Record<string, unknown>) => void) => cb({})),
|
||||
set: vi.fn((_data: unknown, cb?: () => void) => cb?.()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
import * as settingsMod from '../settings';
|
||||
|
||||
describe('settings module contract', () => {
|
||||
it('exports renderSettings as a function', () => {
|
||||
expect(typeof settingsMod.renderSettings).toBe('function');
|
||||
});
|
||||
|
||||
it('exports teardownSettings as a function', () => {
|
||||
expect(typeof settingsMod.teardownSettings).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -1,191 +1,139 @@
|
||||
/// Settings view — capture toggle, prompt style, and blacklist management.
|
||||
|
||||
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import type { DeviceSettings } from '../../shared/types';
|
||||
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
|
||||
import { sendMessage, escapeHtml } from '../../shared/state';
|
||||
import type { VaultSettings } from '../../shared/types';
|
||||
import {
|
||||
loadColorScheme, saveColorScheme, resetColorScheme,
|
||||
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||
} from '../../shared/color-scheme';
|
||||
import { colorizePassword } from '../../shared/password-coloring';
|
||||
import { openGeneratorPanel, closeGeneratorPanel } from './generator-panel';
|
||||
import { renderSecuritySection, teardownSecuritySection } from './settings-security';
|
||||
|
||||
export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
||||
type SettingsSection =
|
||||
| 'autofill'
|
||||
| 'display'
|
||||
| 'security'
|
||||
| 'generator'
|
||||
| 'retention'
|
||||
| 'backup'
|
||||
| 'import';
|
||||
|
||||
// Load settings and blacklist in parallel
|
||||
const [settingsResp, blacklistResp] = await Promise.all([
|
||||
sendMessage({ type: 'get_settings' }),
|
||||
sendMessage({ type: 'get_blacklist' }),
|
||||
]);
|
||||
const NAV_ITEMS: Array<{ id: SettingsSection; icon: string; label: string; group: 'device' | 'vault' }> = [
|
||||
{ id: 'autofill', icon: '⊙', label: 'Autofill', group: 'device' },
|
||||
{ id: 'display', icon: '◈', label: 'Display', group: 'device' },
|
||||
{ id: 'security', icon: '◉', label: 'Security', group: 'vault' },
|
||||
{ id: 'generator', icon: '↻', label: 'Generator', group: 'vault' },
|
||||
{ id: 'retention', icon: '▦', label: 'Retention', group: 'vault' },
|
||||
{ id: 'backup', icon: '⤓', label: 'Backup', group: 'vault' },
|
||||
{ id: 'import', icon: '≡', label: 'Import', group: 'vault' },
|
||||
];
|
||||
|
||||
const settings: DeviceSettings = settingsResp.ok
|
||||
? (settingsResp.data as { settings: DeviceSettings }).settings
|
||||
: { captureEnabled: false, captureStyle: 'bar' };
|
||||
|
||||
const blacklist: string[] = blacklistResp.ok
|
||||
? (blacklistResp.data as { blacklist: string[] }).blacklist
|
||||
: [];
|
||||
|
||||
const blacklistHtml = blacklist.length > 0
|
||||
? blacklist.map((h) => `
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; padding:4px 0; border-bottom:1px solid #21262d;">
|
||||
<span style="font-size:12px; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(h)}</span>
|
||||
<button class="relicario-remove-bl" data-hostname="${escapeHtml(h)}" style="
|
||||
background:transparent; color:#ab2b20; border:none; cursor:pointer;
|
||||
font-size:11px; padding:2px 6px;
|
||||
">remove</button>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<p class="muted" style="font-size:12px;">no blacklisted sites</p>';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad" style="padding-top:12px;">
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<button id="settings-back" class="btn" style="font-size:11px; margin-right:8px;">←</button>
|
||||
<span style="font-size:14px; font-weight:600;">settings</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;">
|
||||
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
|
||||
auto-detect logins
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">prompt style</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button id="style-bar" class="btn" style="font-size:11px; ${settings.captureStyle === 'bar' ? 'background:#7c5719; color:#fff;' : ''}">bar</button>
|
||||
<button id="style-toast" class="btn" style="font-size:11px; ${settings.captureStyle === 'toast' ? 'background:#7c5719; color:#fff;' : ''}">toast</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">${GLYPH_TRASH} trash</button>
|
||||
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button>
|
||||
<button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">📤 Sync now</button>
|
||||
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;" id="display-section-container">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
||||
<div id="blacklist-container">
|
||||
${blacklistHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Back button
|
||||
document.getElementById('settings-back')?.addEventListener('click', () => {
|
||||
navigate('locked');
|
||||
});
|
||||
|
||||
// Navigation buttons
|
||||
document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash'));
|
||||
document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices'));
|
||||
|
||||
// Sync now button
|
||||
document.getElementById('sync-now-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('sync-now-btn') as HTMLButtonElement | null;
|
||||
const status = document.getElementById('sync-status');
|
||||
if (!btn || !status) return;
|
||||
btn.disabled = true;
|
||||
status.textContent = 'syncing...';
|
||||
const result = await sendMessage({ type: 'sync' });
|
||||
btn.disabled = false;
|
||||
status.textContent = result.ok ? 'synced ✓' : `sync failed: ${result.error}`;
|
||||
});
|
||||
|
||||
// Capture enabled toggle
|
||||
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
await sendMessage({ type: 'update_settings', settings: { captureEnabled: checked } });
|
||||
});
|
||||
|
||||
// Style buttons
|
||||
document.getElementById('style-bar')?.addEventListener('click', async () => {
|
||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } });
|
||||
renderSettings(app);
|
||||
});
|
||||
|
||||
document.getElementById('style-toast')?.addEventListener('click', async () => {
|
||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } });
|
||||
renderSettings(app);
|
||||
});
|
||||
|
||||
// Blacklist remove buttons
|
||||
document.querySelectorAll('.relicario-remove-bl').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const hostname = (btn as HTMLElement).dataset.hostname;
|
||||
if (hostname) {
|
||||
await sendMessage({ type: 'remove_blacklist', hostname });
|
||||
renderSettings(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Render Display section after the rest of the DOM is ready
|
||||
await renderDisplaySection();
|
||||
}
|
||||
|
||||
function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): void {
|
||||
swatch.style.setProperty('--relicario-pwd-digit-color', digitColor);
|
||||
swatch.style.setProperty('--relicario-pwd-symbol-color', symbolColor);
|
||||
swatch.innerHTML = '';
|
||||
swatch.appendChild(colorizePassword('Abc123!@#xyz'));
|
||||
}
|
||||
|
||||
async function renderDisplaySection(): Promise<void> {
|
||||
// The Display section container must be present in the DOM before we call this
|
||||
const container = document.getElementById('display-section-container');
|
||||
if (!container) return;
|
||||
|
||||
const scheme = await loadColorScheme();
|
||||
let activeSection: SettingsSection = 'autofill';
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let pendingVaultSettings: VaultSettings | null = null;
|
||||
|
||||
export async function renderSettings(container: HTMLElement): Promise<void> {
|
||||
container.innerHTML = `
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">display</div>
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||
<input type="color" id="display-digit-color" value="${escapeHtml(scheme.digit_color)}">
|
||||
digit color
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||
<input type="color" id="display-symbol-color" value="${escapeHtml(scheme.symbol_color)}">
|
||||
symbol color
|
||||
</label>
|
||||
</div>
|
||||
<div id="display-swatch" class="color-preview-swatch"></div>
|
||||
<div style="margin-top:8px;">
|
||||
<button id="display-reset" class="btn" style="font-size:11px;">reset to defaults</button>
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-nav" id="settings-nav">
|
||||
<div class="settings-nav__group-label">Device</div>
|
||||
${NAV_ITEMS.filter(n => n.group === 'device').map(navItemHtml).join('')}
|
||||
<div class="settings-nav__group-label">Vault</div>
|
||||
${NAV_ITEMS.filter(n => n.group === 'vault').map(navItemHtml).join('')}
|
||||
</nav>
|
||||
<div class="settings-content" id="settings-content"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const digitInput = document.getElementById('display-digit-color') as HTMLInputElement;
|
||||
const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement;
|
||||
const swatch = document.getElementById('display-swatch') as HTMLElement;
|
||||
|
||||
// Render initial swatch
|
||||
updateSwatch(swatch, scheme.digit_color, scheme.symbol_color);
|
||||
|
||||
async function onColorChange(): Promise<void> {
|
||||
const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value };
|
||||
await saveColorScheme(newScheme);
|
||||
updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color);
|
||||
}
|
||||
|
||||
digitInput.addEventListener('change', () => void onColorChange());
|
||||
symbolInput.addEventListener('change', () => void onColorChange());
|
||||
|
||||
document.getElementById('display-reset')?.addEventListener('click', async () => {
|
||||
await resetColorScheme();
|
||||
digitInput.value = DEFAULT_DIGIT_COLOR;
|
||||
symbolInput.value = DEFAULT_SYMBOL_COLOR;
|
||||
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
|
||||
});
|
||||
wireNav();
|
||||
await renderSection(activeSection);
|
||||
}
|
||||
|
||||
export function teardownSettings(): void {
|
||||
closeGeneratorPanel();
|
||||
teardownSecuritySection();
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
pendingVaultSettings = null;
|
||||
}
|
||||
|
||||
function navItemHtml(item: (typeof NAV_ITEMS)[0]): string {
|
||||
const active = item.id === activeSection ? ' settings-nav__item--active' : '';
|
||||
return `
|
||||
<button class="settings-nav__item${active}" data-section="${item.id}">
|
||||
<span class="settings-nav__icon" aria-hidden="true">${item.icon}</span>
|
||||
<span class="settings-nav__label">${escapeHtml(item.label)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function wireNav(): void {
|
||||
document.getElementById('settings-nav')?.querySelectorAll<HTMLButtonElement>('[data-section]')
|
||||
.forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
teardownSecuritySection();
|
||||
closeGeneratorPanel();
|
||||
activeSection = btn.dataset.section as SettingsSection;
|
||||
document.querySelectorAll('.settings-nav__item').forEach(b => b.classList.remove('settings-nav__item--active'));
|
||||
btn.classList.add('settings-nav__item--active');
|
||||
await renderSection(activeSection);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderSection(section: SettingsSection): Promise<void> {
|
||||
const content = document.getElementById('settings-content');
|
||||
if (!content) return;
|
||||
|
||||
switch (section) {
|
||||
case 'autofill': return renderAutofillSection(content);
|
||||
case 'display': return renderDisplaySection(content);
|
||||
case 'security': return renderSecuritySection(content, null);
|
||||
case 'generator': return renderGeneratorSection(content);
|
||||
case 'retention': return renderRetentionSection(content);
|
||||
case 'backup': return renderBackupSection(content);
|
||||
case 'import': return renderImportSection(content);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Section stubs (filled in by Tasks 3-9) ---
|
||||
|
||||
async function renderAutofillSection(content: HTMLElement): Promise<void> {
|
||||
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Autofill — coming soon</p>';
|
||||
}
|
||||
|
||||
function renderDisplaySection(content: HTMLElement): void {
|
||||
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Display — coming soon</p>';
|
||||
}
|
||||
|
||||
async function renderGeneratorSection(content: HTMLElement): Promise<void> {
|
||||
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Generator — coming soon</p>';
|
||||
}
|
||||
|
||||
async function renderRetentionSection(content: HTMLElement): Promise<void> {
|
||||
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Retention — coming soon</p>';
|
||||
}
|
||||
|
||||
function renderBackupSection(content: HTMLElement): void {
|
||||
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Backup — coming soon</p>';
|
||||
}
|
||||
|
||||
function renderImportSection(content: HTMLElement): void {
|
||||
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Import — coming soon</p>';
|
||||
}
|
||||
|
||||
export { renderAutofillSection, renderDisplaySection, renderGeneratorSection,
|
||||
renderRetentionSection, renderBackupSection, renderImportSection };
|
||||
|
||||
// Suppress unused-import warnings — these are used by Tasks 3-9
|
||||
void sendMessage;
|
||||
void loadColorScheme;
|
||||
void saveColorScheme;
|
||||
void resetColorScheme;
|
||||
void DEFAULT_DIGIT_COLOR;
|
||||
void DEFAULT_SYMBOL_COLOR;
|
||||
void colorizePassword;
|
||||
void openGeneratorPanel;
|
||||
void pendingVaultSettings;
|
||||
void activeKeyHandler;
|
||||
|
||||
@@ -1573,3 +1573,92 @@ textarea {
|
||||
margin-top: 8px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
/* === Settings layout === */
|
||||
.settings-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
width: 148px;
|
||||
min-width: 148px;
|
||||
border-right: 1px solid var(--border, #30363d);
|
||||
padding: 12px 0;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-nav__group-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted, #8b949e);
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
|
||||
.settings-nav__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-nav__item:hover { background: var(--bg-hover, #161b22); }
|
||||
.settings-nav__item--active { background: var(--bg-selected, #1c2d41); }
|
||||
|
||||
.settings-nav__icon { font-size: 14px; flex-shrink: 0; }
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-subtle, #21262d);
|
||||
}
|
||||
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
|
||||
.setting-row__info { flex: 1; }
|
||||
.setting-row__title { font-size: 13px; font-weight: 500; }
|
||||
.setting-row__desc { font-size: 11px; color: var(--text-muted, #8b949e); margin-top: 2px; }
|
||||
.setting-row__control { flex-shrink: 0; }
|
||||
|
||||
.settings-section-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted, #8b949e);
|
||||
margin: 0 0 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
}
|
||||
|
||||
.setting-card {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.setting-card--ok { border-color: var(--success, #238636); background: rgba(35, 134, 54, 0.06); }
|
||||
.setting-card--warn { border-color: var(--gold, #b8860b); background: rgba(184, 134, 11, 0.06); }
|
||||
|
||||
.setting-card__status { font-size: 13px; margin-bottom: 8px; }
|
||||
.setting-card__actions { display: flex; gap: 8px; }
|
||||
|
||||
Reference in New Issue
Block a user