Use semantic colors in themes (#5796)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Miguel Solorio
2025-08-07 16:11:35 -07:00
committed by GitHub
parent 4f2974dbfe
commit 785ee5d59a
22 changed files with 396 additions and 198 deletions

View File

@@ -5,6 +5,7 @@
*/
import { type ColorsTheme, Theme } from './theme.js';
import { lightSemanticColors } from './semantic-tokens.js';
const ansiLightColors: ColorsTheme = {
type: 'light',
@@ -145,4 +146,5 @@ export const ANSILight: Theme = new Theme(
},
},
ansiLightColors,
lightSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { type ColorsTheme, Theme } from './theme.js';
import { darkSemanticColors } from './semantic-tokens.js';
const ansiColors: ColorsTheme = {
type: 'dark',
@@ -154,4 +155,5 @@ export const ANSI: Theme = new Theme(
},
},
ansiColors,
darkSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { type ColorsTheme, Theme } from './theme.js';
import { darkSemanticColors } from './semantic-tokens.js';
const atomOneDarkColors: ColorsTheme = {
type: 'dark',
@@ -142,4 +143,5 @@ export const AtomOneDark: Theme = new Theme(
},
},
atomOneDarkColors,
darkSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { type ColorsTheme, Theme } from './theme.js';
import { lightSemanticColors } from './semantic-tokens.js';
const ayuLightColors: ColorsTheme = {
type: 'light',
@@ -134,4 +135,5 @@ export const AyuLight: Theme = new Theme(
},
},
ayuLightColors,
lightSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { type ColorsTheme, Theme } from './theme.js';
import { darkSemanticColors } from './semantic-tokens.js';
const ayuDarkColors: ColorsTheme = {
type: 'dark',
@@ -108,4 +109,5 @@ export const AyuDark: Theme = new Theme(
},
},
ayuDarkColors,
darkSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { lightTheme, Theme } from './theme.js';
import { lightSemanticColors } from './semantic-tokens.js';
export const DefaultLight: Theme = new Theme(
'Default Light',
@@ -103,4 +104,5 @@ export const DefaultLight: Theme = new Theme(
},
},
lightTheme,
lightSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { darkTheme, Theme } from './theme.js';
import { darkSemanticColors } from './semantic-tokens.js';
export const DefaultDark: Theme = new Theme(
'Default',
@@ -146,4 +147,5 @@ export const DefaultDark: Theme = new Theme(
},
},
darkTheme,
darkSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { type ColorsTheme, Theme } from './theme.js';
import { darkSemanticColors } from './semantic-tokens.js';
const draculaColors: ColorsTheme = {
type: 'dark',
@@ -119,4 +120,5 @@ export const Dracula: Theme = new Theme(
},
},
draculaColors,
darkSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { type ColorsTheme, Theme } from './theme.js';
import { darkSemanticColors } from './semantic-tokens.js';
const githubDarkColors: ColorsTheme = {
type: 'dark',
@@ -142,4 +143,5 @@ export const GitHubDark: Theme = new Theme(
},
},
githubDarkColors,
darkSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { type ColorsTheme, Theme } from './theme.js';
import { lightSemanticColors } from './semantic-tokens.js';
const githubLightColors: ColorsTheme = {
type: 'light',
@@ -144,4 +145,5 @@ export const GitHubLight: Theme = new Theme(
},
},
githubLightColors,
lightSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { lightTheme, Theme, type ColorsTheme } from './theme.js';
import { lightSemanticColors } from './semantic-tokens.js';
const googleCodeColors: ColorsTheme = {
type: 'light',
@@ -141,4 +142,5 @@ export const GoogleCode: Theme = new Theme(
},
},
googleCodeColors,
lightSemanticColors,
);

View File

@@ -5,6 +5,7 @@
*/
import { Theme, ColorsTheme } from './theme.js';
import { SemanticColors } from './semantic-tokens.js';
const noColorColorsTheme: ColorsTheme = {
type: 'ansi',
@@ -23,6 +24,36 @@ const noColorColorsTheme: ColorsTheme = {
Gray: '',
};
const noColorSemanticColors: SemanticColors = {
text: {
primary: '',
secondary: '',
link: '',
accent: '',
},
background: {
primary: '',
diff: {
added: '',
removed: '',
},
},
border: {
default: '',
focused: '',
},
ui: {
comment: '',
symbol: '',
gradient: [],
},
status: {
error: '',
success: '',
warning: '',
},
};
export const NoColorTheme: Theme = new Theme(
'NoColor',
'dark',
@@ -90,4 +121,5 @@ export const NoColorTheme: Theme = new Theme(
},
},
noColorColorsTheme,
noColorSemanticColors,
);

View File

@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { lightTheme, darkTheme, ansiTheme } from './theme.js';
export interface SemanticColors {
text: {
primary: string;
secondary: string;
link: string;
accent: string;
};
background: {
primary: string;
diff: {
added: string;
removed: string;
};
};
border: {
default: string;
focused: string;
};
ui: {
comment: string;
symbol: string;
gradient: string[] | undefined;
};
status: {
error: string;
success: string;
warning: string;
};
}
export const lightSemanticColors: SemanticColors = {
text: {
primary: lightTheme.Foreground,
secondary: lightTheme.Gray,
link: lightTheme.AccentBlue,
accent: lightTheme.AccentPurple,
},
background: {
primary: lightTheme.Background,
diff: {
added: lightTheme.DiffAdded,
removed: lightTheme.DiffRemoved,
},
},
border: {
default: lightTheme.Gray,
focused: lightTheme.AccentBlue,
},
ui: {
comment: lightTheme.Comment,
symbol: lightTheme.Gray,
gradient: lightTheme.GradientColors,
},
status: {
error: lightTheme.AccentRed,
success: lightTheme.AccentGreen,
warning: lightTheme.AccentYellow,
},
};
export const darkSemanticColors: SemanticColors = {
text: {
primary: darkTheme.Foreground,
secondary: darkTheme.Gray,
link: darkTheme.AccentBlue,
accent: darkTheme.AccentPurple,
},
background: {
primary: darkTheme.Background,
diff: {
added: darkTheme.DiffAdded,
removed: darkTheme.DiffRemoved,
},
},
border: {
default: darkTheme.Gray,
focused: darkTheme.AccentBlue,
},
ui: {
comment: darkTheme.Comment,
symbol: darkTheme.Gray,
gradient: darkTheme.GradientColors,
},
status: {
error: darkTheme.AccentRed,
success: darkTheme.AccentGreen,
warning: darkTheme.AccentYellow,
},
};
export const ansiSemanticColors: SemanticColors = {
text: {
primary: ansiTheme.Foreground,
secondary: ansiTheme.Gray,
link: ansiTheme.AccentBlue,
accent: ansiTheme.AccentPurple,
},
background: {
primary: ansiTheme.Background,
diff: {
added: ansiTheme.DiffAdded,
removed: ansiTheme.DiffRemoved,
},
},
border: {
default: ansiTheme.Gray,
focused: ansiTheme.AccentBlue,
},
ui: {
comment: ansiTheme.Comment,
symbol: ansiTheme.Gray,
gradient: ansiTheme.GradientColors,
},
status: {
error: ansiTheme.AccentRed,
success: ansiTheme.AccentGreen,
warning: ansiTheme.AccentYellow,
},
};

View File

@@ -9,6 +9,7 @@
* @author Ahmad Awais <https://twitter.com/mrahmadawais/>
*/
import { type ColorsTheme, Theme } from './theme.js';
import { darkSemanticColors } from './semantic-tokens.js';
const shadesOfPurpleColors: ColorsTheme = {
type: 'dark',
@@ -347,4 +348,5 @@ export const ShadesOfPurple = new Theme(
},
},
shadesOfPurpleColors,
darkSemanticColors,
);

View File

@@ -44,15 +44,6 @@ describe('ThemeManager', () => {
expect(themeManager.isCustomTheme('MyCustomTheme')).toBe(true);
});
it('should not load invalid custom themes', () => {
const invalidTheme = { ...validCustomTheme, Background: 'not-a-color' };
themeManager.loadCustomThemes({
InvalidTheme: invalidTheme as unknown as CustomTheme,
});
expect(themeManager.getCustomThemeNames()).not.toContain('InvalidTheme');
expect(themeManager.isCustomTheme('InvalidTheme')).toBe(false);
});
it('should set and get the active theme', () => {
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
themeManager.setActiveTheme('Ayu');

View File

@@ -22,6 +22,7 @@ import {
createCustomTheme,
validateCustomTheme,
} from './theme.js';
import { SemanticColors } from './semantic-tokens.js';
import { ANSI } from './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.js';
@@ -134,6 +135,14 @@ class ThemeManager {
return this.activeTheme;
}
/**
* Gets the semantic colors for the active theme.
* @returns The semantic colors.
*/
getSemanticColors(): SemanticColors {
return this.getActiveTheme().semanticColors;
}
/**
* Gets a list of custom theme names.
* @returns Array of custom theme names.

View File

@@ -36,25 +36,6 @@ describe('validateCustomTheme', () => {
expect(result.error).toBeUndefined();
});
it('should return isValid: false for a theme with a missing required field', () => {
const invalidTheme = {
...validTheme,
name: undefined as unknown as string,
};
const result = validateCustomTheme(invalidTheme);
expect(result.isValid).toBe(false);
expect(result.error).toBe('Missing required field: name');
});
it('should return isValid: false for a theme with an invalid color format', () => {
const invalidTheme = { ...validTheme, Background: 'not-a-color' };
const result = validateCustomTheme(invalidTheme);
expect(result.isValid).toBe(false);
expect(result.error).toBe(
'Invalid color format for Background: not-a-color',
);
});
it('should return isValid: false for a theme with an invalid name', () => {
const invalidTheme = { ...validTheme, name: ' ' };
const result = validateCustomTheme(invalidTheme);
@@ -71,37 +52,6 @@ describe('validateCustomTheme', () => {
expect(result.error).toBeUndefined();
});
it('should return a warning if DiffAdded and DiffRemoved are missing', () => {
const legacyTheme: Partial<CustomTheme> = { ...validTheme };
delete legacyTheme.DiffAdded;
delete legacyTheme.DiffRemoved;
const result = validateCustomTheme(legacyTheme);
expect(result.isValid).toBe(true);
expect(result.warning).toBe('Missing field(s) DiffAdded, DiffRemoved');
});
it('should return a warning if only DiffRemoved is missing', () => {
const legacyTheme: Partial<CustomTheme> = { ...validTheme };
delete legacyTheme.DiffRemoved;
const result = validateCustomTheme(legacyTheme);
expect(result.isValid).toBe(true);
expect(result.warning).toBe('Missing field(s) DiffRemoved');
});
it('should return isValid: false for a theme with an invalid DiffAdded color', () => {
const invalidTheme = { ...validTheme, DiffAdded: 'invalid' };
const result = validateCustomTheme(invalidTheme);
expect(result.isValid).toBe(false);
expect(result.error).toBe('Invalid color format for DiffAdded: invalid');
});
it('should return isValid: false for a theme with an invalid DiffRemoved color', () => {
const invalidTheme = { ...validTheme, DiffRemoved: 'invalid' };
const result = validateCustomTheme(invalidTheme);
expect(result.isValid).toBe(false);
expect(result.error).toBe('Invalid color format for DiffRemoved: invalid');
});
it('should return isValid: false for a theme with a very long name', () => {
const invalidTheme = { ...validTheme, name: 'a'.repeat(51) };
const result = validateCustomTheme(invalidTheme);

View File

@@ -5,7 +5,8 @@
*/
import type { CSSProperties } from 'react';
import { isValidColor, resolveColor } from './color-utils.js';
import { SemanticColors } from './semantic-tokens.js';
import { resolveColor } from './color-utils.js';
export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom';
@@ -27,9 +28,53 @@ export interface ColorsTheme {
GradientColors?: string[];
}
export interface CustomTheme extends ColorsTheme {
export interface CustomTheme {
type: 'custom';
name: string;
text?: {
primary?: string;
secondary?: string;
link?: string;
accent?: string;
};
background?: {
primary?: string;
diff?: {
added?: string;
removed?: string;
};
};
border?: {
default?: string;
focused?: string;
};
ui?: {
comment?: string;
symbol?: string;
gradient?: string[];
};
status?: {
error?: string;
success?: string;
warning?: string;
};
// Legacy properties (all optional)
Background?: string;
Foreground?: string;
LightBlue?: string;
AccentBlue?: string;
AccentPurple?: string;
AccentCyan?: string;
AccentGreen?: string;
AccentYellow?: string;
AccentRed?: string;
DiffAdded?: string;
DiffRemoved?: string;
Comment?: string;
Gray?: string;
GradientColors?: string[];
}
export const lightTheme: ColorsTheme = {
@@ -107,6 +152,7 @@ export class Theme {
readonly type: ThemeType,
rawMappings: Record<string, CSSProperties>,
readonly colors: ColorsTheme,
readonly semanticColors: SemanticColors,
) {
this._colorMap = Object.freeze(this._buildColorMap(rawMappings)); // Build and freeze the map
@@ -174,107 +220,127 @@ export class Theme {
* @returns A new Theme instance.
*/
export function createCustomTheme(customTheme: CustomTheme): Theme {
const colors: ColorsTheme = {
type: 'custom',
Background: customTheme.background?.primary ?? customTheme.Background ?? '',
Foreground: customTheme.text?.primary ?? customTheme.Foreground ?? '',
LightBlue: customTheme.text?.link ?? customTheme.LightBlue ?? '',
AccentBlue: customTheme.text?.link ?? customTheme.AccentBlue ?? '',
AccentPurple: customTheme.text?.accent ?? customTheme.AccentPurple ?? '',
AccentCyan: customTheme.text?.link ?? customTheme.AccentCyan ?? '',
AccentGreen: customTheme.status?.success ?? customTheme.AccentGreen ?? '',
AccentYellow: customTheme.status?.warning ?? customTheme.AccentYellow ?? '',
AccentRed: customTheme.status?.error ?? customTheme.AccentRed ?? '',
DiffAdded:
customTheme.background?.diff?.added ?? customTheme.DiffAdded ?? '',
DiffRemoved:
customTheme.background?.diff?.removed ?? customTheme.DiffRemoved ?? '',
Comment: customTheme.ui?.comment ?? customTheme.Comment ?? '',
Gray: customTheme.text?.secondary ?? customTheme.Gray ?? '',
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
};
// Generate CSS properties mappings based on the custom theme colors
const rawMappings: Record<string, CSSProperties> = {
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
background: customTheme.Background,
color: customTheme.Foreground,
background: colors.Background,
color: colors.Foreground,
},
'hljs-keyword': {
color: customTheme.AccentBlue,
color: colors.AccentBlue,
},
'hljs-literal': {
color: customTheme.AccentBlue,
color: colors.AccentBlue,
},
'hljs-symbol': {
color: customTheme.AccentBlue,
color: colors.AccentBlue,
},
'hljs-name': {
color: customTheme.AccentBlue,
color: colors.AccentBlue,
},
'hljs-link': {
color: customTheme.AccentBlue,
color: colors.AccentBlue,
textDecoration: 'underline',
},
'hljs-built_in': {
color: customTheme.AccentCyan,
color: colors.AccentCyan,
},
'hljs-type': {
color: customTheme.AccentCyan,
color: colors.AccentCyan,
},
'hljs-number': {
color: customTheme.AccentGreen,
color: colors.AccentGreen,
},
'hljs-class': {
color: customTheme.AccentGreen,
color: colors.AccentGreen,
},
'hljs-string': {
color: customTheme.AccentYellow,
color: colors.AccentYellow,
},
'hljs-meta-string': {
color: customTheme.AccentYellow,
color: colors.AccentYellow,
},
'hljs-regexp': {
color: customTheme.AccentRed,
color: colors.AccentRed,
},
'hljs-template-tag': {
color: customTheme.AccentRed,
color: colors.AccentRed,
},
'hljs-subst': {
color: customTheme.Foreground,
color: colors.Foreground,
},
'hljs-function': {
color: customTheme.Foreground,
color: colors.Foreground,
},
'hljs-title': {
color: customTheme.Foreground,
color: colors.Foreground,
},
'hljs-params': {
color: customTheme.Foreground,
color: colors.Foreground,
},
'hljs-formula': {
color: customTheme.Foreground,
color: colors.Foreground,
},
'hljs-comment': {
color: customTheme.Comment,
color: colors.Comment,
fontStyle: 'italic',
},
'hljs-quote': {
color: customTheme.Comment,
color: colors.Comment,
fontStyle: 'italic',
},
'hljs-doctag': {
color: customTheme.Comment,
color: colors.Comment,
},
'hljs-meta': {
color: customTheme.Gray,
color: colors.Gray,
},
'hljs-meta-keyword': {
color: customTheme.Gray,
color: colors.Gray,
},
'hljs-tag': {
color: customTheme.Gray,
color: colors.Gray,
},
'hljs-variable': {
color: customTheme.AccentPurple,
color: colors.AccentPurple,
},
'hljs-template-variable': {
color: customTheme.AccentPurple,
color: colors.AccentPurple,
},
'hljs-attr': {
color: customTheme.LightBlue,
color: colors.LightBlue,
},
'hljs-attribute': {
color: customTheme.LightBlue,
color: colors.LightBlue,
},
'hljs-builtin-name': {
color: customTheme.LightBlue,
color: colors.LightBlue,
},
'hljs-section': {
color: customTheme.AccentYellow,
color: colors.AccentYellow,
},
'hljs-emphasis': {
fontStyle: 'italic',
@@ -283,36 +349,72 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
fontWeight: 'bold',
},
'hljs-bullet': {
color: customTheme.AccentYellow,
color: colors.AccentYellow,
},
'hljs-selector-tag': {
color: customTheme.AccentYellow,
color: colors.AccentYellow,
},
'hljs-selector-id': {
color: customTheme.AccentYellow,
color: colors.AccentYellow,
},
'hljs-selector-class': {
color: customTheme.AccentYellow,
color: colors.AccentYellow,
},
'hljs-selector-attr': {
color: customTheme.AccentYellow,
color: colors.AccentYellow,
},
'hljs-selector-pseudo': {
color: customTheme.AccentYellow,
color: colors.AccentYellow,
},
'hljs-addition': {
backgroundColor: customTheme.AccentGreen,
backgroundColor: colors.AccentGreen,
display: 'inline-block',
width: '100%',
},
'hljs-deletion': {
backgroundColor: customTheme.AccentRed,
backgroundColor: colors.AccentRed,
display: 'inline-block',
width: '100%',
},
};
return new Theme(customTheme.name, 'custom', rawMappings, customTheme);
const semanticColors: SemanticColors = {
text: {
primary: colors.Foreground,
secondary: colors.Gray,
link: colors.AccentBlue,
accent: colors.AccentPurple,
},
background: {
primary: colors.Background,
diff: {
added: colors.DiffAdded,
removed: colors.DiffRemoved,
},
},
border: {
default: colors.Gray,
focused: colors.AccentBlue,
},
ui: {
comment: colors.Comment,
symbol: colors.Gray,
gradient: colors.GradientColors,
},
status: {
error: colors.AccentRed,
success: colors.AccentGreen,
warning: colors.AccentYellow,
},
};
return new Theme(
customTheme.name,
'custom',
rawMappings,
colors,
semanticColors,
);
}
/**
@@ -325,74 +427,7 @@ export function validateCustomTheme(customTheme: Partial<CustomTheme>): {
error?: string;
warning?: string;
} {
// Check required fields
const requiredFields: Array<keyof CustomTheme> = [
'name',
'Background',
'Foreground',
'LightBlue',
'AccentBlue',
'AccentPurple',
'AccentCyan',
'AccentGreen',
'AccentYellow',
'AccentRed',
// 'DiffAdded' and 'DiffRemoved' are not required as they were added after
// the theme format was defined.
'Comment',
'Gray',
];
const recommendedFields: Array<keyof CustomTheme> = [
'DiffAdded',
'DiffRemoved',
];
for (const field of requiredFields) {
if (!customTheme[field]) {
return {
isValid: false,
error: `Missing required field: ${field}`,
};
}
}
const missingFields: string[] = [];
for (const field of recommendedFields) {
if (!customTheme[field]) {
missingFields.push(field);
}
}
// Validate color format (basic hex validation)
const colorFields: Array<keyof CustomTheme> = [
'Background',
'Foreground',
'LightBlue',
'AccentBlue',
'AccentPurple',
'AccentCyan',
'AccentGreen',
'AccentYellow',
'AccentRed',
'DiffAdded',
'DiffRemoved',
'Comment',
'Gray',
];
for (const field of colorFields) {
const color = customTheme[field] as string | undefined;
if (color !== undefined && !isValidColor(color)) {
return {
isValid: false,
error: `Invalid color format for ${field}: ${color}`,
};
}
}
// Validate theme name
// Since all fields are optional, we only need to validate the name.
if (customTheme.name && !isValidThemeName(customTheme.name)) {
return {
isValid: false,
@@ -402,10 +437,6 @@ export function validateCustomTheme(customTheme: Partial<CustomTheme>): {
return {
isValid: true,
warning:
missingFields.length > 0
? `Missing field(s) ${missingFields.join(', ')}`
: undefined,
};
}

View File

@@ -5,6 +5,7 @@
*/
import { type ColorsTheme, Theme } from './theme.js';
import { lightSemanticColors } from './semantic-tokens.js';
const xcodeColors: ColorsTheme = {
type: 'light',
@@ -149,4 +150,5 @@ export const XCode: Theme = new Theme(
},
},
xcodeColors,
lightSemanticColors,
);