From 25c9eb52a0607096e7a9c3ec386c2b0d550f6804 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 16:49:03 -0400 Subject: [PATCH] feat(ext/shared): color-scheme storage + applyColorScheme Co-Authored-By: Claude Sonnet 4.6 --- .../src/shared/__tests__/color-scheme.test.ts | 76 +++++++++++++++++++ extension/src/shared/color-scheme.ts | 48 ++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 extension/src/shared/__tests__/color-scheme.test.ts create mode 100644 extension/src/shared/color-scheme.ts diff --git a/extension/src/shared/__tests__/color-scheme.test.ts b/extension/src/shared/__tests__/color-scheme.test.ts new file mode 100644 index 0000000..f1ab3b1 --- /dev/null +++ b/extension/src/shared/__tests__/color-scheme.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + loadColorScheme, saveColorScheme, resetColorScheme, applyColorScheme, + DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR, +} from '../color-scheme'; + +function mockChromeStorage(initial: any = {}) { + const store = { ...initial }; + (global as any).chrome = { + storage: { + sync: { + get: vi.fn((key: string) => Promise.resolve( + key in store ? { [key]: store[key] } : {})), + set: vi.fn((kv: any) => { Object.assign(store, kv); return Promise.resolve(); }), + remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }), + }, + }, + }; + return store; +} + +describe('color-scheme storage', () => { + beforeEach(() => { + // happy-dom provides document globally; reset inline styles between tests + document.documentElement.removeAttribute('style'); + }); + + it('load returns defaults when storage is empty', async () => { + mockChromeStorage(); + const scheme = await loadColorScheme(); + expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR); + expect(scheme.symbol_color).toBe(DEFAULT_SYMBOL_COLOR); + }); + + it('load returns stored values when present', async () => { + mockChromeStorage({ + password_display_scheme: { digit_color: '#123456', symbol_color: '#abcdef' }, + }); + const scheme = await loadColorScheme(); + expect(scheme.digit_color).toBe('#123456'); + expect(scheme.symbol_color).toBe('#abcdef'); + }); + + it('save round-trips', async () => { + mockChromeStorage(); + await saveColorScheme({ digit_color: '#111111', symbol_color: '#222222' }); + const scheme = await loadColorScheme(); + expect(scheme).toEqual({ digit_color: '#111111', symbol_color: '#222222' }); + }); + + it('reset removes the storage key', async () => { + const store = mockChromeStorage({ + password_display_scheme: { digit_color: '#000000', symbol_color: '#ffffff' }, + }); + await resetColorScheme(); + expect(store.password_display_scheme).toBeUndefined(); + const scheme = await loadColorScheme(); + expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR); + }); + + it('apply sets CSS custom properties on document.documentElement', async () => { + mockChromeStorage({ + password_display_scheme: { digit_color: '#deadbe', symbol_color: '#feed00' }, + }); + await applyColorScheme(); + const root = document.documentElement.style; + expect(root.getPropertyValue('--relicario-pwd-digit-color').trim()).toBe('#deadbe'); + expect(root.getPropertyValue('--relicario-pwd-symbol-color').trim()).toBe('#feed00'); + }); + + it('save rejects malformed hex values', async () => { + mockChromeStorage(); + await expect(saveColorScheme({ digit_color: 'not-a-color', symbol_color: '#ffffff' })) + .rejects.toThrow(); + }); +}); diff --git a/extension/src/shared/color-scheme.ts b/extension/src/shared/color-scheme.ts new file mode 100644 index 0000000..28abd74 --- /dev/null +++ b/extension/src/shared/color-scheme.ts @@ -0,0 +1,48 @@ +export const DEFAULT_DIGIT_COLOR = '#2563eb'; +export const DEFAULT_SYMBOL_COLOR = '#dc2626'; +const STORAGE_KEY = 'password_display_scheme'; +const HEX_RE = /^#[0-9a-fA-F]{6}$/; + +export interface ColorScheme { + digit_color: string; + symbol_color: string; +} + +export const DEFAULT_SCHEME: ColorScheme = { + digit_color: DEFAULT_DIGIT_COLOR, + symbol_color: DEFAULT_SYMBOL_COLOR, +}; + +function isValid(s: ColorScheme): boolean { + return HEX_RE.test(s.digit_color) && HEX_RE.test(s.symbol_color); +} + +export async function loadColorScheme(): Promise { + const result = await chrome.storage.sync.get(STORAGE_KEY); + const stored = result[STORAGE_KEY] as Partial | undefined; + if (!stored) return { ...DEFAULT_SCHEME }; + return { + digit_color: typeof stored.digit_color === 'string' && HEX_RE.test(stored.digit_color) + ? stored.digit_color : DEFAULT_DIGIT_COLOR, + symbol_color: typeof stored.symbol_color === 'string' && HEX_RE.test(stored.symbol_color) + ? stored.symbol_color : DEFAULT_SYMBOL_COLOR, + }; +} + +export async function saveColorScheme(scheme: ColorScheme): Promise { + if (!isValid(scheme)) { + throw new Error('Invalid color values; expected #rrggbb hex strings.'); + } + await chrome.storage.sync.set({ [STORAGE_KEY]: scheme }); +} + +export async function resetColorScheme(): Promise { + await chrome.storage.sync.remove(STORAGE_KEY); +} + +export async function applyColorScheme(): Promise { + const scheme = await loadColorScheme(); + const root = document.documentElement.style; + root.setProperty('--relicario-pwd-digit-color', scheme.digit_color); + root.setProperty('--relicario-pwd-symbol-color', scheme.symbol_color); +}