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:
@@ -12,6 +12,22 @@ vi.mock('../../../shared/state', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { sendMessage } from '../../../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() {
|
function settingsResponses() {
|
||||||
// Two parallel calls in renderSettings: get_settings + get_blacklist.
|
// Two parallel calls in renderSettings: get_settings + get_blacklist.
|
||||||
@@ -30,6 +46,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders a Sync now button', async () => {
|
it('renders a Sync now button', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
|
|
||||||
await renderSettings(app);
|
await renderSettings(app);
|
||||||
@@ -38,6 +55,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
|
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
@@ -52,6 +70,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows the error when sync fails', async () => {
|
it('shows the error when sync fails', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
|
(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/);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
import type { DeviceSettings } from '../../shared/types';
|
import type { DeviceSettings } from '../../shared/types';
|
||||||
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
|
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> {
|
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>';
|
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 id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:16px;" id="display-section-container">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
||||||
<div id="blacklist-container">
|
<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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1563,3 +1563,13 @@ textarea {
|
|||||||
.pwd-digit { color: var(--relicario-pwd-digit-color); }
|
.pwd-digit { color: var(--relicario-pwd-digit-color); }
|
||||||
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
|
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
|
||||||
.pwd-letter { color: inherit; }
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user