From 1e7e332c248f8bb12da0c3c3c6d58c8831c21de0 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Mon, 14 Sep 2026 19:39:52 +0530 Subject: [PATCH] feat(theme-registry): pure ThemeRegistry module with full API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 8 exports: register/unregister/list/get/categories/lightThemes/darkThemes/clear - kebab-case id validator; category whitelist (light/dark/high-contrast/seasonal) - duplicate-id and shape errors with descriptive messages - pure CommonJS, no IO, no Electron deps — T2 bootstrap will register all 37 themes - 10 Jest tests covering register/unregister/get/categories/light+dark filters - full suite: 77 suites, 924 tests passing; lint + Prettier clean Amit Haridas --- src/main/ThemeRegistry.js | 108 +++++++++++++++++++++++++ tests/main/theme-registry.test.js | 126 ++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/main/ThemeRegistry.js create mode 100644 tests/main/theme-registry.test.js diff --git a/src/main/ThemeRegistry.js b/src/main/ThemeRegistry.js new file mode 100644 index 0000000..540f492 --- /dev/null +++ b/src/main/ThemeRegistry.js @@ -0,0 +1,108 @@ +/** + * Editor Theme Registry — single source of truth for installed editor themes. + * + * Pure module: no Electron, no IO, no globals beyond a module-scoped Map. + * The bootstrap (`ThemeRegistry.bootstrap.js`) calls `register()` at startup; + * `main.js` menu builder calls `list()` + `categories()`; `setTheme()` + * validation calls `get()`. + * + * Theme shape: + * { id, label, category: 'light'|'dark'|'high-contrast'|'seasonal', isDark } + * + * Ids are kebab-case lowercase. Categories are independent of `isDark` — + * e.g. `winter-is-coming-light` has `isDark: false` and `category: 'light'`, + * but a future seasonal theme might pair `category: 'seasonal'` with + * `isDark: true`. Filtering by category is the public contract; `isDark` + * is metadata for the renderer. + * + * @module ThemeRegistry + */ + +const VALID_CATEGORIES = new Set(['light', 'dark', 'high-contrast', 'seasonal']); +const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +const themes = new Map(); // id → theme + +function validate(theme) { + if (!theme || typeof theme !== 'object') throw new Error('theme must be an object'); + if (typeof theme.id !== 'string' || !theme.id) throw new Error('theme is missing id'); + if (!ID_RE.test(theme.id)) { + throw new Error(`theme id must be kebab-case lowercase: got "${theme.id}"`); + } + if (typeof theme.label !== 'string' || !theme.label) { + throw new Error(`theme "${theme.id}" is missing label`); + } + if (!('category' in theme) || theme.category === undefined || theme.category === null) { + throw new Error(`theme "${theme.id}" is missing category`); + } + if (!VALID_CATEGORIES.has(theme.category)) { + throw new Error( + `theme "${theme.id}" has invalid category "${theme.category}" (allowed: ${[...VALID_CATEGORIES].join(', ')})` + ); + } + if (typeof theme.isDark !== 'boolean') { + throw new Error(`theme "${theme.id}" is missing isDark boolean`); + } +} + +function register(theme) { + validate(theme); + if (themes.has(theme.id)) { + throw new Error(`duplicate theme id: ${theme.id}`); + } + themes.set(theme.id, { + id: theme.id, + label: theme.label, + category: theme.category, + isDark: theme.isDark, + }); + return theme; +} + +function unregister(id) { + return themes.delete(id); +} + +function list() { + return [...themes.values()]; +} + +function get(id) { + return themes.get(id) || null; +} + +function categories() { + const seen = new Set(); + const out = []; + for (const t of themes.values()) { + if (!seen.has(t.category)) { + seen.add(t.category); + out.push(t.category); + } + } + return out; +} + +function lightThemes() { + return list().filter((t) => t.category === 'light'); +} + +function darkThemes() { + return list().filter((t) => t.category === 'dark'); +} + +/** Test-only: empty the registry between tests. */ +function clear() { + themes.clear(); +} + +module.exports = { + register, + unregister, + list, + get, + categories, + lightThemes, + darkThemes, + clear, +}; diff --git a/tests/main/theme-registry.test.js b/tests/main/theme-registry.test.js new file mode 100644 index 0000000..c053ea9 --- /dev/null +++ b/tests/main/theme-registry.test.js @@ -0,0 +1,126 @@ +/** + * @jest-environment node + * + * ThemeRegistry tests — pure module. Tests run against a freshly-cleared + * registry (`clear()` is a test-only helper, but it's exported alongside the + * public API because every other pure module in src/main/ exposes the same + * `reset` helper for tests — see DocQA, DailyNotes, WorkspaceSearch). + */ +const ThemeRegistry = require('../../src/main/ThemeRegistry'); + +const validTheme = (overrides = {}) => ({ + id: 'atomonelight', + label: 'Atom One Light', + category: 'light', + isDark: false, + ...overrides, +}); + +describe('ThemeRegistry.register', () => { + beforeEach(() => ThemeRegistry.clear()); + + test('adds a theme to list()', () => { + ThemeRegistry.register(validTheme()); + expect(ThemeRegistry.list()).toEqual([ + { + id: 'atomonelight', + label: 'Atom One Light', + category: 'light', + isDark: false, + }, + ]); + }); + + test('throws on duplicate id', () => { + ThemeRegistry.register(validTheme()); + expect(() => ThemeRegistry.register(validTheme())).toThrow(/duplicate theme id: atomonelight/); + }); + + test('throws when theme shape is invalid', () => { + expect(() => ThemeRegistry.register({ id: 'x' })).toThrow(/missing label/); + expect(() => ThemeRegistry.register({ label: 'X' })).toThrow(/missing id/); + expect(() => ThemeRegistry.register({ id: 'x', label: 'X' })).toThrow(/missing category/); + expect(() => ThemeRegistry.register({ id: 'x', label: 'X', category: 'light' })).toThrow( + /missing isDark/ + ); + expect(() => + ThemeRegistry.register({ + id: 'BAD ID', + label: 'X', + category: 'light', + isDark: false, + }) + ).toThrow(/kebab-case/); + expect(() => + ThemeRegistry.register({ + id: 'x', + label: 'X', + category: 'bogus', + isDark: false, + }) + ).toThrow(/invalid category/); + }); + + test('preserves registration order across many inserts', () => { + ThemeRegistry.register(validTheme({ id: 'a', label: 'A' })); + ThemeRegistry.register(validTheme({ id: 'b', label: 'B' })); + ThemeRegistry.register(validTheme({ id: 'c', label: 'C' })); + expect(ThemeRegistry.list().map((t) => t.id)).toEqual(['a', 'b', 'c']); + }); +}); + +describe('ThemeRegistry.unregister', () => { + beforeEach(() => ThemeRegistry.clear()); + + test('removes a theme by id', () => { + ThemeRegistry.register(validTheme()); + expect(ThemeRegistry.unregister('atomonelight')).toBe(true); + expect(ThemeRegistry.list()).toEqual([]); + }); + + test('returns false when id not found', () => { + expect(ThemeRegistry.unregister('nope')).toBe(false); + }); +}); + +describe('ThemeRegistry.get', () => { + beforeEach(() => ThemeRegistry.clear()); + + test('returns the registered theme', () => { + ThemeRegistry.register(validTheme()); + expect(ThemeRegistry.get('atomonelight')).toEqual({ + id: 'atomonelight', + label: 'Atom One Light', + category: 'light', + isDark: false, + }); + }); + + test('returns null for unknown id', () => { + expect(ThemeRegistry.get('nope')).toBeNull(); + }); +}); + +describe('ThemeRegistry.categories', () => { + beforeEach(() => ThemeRegistry.clear()); + + test('returns unique categories in registration order', () => { + ThemeRegistry.register(validTheme({ id: 'a', label: 'A', category: 'light' })); + ThemeRegistry.register(validTheme({ id: 'b', label: 'B', category: 'dark' })); + ThemeRegistry.register(validTheme({ id: 'c', label: 'C', category: 'light' })); + ThemeRegistry.register(validTheme({ id: 'd', label: 'D', category: 'high-contrast' })); + expect(ThemeRegistry.categories()).toEqual(['light', 'dark', 'high-contrast']); + }); +}); + +describe('ThemeRegistry.lightThemes / darkThemes', () => { + beforeEach(() => ThemeRegistry.clear()); + + test('filters by category regardless of isDark', () => { + ThemeRegistry.register(validTheme({ id: 'a', label: 'A', category: 'light', isDark: false })); + ThemeRegistry.register(validTheme({ id: 'b', label: 'B', category: 'light', isDark: true })); + ThemeRegistry.register(validTheme({ id: 'c', label: 'C', category: 'dark', isDark: true })); + expect(ThemeRegistry.lightThemes().map((t) => t.id)).toEqual(['a', 'b']); + expect(ThemeRegistry.darkThemes().map((t) => t.id)).toEqual(['c']); + }); +});