diff --git a/docs/architecture/renderer.md b/docs/architecture/renderer.md index 42a8988e..1a7fe203 100644 --- a/docs/architecture/renderer.md +++ b/docs/architecture/renderer.md @@ -20,6 +20,7 @@ boundaries. Individual panels and hooks are catalogued only enough to locate the - `src/renderer/index.tsx` — process entry: imports `monaco-setup`, mounts `` in `React.StrictMode` via `createRoot`, and pulls in dockview + theme CSS (`index.tsx:14`). - `src/renderer/App.tsx` — the single stateful container. Composes ~30 hooks into one `DockAppState` object and renders `` + `` (`App.tsx:42`, `:256`). - `src/renderer/AppShell.tsx` — presentational shell: title bar, the activity-bar icon rail + `DockviewReact` host (side by side in `.layout-workbench`), status bar, and all modals/overlays/toasts (`AppShell.tsx:80`). +- `src/renderer/components/TitleBar.tsx` — the window title bar. Carries the theme controls at its trailing edge: a family ` onSelectThemeFamily(e.target.value)} + onMouseEnter={() => setThemesHovered(true)} + onMouseLeave={() => setThemesHovered(false)} + style={{ ...styles.themesSelect, ...(themesHovered ? styles.themesSelectHover : undefined) }} + aria-label="Theme" + > + {getThemeFamilies().map((family) => ( + + ))} + + + )} + {onToggleTheme && ( + + )} + ) } diff --git a/src/shared/themes/registry.test.ts b/src/shared/themes/registry.test.ts index 8e2dd153..7d150e82 100644 --- a/src/shared/themes/registry.test.ts +++ b/src/shared/themes/registry.test.ts @@ -1,4 +1,4 @@ -import { getThemeList, loadTheme, migrateLegacyTheme } from './registry' +import { getThemeList, loadTheme, migrateLegacyTheme, getThemeFamilies, themeFamilyOf } from './registry' import { DEFAULT_SETTINGS } from '../defaults' describe('first-run default theme', () => { @@ -38,6 +38,39 @@ describe('retired Royal ids', () => { }) }) +describe('theme families', () => { + it('strips the variant suffix', () => { + expect(themeFamilyOf('jade-light')).toBe('jade') + expect(themeFamilyOf('manifold-dark')).toBe('manifold') + // Family ids are already suffix-free and must survive a round trip. + expect(themeFamilyOf('jade')).toBe('jade') + }) + + it('collapses each dark/light pair into one entry', () => { + const families = getThemeFamilies() + const ids = families.map((f) => f.id) + + expect(ids).toEqual(['manifold', 'garfield', 'neon', 'jade', 'platinum']) + expect(families.map((f) => f.label)).toEqual(['Manifold', 'Garfield', 'Neon', 'Jade', 'Platinum']) + expect(ids).not.toContain('royal') + }) + + // The list is derived so it cannot outlive the themes it names — the previous + // hardcoded copy kept offering Royal after that family was retired. + it('stays in step with the shipped theme list', () => { + const fromThemes = new Set(getThemeList().map((t) => themeFamilyOf(t.id))) + expect(new Set(getThemeFamilies().map((f) => f.id))).toEqual(fromThemes) + }) + + it('names a real theme when a family is combined with either variant', () => { + const ids = getThemeList().map((t) => t.id) + for (const family of getThemeFamilies()) { + expect(ids).toContain(`${family.id}-dark`) + expect(ids).toContain(`${family.id}-light`) + } + }) +}) + describe('custom themes', () => { it.each([ 'manifold-dark', diff --git a/src/shared/themes/registry.ts b/src/shared/themes/registry.ts index 192fbc4c..b74739ae 100644 --- a/src/shared/themes/registry.ts +++ b/src/shared/themes/registry.ts @@ -39,6 +39,30 @@ export function getThemeList(): ThemeMeta[] { return cachedList } +/** A theme id with its variant suffix removed: `jade-light` → `jade`. */ +export function themeFamilyOf(themeId: string): string { + return themeId.replace(/-(dark|light)$/, '') +} + +/** + * The shipped theme families, in registration order. Derived from the theme list + * rather than hardcoded: the title bar's previous hardcoded list kept offering + * Royal after that family was retired, which is exactly the drift this avoids. + */ +export function getThemeFamilies(): ThemeMeta[] { + const families: ThemeMeta[] = [] + const seen = new Set() + + for (const { id, label, type } of getThemeList()) { + const familyId = themeFamilyOf(id) + if (seen.has(familyId)) continue + seen.add(familyId) + families.push({ id: familyId, label: label.replace(/ (Dark|Light)$/, ''), type }) + } + + return families +} + export function loadTheme(id: string): ConvertedTheme { const cached = themeCache.get(id) if (cached) return cached