feat(ext/settings): Display section with color pickers + swatch + reset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-02 19:03:23 -04:00
parent b928ed407b
commit b2fc56709a
3 changed files with 204 additions and 0 deletions

View File

@@ -12,6 +12,22 @@ vi.mock('../../../shared/state', () => ({
}));
import { sendMessage } from '../../../shared/state';
import { DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR } from '../../../shared/color-scheme';
function mockChromeStorage(initial: Record<string, unknown> = {}) {
const store: Record<string, unknown> = { ...initial };
(global as any).chrome = {
storage: {
sync: {
get: vi.fn((key: string) => Promise.resolve(
key in store ? { [key]: store[key] } : {})),
set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
},
},
};
return store;
}
function settingsResponses() {
// Two parallel calls in renderSettings: get_settings + get_blacklist.
@@ -30,6 +46,7 @@ describe('settings view', () => {
});
it('renders a Sync now button', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
@@ -38,6 +55,7 @@ describe('settings view', () => {
});
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
mockChromeStorage();
settingsResponses();
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
@@ -52,6 +70,7 @@ describe('settings view', () => {
});
it('shows the error when sync fails', async () => {
mockChromeStorage();
settingsResponses();
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
@@ -64,3 +83,109 @@ describe('settings view', () => {
expect(status.textContent).toMatch(/remote_unreachable/);
});
});
describe('settings Display section', () => {
let app: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
app = document.getElementById('app')!;
(sendMessage as ReturnType<typeof vi.fn>).mockReset();
});
it('renders digit and symbol color pickers with default values when storage is empty', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
expect(digitInput).not.toBeNull();
expect(symbolInput).not.toBeNull();
expect(digitInput!.value).toBe(DEFAULT_DIGIT_COLOR);
expect(symbolInput!.value).toBe(DEFAULT_SYMBOL_COLOR);
});
it('renders pickers with stored values when storage has a scheme', async () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
});
settingsResponses();
await renderSettings(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
expect(digitInput!.value).toBe('#112233');
expect(symbolInput!.value).toBe('#aabbcc');
});
it('renders a color-preview-swatch element', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
expect(app.querySelector('#display-swatch')).not.toBeNull();
});
it('changing digit color calls saveColorScheme with updated scheme', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
digitInput.value = '#ff0000';
digitInput.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 0));
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
expect(syncSet).toHaveBeenCalledWith(
expect.objectContaining({
password_display_scheme: expect.objectContaining({ digit_color: '#ff0000' }),
}),
);
});
it('changing symbol color calls saveColorScheme with updated scheme', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
symbolInput.value = '#00ff00';
symbolInput.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 0));
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
expect(syncSet).toHaveBeenCalledWith(
expect.objectContaining({
password_display_scheme: expect.objectContaining({ symbol_color: '#00ff00' }),
}),
);
});
it('clicking reset calls chrome.storage.sync.remove and restores defaults', async () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
});
settingsResponses();
await renderSettings(app);
const resetBtn = app.querySelector<HTMLButtonElement>('#display-reset')!;
resetBtn.click();
await new Promise((r) => setTimeout(r, 0));
const syncRemove = (global as any).chrome.storage.sync.remove as ReturnType<typeof vi.fn>;
expect(syncRemove).toHaveBeenCalledWith('password_display_scheme');
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
expect(digitInput.value).toBe(DEFAULT_DIGIT_COLOR);
expect(symbolInput.value).toBe(DEFAULT_SYMBOL_COLOR);
});
});

View File

@@ -3,6 +3,11 @@
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { DeviceSettings } from '../../shared/types';
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
import {
loadColorScheme, saveColorScheme, resetColorScheme,
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
} from '../../shared/color-scheme';
import { colorizePassword } from '../../shared/password-coloring';
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>';
@@ -62,6 +67,9 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
<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">
@@ -119,4 +127,65 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
}
});
});
// 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();
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>
`;
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);
});
}

View File

@@ -1563,3 +1563,13 @@ textarea {
.pwd-digit { color: var(--relicario-pwd-digit-color); }
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
.pwd-letter { color: inherit; }
.color-preview-swatch {
font-family: ui-monospace, monospace;
font-size: 1.1rem;
padding: 8px 12px;
border: 1px solid var(--border-mid);
border-radius: 4px;
margin-top: 8px;
background: var(--bg-input);
}