diff --git a/client/src/Providers/DeploymentTheme.tsx b/client/src/Providers/DeploymentTheme.tsx index fadafaeb2bd..d34b3104178 100644 --- a/client/src/Providers/DeploymentTheme.tsx +++ b/client/src/Providers/DeploymentTheme.tsx @@ -8,8 +8,8 @@ import { createContext, useLayoutEffect, } from 'react'; -import { QueryKeys } from 'librechat-data-provider'; import { notifyManager, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys, isBundledThemeName } from 'librechat-data-provider'; import { ThemeProvider, clickHouseTheme, @@ -17,15 +17,15 @@ import { fromLegacyTheme, validateThemeDefinition, } from '@librechat/client'; +import type { TInterfaceConfig, BundledThemeName } from 'librechat-data-provider'; import type { IThemeRGB, ThemeDefinition } from '@librechat/client'; -import type { TInterfaceConfig } from 'librechat-data-provider'; import type { ComponentProps } from 'react'; import { getThemeFromEnv } from '~/utils/getThemeFromEnv'; import { useGetStartupConfig } from '~/data-provider'; type DeploymentThemeValue = TInterfaceConfig['theme']; -const bundledThemes: Readonly> = { +const bundledThemes: Readonly> = { librechat: libreChatTheme, clickhouse: clickHouseTheme, }; @@ -41,7 +41,7 @@ export function resolveDeploymentTheme(theme: DeploymentThemeValue): ThemeDefini } if (typeof theme === 'string') { - const definition = Object.hasOwn(bundledThemes, theme) ? bundledThemes[theme] : undefined; + const definition = isBundledThemeName(theme) ? bundledThemes[theme] : undefined; if (!definition) { console.warn(`[DeploymentTheme] Ignoring unknown interface.theme "${theme}"`); } diff --git a/e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts b/e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts new file mode 100644 index 00000000000..66c07213b83 --- /dev/null +++ b/e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts @@ -0,0 +1,129 @@ +import { resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { inOneProject, repoRoot, run } from './lint.helpers'; + +/** + * A mistake in `interface.theme` used to fail the whole librechat.yaml and stop the server. The + * config loader that `/api` wires up now drops only the theme, names each problem by its path, and + * loads everything else. The mock lane serves one shared yaml, so these load their own fixtures + * through that same loader in a child process, the way the server does at startup and on reload. + */ +test.describe.configure({ timeout: 120_000 }); + +const fixtures = resolve(repoRoot, 'packages/api/src/app/__fixtures__/theme'); +const RESULT = '__THEME_SCENARIO_RESULT__'; +/** The logger colours its console output. */ +const ANSI_COLOR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +const loadScript = ` +const load = require('./server/services/Config/loadCustomConfig'); +process.exit = (code) => { + console.log('${RESULT}' + JSON.stringify({ exited: code })); + process.reallyExit(0); +}; +load(false, { mode: process.argv[1] }).then( + (config) => { + console.log('${RESULT}' + JSON.stringify({ interface: config && config.interface })); + process.reallyExit(0); + }, + (error) => { + console.log('${RESULT}' + JSON.stringify({ rejected: error.name })); + process.reallyExit(0); + }, +); +`; + +type LoadOutcome = { + interface?: { theme?: unknown; modelSelect?: boolean }; + exited?: number; + rejected?: string; +}; + +function loadFixture(name: string, mode: 'startup' | 'reload' = 'startup') { + process.env.CONFIG_PATH = resolve(fixtures, `${name}.yaml`); + const result = run('node', ['-e', loadScript, mode], { cwd: resolve(repoRoot, 'api') }); + const log = result.output.replace(ANSI_COLOR, ''); + const line = log.split('\n').find((entry) => entry.startsWith(RESULT)); + if (!line) { + throw new Error(`The loader printed no result:\n${log}`); + } + return { outcome: JSON.parse(line.slice(RESULT.length)) as LoadOutcome, log }; +} + +test.describe('interface.theme in librechat.yaml', () => { + test.beforeEach(() => inOneProject()); + test.afterEach(() => { + delete process.env.CONFIG_PATH; + }); + + test('a typo in a theme token leaves the server running on the default theme @scenario:yaml-theme-typo-falls-back', () => { + const { outcome, log } = loadFixture('unknown-token'); + + expect(outcome.exited).toBeUndefined(); + expect(outcome.interface).toEqual({ modelSelect: true }); + expect(log).toContain('the default theme applies instead'); + expect(log).toContain( + 'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown color token: rgb-surfce-secondary', + ); + expect(log).toContain( + 'interface.theme.modes.light.colors.surface-tertiary: Unknown color token: surface-tertiary', + ); + }); + + test('a bad token value is reported with its path and the theme is dropped @scenario:yaml-theme-bad-value-reported', () => { + const { outcome, log } = loadFixture('bad-value'); + + expect(outcome.exited).toBeUndefined(); + expect(outcome.interface).toEqual({ modelSelect: true }); + expect(log).toContain( + 'interface.theme.modes.dark.colors.rgb-surface-primary: Invalid RGB value for rgb-surface-primary: 300 16 32', + ); + expect(log).toContain( + 'interface.theme.modes.dark.appearance.controlRadius: Invalid appearance value for controlRadius: 4 pixels', + ); + }); + + test('a misspelled bundled theme name falls back with a warning @scenario:yaml-theme-unknown-name-falls-back', () => { + const { outcome, log } = loadFixture('unknown-name'); + + expect(outcome.exited).toBeUndefined(); + expect(outcome.interface).toEqual({ modelSelect: true }); + expect(log).toContain( + 'interface.theme: Unknown bundled theme "clickhous", expected one of: librechat, clickhouse', + ); + expect(loadFixture('bundled').outcome.interface?.theme).toBe('clickhouse'); + }); + + test('a valid inline theme loads exactly as written @scenario:yaml-theme-valid-unchanged', () => { + const { outcome, log } = loadFixture('valid'); + + expect(outcome.interface?.theme).toEqual({ + version: 1, + name: 'acme', + modes: { + light: { + colors: { 'rgb-surface-primary': '240 244 255' }, + appearance: { controlRadius: '0.25rem' }, + }, + dark: { colors: { 'rgb-surface-primary': '12 16 32' } }, + }, + brands: { 'provider-openai': '#10a37f' }, + }); + expect(log).not.toContain('interface.theme'); + }); + + test('a config reload with a broken theme applies the same fallback @scenario:yaml-theme-reload-falls-back', () => { + const { outcome, log } = loadFixture('unknown-token', 'reload'); + + expect(outcome.rejected).toBeUndefined(); + expect(outcome.interface).toEqual({ modelSelect: true }); + expect(log).toContain('interface.theme.modes.light.colors.rgb-surfce-secondary'); + }); + + test('an invalid key outside the theme still stops startup @scenario:yaml-theme-other-errors-still-exit', () => { + const { outcome } = loadFixture('unrelated-error'); + + expect(outcome.exited).toBe(1); + expect(loadFixture('unrelated-error', 'reload').outcome.rejected).toBe('ConfigReloadError'); + }); +}); diff --git a/packages/api/src/app/__fixtures__/theme/bad-value.yaml b/packages/api/src/app/__fixtures__/theme/bad-value.yaml new file mode 100644 index 00000000000..1dd4e4704e0 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/bad-value.yaml @@ -0,0 +1,13 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + dark: + colors: + rgb-surface-primary: '300 16 32' + appearance: + controlRadius: 4 pixels diff --git a/packages/api/src/app/__fixtures__/theme/bundled.yaml b/packages/api/src/app/__fixtures__/theme/bundled.yaml new file mode 100644 index 00000000000..7e3a6c8ebbf --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/bundled.yaml @@ -0,0 +1,5 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: clickhouse diff --git a/packages/api/src/app/__fixtures__/theme/malformed.yaml b/packages/api/src/app/__fixtures__/theme/malformed.yaml new file mode 100644 index 00000000000..e1ac0b825e0 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/malformed.yaml @@ -0,0 +1,7 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + - acme + - clickhouse diff --git a/packages/api/src/app/__fixtures__/theme/spacing.yaml b/packages/api/src/app/__fixtures__/theme/spacing.yaml new file mode 100644 index 00000000000..9f8da1036ff --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/spacing.yaml @@ -0,0 +1,11 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + light: + colors: + rgb-surface-primary: '240 244 255' diff --git a/packages/api/src/app/__fixtures__/theme/unknown-appearance.yaml b/packages/api/src/app/__fixtures__/theme/unknown-appearance.yaml new file mode 100644 index 00000000000..690277e45e0 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/unknown-appearance.yaml @@ -0,0 +1,12 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + light: + appearance: + controlRadius: 2px + futureSpacing: 3rem diff --git a/packages/api/src/app/__fixtures__/theme/unknown-name.yaml b/packages/api/src/app/__fixtures__/theme/unknown-name.yaml new file mode 100644 index 00000000000..7881ecb4821 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/unknown-name.yaml @@ -0,0 +1,5 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: clickhous diff --git a/packages/api/src/app/__fixtures__/theme/unknown-token.yaml b/packages/api/src/app/__fixtures__/theme/unknown-token.yaml new file mode 100644 index 00000000000..c8cdec00ba2 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/unknown-token.yaml @@ -0,0 +1,13 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + light: + colors: + rgb-surface-primary: '240 244 255' + rgb-surfce-secondary: '250 250 250' + surface-tertiary: '245 245 245' diff --git a/packages/api/src/app/__fixtures__/theme/unrelated-error.yaml b/packages/api/src/app/__fixtures__/theme/unrelated-error.yaml new file mode 100644 index 00000000000..353ecee5477 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/unrelated-error.yaml @@ -0,0 +1,12 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + light: + colors: + rgb-surfce-primary: '240 244 255' +notAConfigKey: true diff --git a/packages/api/src/app/__fixtures__/theme/valid.yaml b/packages/api/src/app/__fixtures__/theme/valid.yaml new file mode 100644 index 00000000000..e01463685ab --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/valid.yaml @@ -0,0 +1,18 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + light: + colors: + rgb-surface-primary: '240 244 255' + appearance: + controlRadius: 0.25rem + dark: + colors: + rgb-surface-primary: '12 16 32' + brands: + provider-openai: '#10a37f' diff --git a/packages/api/src/app/loader.spec.ts b/packages/api/src/app/loader.spec.ts new file mode 100644 index 00000000000..d0c523c6f21 --- /dev/null +++ b/packages/api/src/app/loader.spec.ts @@ -0,0 +1,165 @@ +import path from 'path'; +import { logger } from '@librechat/data-schemas'; +import type { TCustomConfig } from 'librechat-data-provider'; +import { createCustomConfigLoader, ConfigReloadError } from './loader'; +import { loadYaml } from '~/utils/yaml'; + +const fixture = (name: string) => path.join(__dirname, '__fixtures__', 'theme', `${name}.yaml`); + +const raw = (name: string) => loadYaml(fixture(name)) as TCustomConfig; + +const createLoader = () => + createCustomConfigLoader({ + loadLocal: loadYaml, + defaultConfigPath: fixture('valid'), + redactConfig: (config: TCustomConfig) => config, + }); + +const warnings = (spy: jest.SpyInstance): string => + spy.mock.calls.map(([message]) => String(message)).join('\n'); + +describe('createCustomConfigLoader interface.theme', () => { + const originalConfigPath = process.env.CONFIG_PATH; + let warn: jest.SpyInstance; + let exit: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(logger, 'warn'); + jest.spyOn(logger, 'error').mockImplementation(() => logger); + jest.spyOn(logger, 'info').mockImplementation(() => logger); + exit = jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + if (originalConfigPath === undefined) { + delete process.env.CONFIG_PATH; + } else { + process.env.CONFIG_PATH = originalConfigPath; + } + }); + + const load = async (name: string, mode: 'startup' | 'reload' = 'startup') => { + process.env.CONFIG_PATH = fixture(name); + return createLoader()(false, { mode }); + }; + + it('returns a valid theme exactly as written, without warnings', async () => { + const config = await load('valid'); + + expect(config?.interface?.theme).toEqual(raw('valid').interface?.theme); + expect(config?.interface?.modelSelect).toBe(true); + expect(warn).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + }); + + it('drops a theme naming an unknown color token and keeps the rest of the config', async () => { + const config = await load('unknown-token'); + + expect(exit).not.toHaveBeenCalled(); + expect(config).not.toBeNull(); + expect(config?.interface).not.toHaveProperty('theme'); + expect(config?.interface?.modelSelect).toBe(true); + expect(config?.cache).toBe(true); + expect(warnings(warn)).toContain( + 'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown color token: rgb-surfce-secondary', + ); + expect(warnings(warn)).toContain( + 'interface.theme.modes.light.colors.surface-tertiary: Unknown color token: surface-tertiary', + ); + expect(warnings(warn)).toContain('the default theme applies instead'); + }); + + it('reports every bad value with its path and drops the theme', async () => { + const config = await load('bad-value'); + + expect(exit).not.toHaveBeenCalled(); + expect(config?.interface).not.toHaveProperty('theme'); + const logged = warnings(warn); + expect(logged).toContain( + 'interface.theme.modes.dark.colors.rgb-surface-primary: Invalid RGB value for rgb-surface-primary: 300 16 32', + ); + expect(logged).toContain( + 'interface.theme.modes.dark.appearance.controlRadius: Invalid appearance value for controlRadius: 4 pixels', + ); + }); + + it('drops a theme that is neither a name nor a definition', async () => { + const config = await load('malformed'); + + expect(exit).not.toHaveBeenCalled(); + expect(config?.interface).not.toHaveProperty('theme'); + expect(config?.interface?.modelSelect).toBe(true); + expect(warnings(warn)).toContain( + 'interface.theme: Expected a bundled theme name or an inline theme definition', + ); + }); + + it('drops a value the client reads but the config schema rejects, citing the schema path', async () => { + const config = await load('spacing'); + + expect(exit).not.toHaveBeenCalled(); + expect(config?.interface).not.toHaveProperty('theme'); + expect(warnings(warn)).toContain('interface.theme.modes.light.colors.rgb-surface-primary:'); + }); + + it('drops a bundled theme name that does not exist', async () => { + const config = await load('unknown-name'); + + expect(exit).not.toHaveBeenCalled(); + expect(config?.interface).not.toHaveProperty('theme'); + expect(config?.interface?.modelSelect).toBe(true); + expect(warnings(warn)).toContain( + 'interface.theme: Unknown bundled theme "clickhous", expected one of: librechat, clickhouse', + ); + }); + + it('keeps a bundled theme name', async () => { + const config = await load('bundled'); + + expect(config?.interface?.theme).toBe('clickhouse'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('keeps a theme whose only problem is an appearance token this version ignores', async () => { + const config = await load('unknown-appearance'); + + expect(config?.interface?.theme).toEqual(raw('unknown-appearance').interface?.theme); + expect(warnings(warn)).toContain( + 'interface.theme.modes.light.appearance.futureSpacing: Unknown light appearance token ignored: futureSpacing', + ); + expect(warnings(warn)).not.toContain('the default theme applies instead'); + }); + + it('still exits at startup when something other than the theme is invalid', async () => { + await expect(load('unrelated-error')).rejects.toThrow('process.exit(1)'); + expect(exit).toHaveBeenCalledWith(1); + expect(warnings(warn)).toContain('interface.theme.modes.light.colors.rgb-surfce-primary'); + }); + + describe('reload mode', () => { + it('applies the same fallback instead of rejecting the reload', async () => { + const config = await load('unknown-token', 'reload'); + + expect(config?.interface).not.toHaveProperty('theme'); + expect(config?.interface?.modelSelect).toBe(true); + expect(warnings(warn)).toContain( + 'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown color token', + ); + }); + + it('returns a valid theme unchanged', async () => { + const config = await load('valid', 'reload'); + + expect(config?.interface?.theme).toEqual(raw('valid').interface?.theme); + expect(warn).not.toHaveBeenCalled(); + }); + + it('still rejects a reload whose other keys are invalid', async () => { + await expect(load('unrelated-error', 'reload')).rejects.toBeInstanceOf(ConfigReloadError); + expect(exit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/api/src/app/loader.ts b/packages/api/src/app/loader.ts index 6365450c4ea..25cdad6cdc3 100644 --- a/packages/api/src/app/loader.ts +++ b/packages/api/src/app/loader.ts @@ -14,6 +14,7 @@ import { } from 'librechat-data-provider'; import type { TCustomConfig, TEndpoint } from 'librechat-data-provider'; import type { ZodIssue } from 'zod'; +import { checkConfigTheme } from './theme'; type CustomParams = NonNullable; type CustomParamDefinition = NonNullable[number]; @@ -154,6 +155,21 @@ function isRemoteConfigPath(configPath: string): boolean { return /^https?:\/\//.test(configPath); } +function reportThemeCheck(configPath: string, errors: string[], warnings: string[]): void { + if (errors.length > 0) { + logger.warn( + `Ignoring interface.theme in ${configPath}; the default theme applies instead:\n` + + errors.map((error) => `- ${error}`).join('\n'), + ); + } + if (warnings.length > 0) { + logger.warn( + `interface.theme in ${configPath} names tokens this version ignores:\n` + + warnings.map((warning) => `- ${warning}`).join('\n'), + ); + } +} + /** Creates one process-local custom-config reader. */ export function createCustomConfigLoader({ defaultConfigPath, @@ -242,6 +258,10 @@ export function createCustomConfigLoader({ } } + const themeCheck = checkConfigTheme(loadedConfig); + loadedConfig = themeCheck.config; + reportThemeCheck(configPath, themeCheck.errors, themeCheck.warnings); + setMaxSubagents(getConfiguredMaxSubagents(loadedConfig)); const result = configSchema.strict().safeParse(loadedConfig); if ( diff --git a/packages/api/src/app/theme.ts b/packages/api/src/app/theme.ts new file mode 100644 index 00000000000..f604e99078f --- /dev/null +++ b/packages/api/src/app/theme.ts @@ -0,0 +1,77 @@ +import { + bundledThemeNames, + collectThemeIssues, + isBundledThemeName, + isPlainThemeRecord, + deploymentThemeSchema, + collectThemeWarningIssues, +} from 'librechat-data-provider'; +import type { ThemeIssue } from 'librechat-data-provider'; + +const THEME_PATH = ['interface', 'theme']; + +export interface ConfigThemeCheck { + /** The config to validate and return: the input itself unless the theme had to be dropped. */ + config: unknown; + /** Why the theme was dropped, one `path: reason` line per problem. */ + errors: string[]; + /** Tokens the client will ignore while still applying the rest of the theme. */ + warnings: string[]; +} + +const formatIssue = ({ path, message }: ThemeIssue): string => + `${[...THEME_PATH, ...path].join('.')}: ${message}`; + +function collectErrors(theme: unknown): ThemeIssue[] { + if (typeof theme !== 'string' && !isPlainThemeRecord(theme)) { + return [{ path: [], message: 'Expected a bundled theme name or an inline theme definition' }]; + } + if (typeof theme === 'string') { + return isBundledThemeName(theme) + ? [] + : [ + { + path: [], + message: `Unknown bundled theme "${theme}", expected one of: ${bundledThemeNames.join(', ')}`, + }, + ]; + } + const issues = collectThemeIssues(theme); + if (issues.length > 0) { + return issues; + } + const result = deploymentThemeSchema.safeParse(theme); + if (result.success) { + return []; + } + return result.error.errors.map(({ path, message }) => ({ + path: path.map(String), + message, + })); +} + +/** + * Checks `interface.theme` with the rules the client applies before painting it. A theme the + * client would reject is removed, so the deployment falls back to the default theme and the rest + * of the config still loads; unknown appearance tokens, which the client drops on its own, only + * warn. A config without a theme, or with a valid one, is returned as the same object. + */ +export function checkConfigTheme(config: unknown): ConfigThemeCheck { + const unchanged: ConfigThemeCheck = { config, errors: [], warnings: [] }; + if (!isPlainThemeRecord(config) || !isPlainThemeRecord(config.interface)) { + return unchanged; + } + const interfaceConfig = config.interface; + if (!('theme' in interfaceConfig) || interfaceConfig.theme === undefined) { + return unchanged; + } + + const theme = interfaceConfig.theme; + const errors = collectErrors(theme).map(formatIssue); + if (errors.length === 0) { + return { config, errors, warnings: collectThemeWarningIssues(theme).map(formatIssue) }; + } + + const { theme: _dropped, ...rest } = interfaceConfig; + return { config: { ...config, interface: rest }, errors, warnings: [] }; +} diff --git a/packages/client/src/theme/registry.ts b/packages/client/src/theme/registry.ts index 3c8fac068e4..5a456097234 100644 --- a/packages/client/src/theme/registry.ts +++ b/packages/client/src/theme/registry.ts @@ -1,3 +1,17 @@ +import { + THEME_VERSION, + isThemeRGB, + collectThemeIssues, + isThemeAppearanceToken, + collectThemeWarningIssues, + themeColorTokens as sharedColorTokens, + themeBrandTokens as sharedBrandTokens, +} from 'librechat-data-provider'; +import type { + ThemeColorToken, + ThemeBrandToken, + ThemeAppearanceToken, +} from 'librechat-data-provider'; import type { IThemeAppearance, IThemeBrands, @@ -11,7 +25,8 @@ import type { import { highContrastDarkTheme, highContrastLightTheme } from './themes/highContrast'; import { defaultTheme } from './themes/default'; import { darkTheme } from './themes/dark'; -export const THEME_VERSION = 1 as const; + +export { THEME_VERSION }; /** * Compile-time guard: the categorical series scale is declared across three @@ -28,9 +43,21 @@ export type SeriesTokensAreDeclared = [ Assert>, ]; -export const themeColorTokens: readonly (keyof IThemeRGB)[] = Object.freeze( - Object.keys(defaultTheme) as Array, -); +type SameKeys = [A] extends [B] + ? [B] extends [A] + ? true + : false + : false; + +/** The token lists live in `librechat-data-provider` so the server validates against the same + * set; these fail the build when the client types drift from them. */ +export type SharedTokensMatchTypes = [ + Assert>, + Assert>, + Assert>, +]; + +export const themeColorTokens: readonly (keyof IThemeRGB)[] = sharedColorTokens; /** * What the verified mark is measured against: the fill it wore before it had a @@ -120,15 +147,7 @@ export const defaultAppearance: IThemeAppearance = Object.freeze({ motionNormal: '200ms', }); -export const themeBrandTokens: readonly (keyof IThemeBrands)[] = Object.freeze([ - 'provider-openai', - 'provider-openai-gpt4', - 'provider-openai-reasoning', - 'provider-anthropic', - 'provider-azure', - 'provider-bedrock', - 'provider-foreground', -]); +export const themeBrandTokens: readonly (keyof IThemeBrands)[] = sharedBrandTokens; export const defaultBrands: IThemeBrands = Object.freeze({ 'provider-openai': '#19C37D', @@ -195,295 +214,16 @@ export const highContrastTheme: ThemeDefinition = Object.freeze({ }, }); -const rgbPattern = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/; -const cssLengthPattern = /^(0|\d*\.?\d+(px|rem|em))$/; -const cssLengthDifferencePattern = - /^calc\(\s*\d*\.?\d+(px|rem|em)\s+[-+]\s+\d*\.?\d+(px|rem|em)\s*\)$/; -const cssDurationPattern = /^\d*\.?\d+(ms|s)$/; -const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; -const shadowLengthPattern = /^(-?(0|\d*\.?\d+[a-z]+)|(calc|min|max|clamp)\(.*\))$/i; -const shadowColorPattern = /^(#[0-9a-f]{3,8}|[a-z]+|[a-z-]+\(.*\))$/i; /** Tailwind composes `--tw-shadow` into one list with the ring layers, where `none` is invalid. */ const disabledShadow = '0 0 #0000'; -function isLinearGradient(value: string): boolean { - if (!value.startsWith('linear-gradient(') || /url\s*\(|image-set/i.test(value)) { - return false; - } - let depth = 0; - for (let i = 0; i < value.length; i++) { - const char = value[i]; - if (char === '(') { - depth += 1; - } else if (char === ')') { - depth -= 1; - if (depth === 0) { - return i === value.length - 1; - } - if (depth < 0) { - return false; - } - } - } - return false; -} - -const isRGB = (value: unknown): value is string => { - if (typeof value !== 'string') { - return false; - } - const match = value.match(rgbPattern); - return match !== null && match.slice(1).every((channel) => Number(channel) <= 255); -}; - -/** The bare form, or one `calc()` of two unit-bearing lengths (a bare `0` is a number there): - * the small radius defaults keep a px offset. */ -const isLength = (value: unknown): value is string => - typeof value === 'string' && - (cssLengthPattern.test(value) || cssLengthDifferencePattern.test(value)); -const isFontFamily = (value: unknown): value is string => - typeof value === 'string' && value.trim().length > 0 && !/[;{}]/.test(value); -/** Splits on `separator` outside parentheses, so `rgb(0, 0, 0)` stays one part. Empty parts are - * kept, so a stray comma stays visible to the caller. */ -function splitTopLevel(value: string, separator: RegExp): string[] { - const parts: string[] = []; - let depth = 0; - let current = ''; - for (const char of value) { - if (char === '(') { - depth += 1; - } else if (char === ')') { - depth -= 1; - } - if (depth === 0 && separator.test(char)) { - parts.push(current); - current = ''; - continue; - } - current += char; - } - parts.push(current); - return parts.map((part) => part.trim()); -} - -/** A named color is indistinguishable from any other word without the browser's color parser. */ -const isShadowColor = (token: string): boolean => - globalThis.CSS?.supports?.('color', token) ?? shadowColorPattern.test(token); - -/** One layer: two to four lengths, optionally `inset` and one color, per the box-shadow grammar. */ -function isShadowLayer(layer: string): boolean { - const tokens = splitTopLevel(layer, /\s/).filter((token) => token.length > 0); - const lengths = tokens.filter((token) => shadowLengthPattern.test(token)).length; - const insets = tokens.filter((token) => token.toLowerCase() === 'inset').length; - const colors = tokens.filter( - (token) => !shadowLengthPattern.test(token) && token.toLowerCase() !== 'inset', - ); - return ( - lengths >= 2 && lengths <= 4 && insets <= 1 && colors.length <= 1 && colors.every(isShadowColor) - ); -} - -/** - * A shadow must be concrete: a browser defers its check of any value holding `var()`, `env()` or - * `attr()` until substitution, so such a value could never be validated before it reaches the - * ring layers. - */ -const isShadow = (value: unknown): value is string => { - if (typeof value !== 'string' || /[;{}]|url\s*\(|(var|env|attr)\s*\(/i.test(value)) { - return false; - } - if (value.trim().toLowerCase() === 'none') { - return true; - } - const layers = splitTopLevel(value, /,/); - if (layers.some((layer) => layer.length === 0) || !layers.every(isShadowLayer)) { - return false; - } - return globalThis.CSS?.supports?.('box-shadow', value) ?? true; -}; -const isDuration = (value: unknown): value is string => - typeof value === 'string' && cssDurationPattern.test(value); - -const isPlainRecord = (value: unknown): value is Record => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return false; - } - try { - const prototype = Object.getPrototypeOf(value); - return prototype === null || prototype.constructor?.name === 'Object'; - } catch { - return false; - } -}; - -const appearanceValidators: Record boolean> = { - controlRadius: isLength, - roundControlRadius: isLength, - surfaceRadius: isLength, - largeSurfaceRadius: isLength, - radiusSm: isLength, - radiusMd: isLength, - radiusLg: isLength, - radiusXl: isLength, - radius2xl: isLength, - radius3xl: isLength, - controlHeight: isLength, - spaceCompact: isLength, - spaceNormal: isLength, - fontFamily: isFontFamily, - monoFontFamily: isFontFamily, - /** Released themes may hold `var()` here, so this role keeps its original, looser check. */ - elevationSurface: (value) => - typeof value === 'string' && value.trim().length > 0 && !/[;{}]|url\s*\(/i.test(value), - shadow2xs: isShadow, - shadowXs: isShadow, - shadowSm: isShadow, - shadowMd: isShadow, - shadowLg: isShadow, - shadowXl: isShadow, - shadow2xl: isShadow, - motionFast: isDuration, - motionNormal: isDuration, -}; - -const isAppearanceKey = (key: string): key is keyof IThemeAppearance => - Object.prototype.hasOwnProperty.call(appearanceValidators, key); - -/** - * A token added after this reader shipped is ignored rather than rejected, so a newer definition - * degrades to the defaults for what this version cannot paint instead of losing every value it - * can. It never reaches the DOM, but it must still look like a token: a camelCase name and a - * plain CSS value, never a declaration or rule break. - */ -const isFutureAppearance = (key: string, value: unknown): boolean => - /^[a-z][a-zA-Z0-9]*$/.test(key) && typeof value === 'string' && !/[;{}<>]|url\s*\(/i.test(value); - /** The appearance tokens this reader does not know, which `resolveTheme` leaves out. */ export function collectThemeWarnings(theme: ThemeDefinition): string[] { - if (!isPlainRecord(theme) || !isPlainRecord(theme.modes)) { - return []; - } - return (['light', 'dark'] as const).flatMap((mode) => { - const appearance: unknown = isPlainRecord(theme.modes[mode]) - ? theme.modes[mode]?.appearance - : undefined; - if (!isPlainRecord(appearance)) { - return []; - } - return Object.keys(appearance) - .filter((key) => !isAppearanceKey(key)) - .map((key) => `Unknown ${mode} appearance token ignored: ${key}`); - }); -} - -/** Shared by the theme-wide `brands` and each mode's override block. */ -function collectBrandErrors(brands: unknown): string[] { - if (!isPlainRecord(brands)) { - return []; - } - - return Object.entries(brands).flatMap(([key, value]) => { - if (!themeBrandTokens.includes(key as keyof IThemeBrands)) { - return [`Unknown brand token: ${key}`]; - } - /** Only the glyph is a flat colour; a fill may also be a gradient. */ - const isColorOnly = key === 'provider-foreground'; - const isValidBrand = - typeof value === 'string' && - (isColorOnly - ? hexColorPattern.test(value) - : hexColorPattern.test(value) || isLinearGradient(value)); - return value !== undefined && !isValidBrand ? [`Invalid brand value for ${key}: ${value}`] : []; - }); + return collectThemeWarningIssues(theme).map(({ message }) => message); } export function validateThemeDefinition(theme: ThemeDefinition): string[] { - const errors: string[] = []; - - if (!isPlainRecord(theme)) { - return ['Theme definition must be an object']; - } - - Object.keys(theme).forEach((key) => { - if (key !== 'version' && key !== 'name' && key !== 'modes' && key !== 'brands') { - errors.push(`Unknown theme field: ${key}`); - } - }); - - if (theme.version !== THEME_VERSION) { - errors.push(`Unsupported theme version: ${theme.version}`); - } - if (typeof theme.name !== 'string' || !theme.name.trim()) { - errors.push('Theme name is required'); - } - if (!isPlainRecord(theme.modes)) { - errors.push('Theme modes must be an object'); - return errors; - } - - Object.keys(theme.modes).forEach((mode) => { - if (mode !== 'light' && mode !== 'dark') { - errors.push(`Unknown theme mode: ${mode}`); - } - }); - - (['light', 'dark'] as const).forEach((mode) => { - const definition = theme.modes[mode]; - if (definition === undefined) { - return; - } - - if (!isPlainRecord(definition)) { - errors.push(`Theme mode ${mode} must be an object`); - return; - } - - Object.keys(definition).forEach((key) => { - if (key !== 'colors' && key !== 'appearance' && key !== 'brands') { - errors.push(`Unknown ${mode} theme field: ${key}`); - } - }); - - if (definition.colors !== undefined && !isPlainRecord(definition.colors)) { - errors.push(`Theme colors for ${mode} must be an object`); - } else { - Object.entries(definition.colors ?? {}).forEach(([key, value]) => { - if (!themeColorTokens.includes(key as keyof IThemeRGB)) { - errors.push(`Unknown color token: ${key}`); - return; - } - if (value !== undefined && !isRGB(value)) { - errors.push(`Invalid RGB value for ${key}: ${value}`); - } - }); - } - - if (definition.appearance !== undefined && !isPlainRecord(definition.appearance)) { - errors.push(`Theme appearance for ${mode} must be an object`); - } else { - Object.entries(definition.appearance ?? {}).forEach(([key, value]) => { - const isKnown = isAppearanceKey(key); - const isValid = isKnown ? appearanceValidators[key](value) : isFutureAppearance(key, value); - if (value !== undefined && !isValid) { - errors.push(`Invalid appearance value for ${key}: ${value}`); - } - }); - } - - if (definition.brands !== undefined && !isPlainRecord(definition.brands)) { - errors.push(`Theme brands for ${mode} must be an object`); - } else { - errors.push(...collectBrandErrors(definition.brands)); - } - }); - - if (theme.brands !== undefined && !isPlainRecord(theme.brands)) { - errors.push('Theme brands must be an object'); - } else { - errors.push(...collectBrandErrors(theme.brands)); - } - - return errors; + return collectThemeIssues(theme).map(({ message }) => message); } /** @@ -503,7 +243,7 @@ function definedEntries(values?: Partial): Partial { function knownAppearance(appearance?: Partial): Partial { return Object.fromEntries( - Object.entries(definedEntries(appearance)).filter(([key]) => isAppearanceKey(key)), + Object.entries(definedEntries(appearance)).filter(([key]) => isThemeAppearanceToken(key)), ); } @@ -679,7 +419,7 @@ export function fromLegacyTheme(colors: IThemeRGB, name = 'custom'): ThemeDefini const legacyName = name.trim() || 'custom'; const sanitizedColors = themeColorTokens.reduce((result, token) => { const value = colors[token]; - if (isRGB(value)) { + if (isThemeRGB(value)) { result[token] = value; } return result; diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 9987174975d..c480ad92b76 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -4,6 +4,7 @@ export * from './bedrock'; export * from './balance'; export * from './config'; export * from './footer'; +export * from './theme'; export * from './langchain'; export * from './filters'; export * from './file-config'; diff --git a/packages/data-provider/src/theme.ts b/packages/data-provider/src/theme.ts new file mode 100644 index 00000000000..e1b321dea54 --- /dev/null +++ b/packages/data-provider/src/theme.ts @@ -0,0 +1,442 @@ +/** + * The theme token contract shared by the client registry, which paints a definition, and the + * server, which checks `interface.theme` when librechat.yaml loads. Both read these lists, so a + * token added here is accepted everywhere at once. + */ +export const THEME_VERSION = 1 as const; + +/** The names `interface.theme` may give instead of an inline definition. */ +export const bundledThemeNames = Object.freeze(['librechat', 'clickhouse'] as const); + +export type BundledThemeName = (typeof bundledThemeNames)[number]; + +export const isBundledThemeName = (value: string): value is BundledThemeName => + (bundledThemeNames as readonly string[]).includes(value); + +export const themeColorTokens = Object.freeze([ + 'rgb-text-primary', + 'rgb-text-secondary', + 'rgb-text-secondary-alt', + 'rgb-text-tertiary', + 'rgb-text-muted', + 'rgb-text-warning', + 'rgb-text-destructive', + 'rgb-shimmer-base', + 'rgb-shimmer-dip', + 'rgb-link', + 'rgb-link-hover', + 'rgb-link-visited', + 'rgb-accent-primary', + 'rgb-accent-primary-hover', + 'rgb-ring-primary', + 'rgb-header-primary', + 'rgb-header-hover', + 'rgb-header-button-hover', + 'rgb-surface-active', + 'rgb-surface-active-alt', + 'rgb-surface-hover', + 'rgb-surface-hover-alt', + 'rgb-surface-composer-hover', + 'rgb-surface-primary', + 'rgb-chart-widget-surface', + 'rgb-chart-widget-stroke', + 'rgb-surface-primary-alt', + 'rgb-surface-primary-contrast', + 'rgb-surface-secondary', + 'rgb-surface-secondary-alt', + 'rgb-surface-tertiary', + 'rgb-surface-tertiary-alt', + 'rgb-surface-dialog', + 'rgb-surface-overlay', + 'rgb-surface-submit', + 'rgb-surface-submit-hover', + 'rgb-surface-destructive', + 'rgb-surface-destructive-hover', + 'rgb-surface-chat', + 'rgb-surface-code', + 'rgb-surface-code-body', + 'rgb-surface-inverted', + 'rgb-surface-inverted-hover', + 'rgb-text-inverted', + 'rgb-surface-fixed', + 'rgb-surface-fixed-hover', + 'rgb-text-fixed', + 'rgb-border-light', + 'rgb-border-medium', + 'rgb-border-medium-alt', + 'rgb-border-heavy', + 'rgb-border-xheavy', + 'rgb-border-destructive', + 'rgb-border-control', + 'rgb-status-success', + 'rgb-status-success-subtle', + 'rgb-status-success-border', + 'rgb-status-success-strong', + 'rgb-status-info', + 'rgb-status-info-subtle', + 'rgb-status-info-border', + 'rgb-status-info-strong', + 'rgb-status-warning', + 'rgb-status-warning-subtle', + 'rgb-status-warning-border', + 'rgb-status-warning-strong', + 'rgb-status-error', + 'rgb-status-error-subtle', + 'rgb-status-error-border', + 'rgb-status-error-strong', + 'rgb-status-neutral', + 'rgb-status-neutral-subtle', + 'rgb-status-neutral-border', + 'rgb-status-verified', + 'rgb-text-on-status', + 'rgb-brand-purple', + 'rgb-syntax-text', + 'rgb-syntax-comment', + 'rgb-syntax-meta', + 'rgb-syntax-builtin', + 'rgb-syntax-keyword', + 'rgb-syntax-string', + 'rgb-syntax-attr', + 'rgb-syntax-title', + 'rgb-series-1', + 'rgb-series-2', + 'rgb-series-3', + 'rgb-series-4', + 'rgb-series-5', + 'rgb-series-6', + 'rgb-series-7', + 'rgb-series-8', + 'rgb-switch-unchecked', + 'rgb-presentation', +] as const); + +export type ThemeColorToken = (typeof themeColorTokens)[number]; + +export const themeBrandTokens = Object.freeze([ + 'provider-openai', + 'provider-openai-gpt4', + 'provider-openai-reasoning', + 'provider-anthropic', + 'provider-azure', + 'provider-bedrock', + 'provider-foreground', +] as const); + +export type ThemeBrandToken = (typeof themeBrandTokens)[number]; + +/** One problem in a theme definition: where it is, relative to the definition, and what it is. */ +export interface ThemeIssue { + path: string[]; + message: string; +} + +const rgbPattern = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/; +const cssLengthPattern = /^(0|\d*\.?\d+(px|rem|em))$/; +const cssLengthDifferencePattern = + /^calc\(\s*\d*\.?\d+(px|rem|em)\s+[-+]\s+\d*\.?\d+(px|rem|em)\s*\)$/; +const cssDurationPattern = /^\d*\.?\d+(ms|s)$/; +const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; +const shadowLengthPattern = /^(-?(0|\d*\.?\d+[a-z]+)|(calc|min|max|clamp)\(.*\))$/i; +const shadowColorPattern = /^(#[0-9a-f]{3,8}|[a-z]+|[a-z-]+\(.*\))$/i; + +function isLinearGradient(value: string): boolean { + if (!value.startsWith('linear-gradient(') || /url\s*\(|image-set/i.test(value)) { + return false; + } + let depth = 0; + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (char === '(') { + depth += 1; + } else if (char === ')') { + depth -= 1; + if (depth === 0) { + return i === value.length - 1; + } + if (depth < 0) { + return false; + } + } + } + return false; +} + +export const isThemeRGB = (value: unknown): value is string => { + if (typeof value !== 'string') { + return false; + } + const match = value.match(rgbPattern); + return match !== null && match.slice(1).every((channel) => Number(channel) <= 255); +}; + +/** The bare form, or one `calc()` of two unit-bearing lengths (a bare `0` is a number there): + * the small radius defaults keep a px offset. */ +const isLength = (value: unknown): value is string => + typeof value === 'string' && + (cssLengthPattern.test(value) || cssLengthDifferencePattern.test(value)); +const isFontFamily = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0 && !/[;{}]/.test(value); + +/** Splits on `separator` outside parentheses, so `rgb(0, 0, 0)` stays one part. Empty parts are + * kept, so a stray comma stays visible to the caller. */ +function splitTopLevel(value: string, separator: RegExp): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (char === '(') { + depth += 1; + } else if (char === ')') { + depth -= 1; + } + if (depth === 0 && separator.test(char)) { + parts.push(current); + current = ''; + continue; + } + current += char; + } + parts.push(current); + return parts.map((part) => part.trim()); +} + +/** A named color is indistinguishable from any other word without the browser's color parser, + * so outside a browser the pattern decides. */ +const isShadowColor = (token: string): boolean => + globalThis.CSS?.supports?.('color', token) ?? shadowColorPattern.test(token); + +/** One layer: two to four lengths, optionally `inset` and one color, per the box-shadow grammar. */ +function isShadowLayer(layer: string): boolean { + const tokens = splitTopLevel(layer, /\s/).filter((token) => token.length > 0); + const lengths = tokens.filter((token) => shadowLengthPattern.test(token)).length; + const insets = tokens.filter((token) => token.toLowerCase() === 'inset').length; + const colors = tokens.filter( + (token) => !shadowLengthPattern.test(token) && token.toLowerCase() !== 'inset', + ); + return ( + lengths >= 2 && lengths <= 4 && insets <= 1 && colors.length <= 1 && colors.every(isShadowColor) + ); +} + +/** + * A shadow must be concrete: a browser defers its check of any value holding `var()`, `env()` or + * `attr()` until substitution, so such a value could never be validated before it reaches the + * ring layers. + */ +const isShadow = (value: unknown): value is string => { + if (typeof value !== 'string' || /[;{}]|url\s*\(|(var|env|attr)\s*\(/i.test(value)) { + return false; + } + if (value.trim().toLowerCase() === 'none') { + return true; + } + const layers = splitTopLevel(value, /,/); + if (layers.some((layer) => layer.length === 0) || !layers.every(isShadowLayer)) { + return false; + } + return globalThis.CSS?.supports?.('box-shadow', value) ?? true; +}; +const isDuration = (value: unknown): value is string => + typeof value === 'string' && cssDurationPattern.test(value); + +export const isPlainThemeRecord = (value: unknown): value is Record => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + try { + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype.constructor?.name === 'Object'; + } catch { + return false; + } +}; + +const appearanceValidators = { + controlRadius: isLength, + roundControlRadius: isLength, + surfaceRadius: isLength, + largeSurfaceRadius: isLength, + radiusSm: isLength, + radiusMd: isLength, + radiusLg: isLength, + radiusXl: isLength, + radius2xl: isLength, + radius3xl: isLength, + controlHeight: isLength, + spaceCompact: isLength, + spaceNormal: isLength, + fontFamily: isFontFamily, + monoFontFamily: isFontFamily, + /** Released themes may hold `var()` here, so this role keeps its original, looser check. */ + elevationSurface: (value: unknown) => + typeof value === 'string' && value.trim().length > 0 && !/[;{}]|url\s*\(/i.test(value), + shadow2xs: isShadow, + shadowXs: isShadow, + shadowSm: isShadow, + shadowMd: isShadow, + shadowLg: isShadow, + shadowXl: isShadow, + shadow2xl: isShadow, + motionFast: isDuration, + motionNormal: isDuration, +} satisfies Record boolean>; + +export type ThemeAppearanceToken = keyof typeof appearanceValidators; + +export const themeAppearanceTokens = Object.freeze( + Object.keys(appearanceValidators) as ThemeAppearanceToken[], +); + +export const isThemeAppearanceToken = (key: string): key is ThemeAppearanceToken => + Object.prototype.hasOwnProperty.call(appearanceValidators, key); + +const colorTokenSet: ReadonlySet = new Set(themeColorTokens); +const brandTokenSet: ReadonlySet = new Set(themeBrandTokens); +const themeModes = ['light', 'dark'] as const; + +/** + * A token added after this reader shipped is ignored rather than rejected, so a newer definition + * degrades to the defaults for what this version cannot paint instead of losing every value it + * can. It never reaches the DOM, but it must still look like a token: a camelCase name and a + * plain CSS value, never a declaration or rule break. + */ +const isFutureAppearance = (key: string, value: unknown): boolean => + /^[a-z][a-zA-Z0-9]*$/.test(key) && typeof value === 'string' && !/[;{}<>]|url\s*\(/i.test(value); + +const issue = (path: string[], message: string): ThemeIssue => ({ path, message }); + +/** The appearance tokens this reader does not know, which a resolved theme leaves out. */ +export function collectThemeWarningIssues(theme: unknown): ThemeIssue[] { + if (!isPlainThemeRecord(theme) || !isPlainThemeRecord(theme.modes)) { + return []; + } + const modes = theme.modes; + return themeModes.flatMap((mode) => { + const definition = modes[mode]; + const appearance = isPlainThemeRecord(definition) ? definition.appearance : undefined; + if (!isPlainThemeRecord(appearance)) { + return []; + } + return Object.keys(appearance) + .filter((key) => !isThemeAppearanceToken(key)) + .map((key) => + issue( + ['modes', mode, 'appearance', key], + `Unknown ${mode} appearance token ignored: ${key}`, + ), + ); + }); +} + +/** Shared by the theme-wide `brands` and each mode's override block. */ +function collectBrandIssues(brands: unknown, path: string[]): ThemeIssue[] { + if (!isPlainThemeRecord(brands)) { + return []; + } + + return Object.entries(brands).flatMap(([key, value]) => { + if (!brandTokenSet.has(key)) { + return [issue([...path, key], `Unknown brand token: ${key}`)]; + } + /** Only the glyph is a flat colour; a fill may also be a gradient. */ + const isColorOnly = key === 'provider-foreground'; + const isValidBrand = + typeof value === 'string' && + (isColorOnly + ? hexColorPattern.test(value) + : hexColorPattern.test(value) || isLinearGradient(value)); + return value !== undefined && !isValidBrand + ? [issue([...path, key], `Invalid brand value for ${key}: ${value}`)] + : []; + }); +} + +function collectModeIssues(mode: 'light' | 'dark', definition: unknown): ThemeIssue[] { + const base = ['modes', mode]; + if (definition === undefined) { + return []; + } + if (!isPlainThemeRecord(definition)) { + return [issue(base, `Theme mode ${mode} must be an object`)]; + } + + const issues: ThemeIssue[] = Object.keys(definition) + .filter((key) => key !== 'colors' && key !== 'appearance' && key !== 'brands') + .map((key) => issue([...base, key], `Unknown ${mode} theme field: ${key}`)); + + const { colors, appearance, brands } = definition; + if (colors !== undefined && !isPlainThemeRecord(colors)) { + issues.push(issue([...base, 'colors'], `Theme colors for ${mode} must be an object`)); + } else { + Object.entries(colors ?? {}).forEach(([key, value]) => { + const path = [...base, 'colors', key]; + if (!colorTokenSet.has(key)) { + issues.push(issue(path, `Unknown color token: ${key}`)); + return; + } + if (value !== undefined && !isThemeRGB(value)) { + issues.push(issue(path, `Invalid RGB value for ${key}: ${value}`)); + } + }); + } + + if (appearance !== undefined && !isPlainThemeRecord(appearance)) { + issues.push(issue([...base, 'appearance'], `Theme appearance for ${mode} must be an object`)); + } else { + Object.entries(appearance ?? {}).forEach(([key, value]) => { + const isValid = isThemeAppearanceToken(key) + ? appearanceValidators[key](value) + : isFutureAppearance(key, value); + if (value !== undefined && !isValid) { + issues.push( + issue([...base, 'appearance', key], `Invalid appearance value for ${key}: ${value}`), + ); + } + }); + } + + if (brands !== undefined && !isPlainThemeRecord(brands)) { + issues.push(issue([...base, 'brands'], `Theme brands for ${mode} must be an object`)); + } else { + issues.push(...collectBrandIssues(brands, [...base, 'brands'])); + } + return issues; +} + +/** Every reason a definition cannot be painted; empty when it can. */ +export function collectThemeIssues(theme: unknown): ThemeIssue[] { + if (!isPlainThemeRecord(theme)) { + return [issue([], 'Theme definition must be an object')]; + } + + const issues: ThemeIssue[] = Object.keys(theme) + .filter((key) => key !== 'version' && key !== 'name' && key !== 'modes' && key !== 'brands') + .map((key) => issue([key], `Unknown theme field: ${key}`)); + + if (theme.version !== THEME_VERSION) { + issues.push(issue(['version'], `Unsupported theme version: ${theme.version}`)); + } + if (typeof theme.name !== 'string' || !theme.name.trim()) { + issues.push(issue(['name'], 'Theme name is required')); + } + const modes = theme.modes; + if (!isPlainThemeRecord(modes)) { + issues.push(issue(['modes'], 'Theme modes must be an object')); + return issues; + } + + Object.keys(modes) + .filter((mode) => mode !== 'light' && mode !== 'dark') + .forEach((mode) => issues.push(issue(['modes', mode], `Unknown theme mode: ${mode}`))); + + themeModes.forEach((mode) => issues.push(...collectModeIssues(mode, modes[mode]))); + + if (theme.brands !== undefined && !isPlainThemeRecord(theme.brands)) { + issues.push(issue(['brands'], 'Theme brands must be an object')); + } else { + issues.push(...collectBrandIssues(theme.brands, ['brands'])); + } + + return issues; +}