diff --git a/src/browser/__tests__/__snapshots__/index.test.ts.snap b/src/browser/__tests__/__snapshots__/index.test.ts.snap index 03ffe8f..f2282a7 100644 --- a/src/browser/__tests__/__snapshots__/index.test.ts.snap +++ b/src/browser/__tests__/__snapshots__/index.test.ts.snap @@ -177,6 +177,144 @@ exports[`generateThemeStylesheet > with baseThemeId > creates override styles 1` }}" `; +exports[`generateThemeStylesheet > with reference tokens and useCssVars > creates override styles with CSS variables when useCssVars is enabled 1`] = ` +":root:root{ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +} +.compact.compact:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +} +@media not print {.dark.dark:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +}} +.disabled-motion.disabled-motion:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +} +.navigation.navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +} +.compact.compact .navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +} +.compact.compact.navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +} +@media not print {.dark.dark .navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +}} +@media not print {.dark.dark.navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +}} +.disabled-motion.disabled-motion .navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +} +.disabled-motion.disabled-motion.navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +}" +`; + +exports[`generateThemeStylesheet > with reference tokens and useCssVars > creates override styles without CSS variables by default 1`] = ` +":root:root{ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +} +.compact.compact:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +} +@media not print {.dark.dark:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +}} +.disabled-motion.disabled-motion:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +} +.navigation.navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +} +.compact.compact .navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +} +.compact.compact.navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +} +@media not print {.dark.dark .navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +}} +@media not print {.dark.dark.navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +}} +.disabled-motion.disabled-motion .navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +} +.disabled-motion.disabled-motion.navigation:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; + --colorButtonPrimary-css:#ff6600; + --colorButtonSecondary-css:#692dc9; + --colorTextPrimary-css:#ff6600; + --colorBackgroundPrimary-css:#692dc9; +}" +`; + exports[`generateThemeStylesheet > with secondary theme > creates override styles 1`] = ` ":root:root{ --shadow-css:yellow; diff --git a/src/browser/__tests__/index.test.ts b/src/browser/__tests__/index.test.ts index 641c0df..8dc1845 100644 --- a/src/browser/__tests__/index.test.ts +++ b/src/browser/__tests__/index.test.ts @@ -1,11 +1,82 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { afterEach, describe, test, expect } from 'vitest'; -import { preset, presetWithSecondaryTheme, override } from '../../__fixtures__/common'; +import { + preset, + presetWithSecondaryTheme, + override, + rootTheme, + createStubPropertiesMap, + createStubVariablesMap, +} from '../../__fixtures__/common'; import { applyTheme, generateThemeStylesheet } from '../index'; +import { Theme, ThemePreset, Override } from '../../shared/theme'; const allStyleNodes = (targetDocument: Document = document) => targetDocument.head.querySelectorAll('style'); +// Create a theme with reference tokens to test useCssVars +const themeWithReferenceTokens: Theme = { + ...rootTheme, + referenceTokens: { + color: { + primary: { + 600: '#006ce0', + 700: '#0053ba', + 800: '#064695', + }, + }, + }, + tokens: { + ...rootTheme.tokens, + // Generated reference tokens (normally created by ThemeBuilder) + colorPrimary600: '#006ce0', + colorPrimary700: '#0053ba', + colorPrimary800: '#064695', + // Tokens that reference the base tokens + colorButtonPrimary: '{colorPrimary600}', + colorButtonSecondary: '{colorPrimary700}', + colorTextPrimary: '{colorPrimary600}', + colorBorderPrimary: '{colorPrimary800}', + colorBackgroundPrimary: '{colorPrimary700}', + }, +}; + +const presetWithReferenceTokens: ThemePreset = { + theme: themeWithReferenceTokens, + themeable: [ + 'colorPrimary600', + 'colorPrimary700', + 'colorPrimary800', + 'colorButtonPrimary', + 'colorButtonSecondary', + 'colorTextPrimary', + 'colorBorderPrimary', + 'colorBackgroundPrimary', + ], + exposed: [ + 'colorPrimary600', + 'colorPrimary700', + 'colorPrimary800', + 'colorButtonPrimary', + 'colorButtonSecondary', + 'colorTextPrimary', + 'colorBorderPrimary', + 'colorBackgroundPrimary', + ], + propertiesMap: { + ...createStubPropertiesMap(themeWithReferenceTokens), + }, + variablesMap: createStubVariablesMap(themeWithReferenceTokens), +}; + +const overrideWithReferenceTokens: Override = { + referenceTokens: { color: { primary: { 600: '#ff6600', 700: '#692dc9' } } }, + tokens: { + colorPrimary700: '#ff00bf', // This should be overridden by reference token + // Don't override the dependent tokens - let them cascade via CSS variables + }, +}; + describe('applyTheme', () => { afterEach(() => { allStyleNodes().forEach((tag) => tag.remove()); @@ -126,4 +197,25 @@ describe('generateThemeStylesheet', () => { ).toThrow(`Specified baseThemeId 'invalid' is not available. Available values are 'root', 'secondary'.`); }); }); + + describe('with reference tokens and useCssVars', () => { + test('creates override styles without CSS variables by default', () => { + const styles = generateThemeStylesheet({ + override: overrideWithReferenceTokens, + preset: presetWithReferenceTokens, + }); + + expect(styles).toMatchSnapshot(); + }); + + test('creates override styles with CSS variables when useCssVars is enabled', () => { + const styles = generateThemeStylesheet({ + override: overrideWithReferenceTokens, + preset: presetWithReferenceTokens, + useCssVars: true, + }); + + expect(styles).toMatchSnapshot(); + }); + }); }); diff --git a/src/browser/index.ts b/src/browser/index.ts index fbdd3d2..1358a03 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -10,10 +10,11 @@ export interface GenerateThemeStylesheetParams { override: Override; preset: ThemePreset; baseThemeId?: string; + useCssVars?: boolean; } export function generateThemeStylesheet(params: GenerateThemeStylesheetParams): string { - const { override, preset, baseThemeId } = params; + const { override, preset, baseThemeId, useCssVars } = params; const availableContexts = getContexts(preset); const validated = validateOverride(override, preset.themeable, availableContexts); const theme = getThemeFromPreset(preset, baseThemeId); @@ -22,7 +23,8 @@ export function generateThemeStylesheet(params: GenerateThemeStylesheetParams): theme, validated, preset.propertiesMap, - createMultiThemeCustomizer(preset.theme.selector) + createMultiThemeCustomizer(preset.theme.selector), + useCssVars ); } @@ -31,6 +33,7 @@ export interface ApplyThemeParams { preset: ThemePreset; baseThemeId?: string; targetDocument?: Document; + useCssVars?: boolean; } export interface ApplyThemeResult { @@ -39,7 +42,7 @@ export interface ApplyThemeResult { export function applyTheme(params: ApplyThemeParams): ApplyThemeResult { const { targetDocument } = params; - const content = generateThemeStylesheet(params); + const content = generateThemeStylesheet({ ...params }); const nonce = getNonce(targetDocument); const styleNode = createStyleNode(content, nonce); diff --git a/src/build/__tests__/css-vars.test.ts b/src/build/__tests__/css-vars.test.ts new file mode 100644 index 0000000..e3a2936 --- /dev/null +++ b/src/build/__tests__/css-vars.test.ts @@ -0,0 +1,168 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { test, expect } from 'vitest'; +import { buildThemedComponentsInternal } from '../internal'; +import { Theme, resolveTheme, resolveContext } from '../../shared/theme'; +import { isReferenceToken, generateReferenceTokenName, flattenReferenceTokens } from '../../shared/theme/utils'; +import { createBuildDeclarations } from '../../shared/declaration'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const testTheme: Theme = { + id: 'test', + selector: ':root', + tokens: { + colorPrimary: '#0073bb', + colorBackground: '{colorPrimary}', + colorPrimary500: '#direct-primary-500', // Different from reference token to test precedence + colorSecondary: '{colorNeutral900}', // References a token that exists in both places + colorNeutral900: '#direct-neutral', // Different from reference token + }, + modes: {}, + contexts: {}, + tokenModeMap: {}, + referenceTokens: { + color: { + primary: { 500: '#reference-primary-value' }, // Also defined in tokens - tests precedence + neutral: { 900: '#reference-neutral-value' }, // Also defined in tokens - tests precedence + success: { 200: '#reference-success-value' }, // Only in reference tokens - tests auto-generation + }, + }, +}; + +const propertiesMap = { + colorPrimary: '--color-primary', + colorBackground: '--color-background', + colorPrimary500: '--color-primary-500', + colorSecondary: '--color-secondary', + colorNeutral900: '--color-neutral-900', + colorSuccess200: '--color-success-200', // For the reference-only token + colorError100: '--color-error-100', // For secondary theme reference token +}; + +test('CSS variable optimization works without errors', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'css-vars-test-')); + + try { + await buildThemedComponentsInternal({ + primary: testTheme, + exposed: ['colorPrimary', 'colorBackground'], + themeable: ['colorPrimary', 'colorBackground'], + variablesMap: { + colorPrimary: 'color-primary', + colorBackground: 'color-background', + }, + componentsOutputDir: tempDir, + scssDir: tempDir, + useCssVars: true, + skip: ['preset', 'design-tokens'], + }); + + expect(true).toBe(true); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('isReferenceToken identifies reference tokens correctly', () => { + expect(isReferenceToken('color', testTheme, 'colorPrimary500')).toBe(true); + expect(isReferenceToken('color', testTheme, 'colorNeutral900')).toBe(true); + expect(isReferenceToken('color', testTheme, 'colorPrimary')).toBe(false); + expect(isReferenceToken('color', testTheme, 'colorSuccess500')).toBe(false); // Not in referenceTokens +}); + +test('generateReferenceTokenName creates correct token names', () => { + expect(generateReferenceTokenName('color', 'primary', '500')).toBe('colorPrimary500'); + expect(generateReferenceTokenName('color', 'neutral', '900')).toBe('colorNeutral900'); +}); + +test('generateReferenceTokenDefaults creates CSS variable declarations', () => { + const defaults = flattenReferenceTokens(testTheme); + + expect(defaults).toEqual({ + colorPrimary500: '#reference-primary-value', + colorNeutral900: '#reference-neutral-value', + colorSuccess200: '#reference-success-value', + }); +}); + +test('resolveTheme with CSS variables returns CSS var() for reference tokens', () => { + const resolved = resolveTheme(testTheme, undefined, { + useCssVars: true, + propertiesMap, + }); + + // Direct token takes precedence over reference token + expect(resolved.colorPrimary500).toBe('#direct-primary-500'); + // Token referencing a reference token should get CSS var + expect(resolved.colorSecondary).toBe('var(--color-neutral-900)'); + expect(resolved.colorPrimary).toBe('#0073bb'); // Not a reference token +}); + +test('token precedence: direct tokens override reference tokens', () => { + const resolved = resolveTheme(testTheme, undefined); + + // Direct token value should be used, not reference token value + expect(resolved.colorPrimary500).toBe('#direct-primary-500'); + expect(resolved.colorNeutral900).toBe('#direct-neutral'); +}); + +test('resolveContext with CSS variables preserves var() references', () => { + const context = { + id: 'test-context', + selector: '.test', + tokens: { + colorSecondary: '{colorNeutral900}', // References a token that should become CSS var + }, + }; + + const resolved = resolveContext(testTheme, context, undefined, undefined, { + useCssVars: true, + propertiesMap, + }); + + // Should resolve to CSS var since colorNeutral900 is a reference token + expect(resolved.colorSecondary).toBe('var(--color-neutral-900)'); +}); + +test('createBuildDeclarations includes reference tokens when useCssVars enabled', () => { + const css = createBuildDeclarations(testTheme, [], propertiesMap, (selector) => selector, ['colorPrimary'], true); + + expect(css).toContain('--color-primary-500'); + expect(css).toContain('--color-neutral-900'); +}); + +test('createBuildDeclarations with secondary theme generates reference token CSS variables', () => { + const secondaryTheme: Theme = { + id: 'dark', + selector: '[data-theme="dark"]', + tokens: { + colorPrimary: '#1a73e8', + colorPrimary500: '#secondary-primary-500', + }, + modes: {}, + contexts: {}, + tokenModeMap: {}, + referenceTokens: { + color: { + primary: { 500: '#secondary-reference-value' }, + error: { 100: '#secondary-error-value' }, + }, + }, + }; + + const css = createBuildDeclarations( + testTheme, + [secondaryTheme], + propertiesMap, + (selector) => selector, + ['colorPrimary', 'colorError100'], + true + ); + + // Should contain reference tokens from both primary and secondary themes + expect(css).toContain('--color-primary-500'); + expect(css).toContain('[data-theme="dark"]'); + expect(css).toContain('--color-error-100'); // Now working - reference token from secondary theme +}); diff --git a/src/build/inline-stylesheets.ts b/src/build/inline-stylesheets.ts index 7a569f2..959a23c 100644 --- a/src/build/inline-stylesheets.ts +++ b/src/build/inline-stylesheets.ts @@ -18,14 +18,16 @@ export function getInlineStylesheets( resolution: SpecificResolution, variablesMap: Record, propertiesMap: Record, - neededTokens: string[] + neededTokens: string[], + useCssVars?: boolean ): InlineStylesheet[] { const declarations = createBuildDeclarations( primary, secondary, propertiesMap, (selector) => markGlobal(selector), - neededTokens + neededTokens, + useCssVars ); const declaration = { diff --git a/src/build/internal.ts b/src/build/internal.ts index 62adaa9..68ba05a 100644 --- a/src/build/internal.ts +++ b/src/build/internal.ts @@ -40,6 +40,8 @@ export interface BuildThemedComponentsInternalParams { jsonSchema?: boolean; /** Fail the build when SASS deprecation warning occurs **/ failOnDeprecations?: boolean; + /** Use CSS variables instead of resolved values for better runtime theme swapping */ + useCssVars?: boolean; } /** * Builds themed components and optionally design tokens, if not skipped. @@ -71,6 +73,7 @@ export async function buildThemedComponentsInternal(params: BuildThemedComponent descriptions = {}, jsonSchema = false, failOnDeprecations, + useCssVars = false, } = params; if (!skip.includes('design-tokens') && !designTokensOutputDir) { @@ -86,7 +89,7 @@ export async function buildThemedComponentsInternal(params: BuildThemedComponent const styleTask = buildStyles( scssDir, componentsOutputDir, - getInlineStylesheets(primary, secondary, defaults, variablesMap, propertiesMap, neededTokens), + getInlineStylesheets(primary, secondary, defaults, variablesMap, propertiesMap, neededTokens, useCssVars), { failOnDeprecations } ); const internalTokensTask = createInternalTokenFiles(defaults, propertiesMap, componentsOutputDir); diff --git a/src/shared/declaration/index.ts b/src/shared/declaration/index.ts index 2f3e37b..1f2cd07 100644 --- a/src/shared/declaration/index.ts +++ b/src/shared/declaration/index.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { mergeInPlace, Override, Theme } from '../theme'; +import { mergeInPlace, Override, Theme, resolveTheme, difference, ResolveOptions } from '../theme'; +import { flattenReferenceTokens, isModeValue } from '../theme/utils'; import type { PropertiesMap, SelectorCustomizer } from './interfaces'; import { RuleCreator } from './rule'; import { SingleThemeCreator } from './single'; @@ -10,37 +11,97 @@ import { AllPropertyRegistry, UsedPropertyRegistry } from './registry'; import { MinimalTransformer } from './transformer'; import { cloneDeep, values } from '../utils'; -function createMinimalTheme(base: Theme, override: Override): Theme { +function createMinimalTheme(base: Theme, override: Override, options?: ResolveOptions): Theme { + // Resolve both themes + const resolvedBase = resolveTheme(base, undefined, options); + const resolvedOverride = resolveTheme(override as Theme, base, options); + + // Get only the different tokens with mode-level granularity + const differentTokens = difference(resolvedBase, resolvedOverride); + + // Create minimal theme with only changed tokens const minimalTheme = cloneDeep(base); - const contextTokens: Set = new Set(); + + // Keep only tokens that have differences AND are explicitly overridden + Object.keys(minimalTheme.tokens).forEach((key) => { + const isDifferent = key in differentTokens; + const isExplicitlyOverridden = key in override.tokens; + + // When useCssVars=true, only keep tokens that are both different AND explicitly overridden + // This allows tokens that changed due to reference changes to fall back to CSS variables + if (options?.useCssVars) { + if (!isDifferent || !isExplicitlyOverridden) { + delete minimalTheme.tokens[key]; + } + } else { + // When useCssVars=false, keep all different tokens (original behavior) + if (!isDifferent) { + delete minimalTheme.tokens[key]; + } + } + }); + + // Handle contexts - only keep tokens that are different or in override contexts values(minimalTheme.contexts).forEach((context) => { Object.keys(context.tokens).forEach((key) => { - if (!(key in override.tokens) && !(key in (override?.contexts?.[context.id]?.tokens ?? {}))) { + const isInOverrideContext = key in (override?.contexts?.[context.id]?.tokens ?? {}); + if (!(key in differentTokens) && !isInOverrideContext) { delete context.tokens[key]; - } else { - contextTokens.add(key); } }); }); - Object.keys(minimalTheme.tokens).forEach((key) => { - if (!contextTokens.has(key) && !(key in override.tokens)) { - delete minimalTheme.tokens[key]; + + // Create filtered override with mode-aware token filtering + const filteredTokens: Record = {}; + for (const key in override.tokens) { + if (key in differentTokens) { + const overrideToken = override.tokens[key]; + const differentToken = differentTokens[key]; + + // If it's a mode token and difference function returned partial modes, filter the override token + if (isModeValue(overrideToken) && typeof differentToken === 'object' && differentToken !== null) { + // Only include mode values from override that are actually different + const filteredModeToken: Record = {}; + for (const mode in differentToken) { + if (mode in overrideToken) { + filteredModeToken[mode] = overrideToken[mode]; + } + } + if (Object.keys(filteredModeToken).length > 0) { + filteredTokens[key] = filteredModeToken; + } + } else { + // Non-mode token, include as-is + filteredTokens[key] = overrideToken; + } } - }); + } + const filteredOverride: Override = { + ...override, + tokens: filteredTokens, + }; - return mergeInPlace(minimalTheme, override); + return mergeInPlace(minimalTheme, filteredOverride); } export function createOverrideDeclarations( base: Theme, override: Override, propertiesMap: PropertiesMap, - selectorCustomizer: SelectorCustomizer + selectorCustomizer: SelectorCustomizer, + useCssVars?: boolean ): string { // create theme containing only modified tokens - const minimalTheme = createMinimalTheme(base, override); - const ruleCreator = new RuleCreator(new Selector(selectorCustomizer), new AllPropertyRegistry(propertiesMap)); - const stylesheetCreator = new SingleThemeCreator(minimalTheme, ruleCreator, base); + const minimalTheme = createMinimalTheme(base, override, { useCssVars, propertiesMap }); + const usedTokens = Object.keys(minimalTheme.tokens); + const ruleCreator = new RuleCreator( + new Selector(selectorCustomizer), + useCssVars ? new UsedPropertyRegistry(propertiesMap, usedTokens) : new AllPropertyRegistry(propertiesMap) + ); + const stylesheetCreator = new SingleThemeCreator(minimalTheme, ruleCreator, base, { + useCssVars, + propertiesMap, + }); const stylesheet = stylesheetCreator.create(); return stylesheet.toString(); } @@ -50,10 +111,25 @@ export function createBuildDeclarations( secondary: Theme[], propertiesMap: PropertiesMap, selectorCustomizer: SelectorCustomizer, - used: string[] + used: string[], + useCssVars?: boolean ): string { - const ruleCreator = new RuleCreator(new Selector(selectorCustomizer), new UsedPropertyRegistry(propertiesMap, used)); - const stylesheetCreator = new MultiThemeCreator([primary, ...secondary], ruleCreator); + // When CSS vars are enabled, include reference tokens from all themes in the used list + let effectiveUsed = used; + if (useCssVars) { + const allThemes = [primary, ...secondary]; + let referenceTokens: string[] = []; + allThemes.forEach((theme) => { + referenceTokens = Object.keys(flattenReferenceTokens(theme)); + }); + effectiveUsed = [...used, ...referenceTokens]; + } + + const ruleCreator = new RuleCreator( + new Selector(selectorCustomizer), + new UsedPropertyRegistry(propertiesMap, effectiveUsed) + ); + const stylesheetCreator = new MultiThemeCreator([primary, ...secondary], ruleCreator, { useCssVars, propertiesMap }); const stylesheet = stylesheetCreator.create(); const transformer = new MinimalTransformer(); const minimal = transformer.transform(stylesheet); diff --git a/src/shared/declaration/multi.ts b/src/shared/declaration/multi.ts index 562bcf3..194202e 100644 --- a/src/shared/declaration/multi.ts +++ b/src/shared/declaration/multi.ts @@ -1,7 +1,17 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { isGlobalSelector } from '../styles/selector'; -import { defaultsReducer, modeReducer, OptionalState, reduce, resolveContext, resolveTheme, Theme } from '../theme'; +import { + defaultsReducer, + modeReducer, + OptionalState, + reduce, + resolveContext, + resolveTheme, + Theme, + ResolveOptions, +} from '../theme'; +import { flattenReferenceTokens } from '../theme/utils'; import { AbstractCreator } from './abstract'; import type { StylesheetCreator } from './interfaces'; import { RuleCreator, SelectorConfig } from './rule'; @@ -16,11 +26,13 @@ import { compact } from './utils'; export class MultiThemeCreator extends AbstractCreator implements StylesheetCreator { themes: Theme[]; ruleCreator: RuleCreator; + options?: ResolveOptions; - constructor(themes: Theme[], ruleCreator: RuleCreator) { + constructor(themes: Theme[], ruleCreator: RuleCreator, options?: ResolveOptions) { super(); this.themes = themes; this.ruleCreator = ruleCreator; + this.options = options; } create(): Stylesheet { @@ -38,7 +50,9 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea if (!globalThemes.length) { // If there is no root theme, all themes are scoped by their root selector. No interference. - const stylesheets = this.themes.map((theme) => new SingleThemeCreator(theme, this.ruleCreator).create()); + const stylesheets = this.themes.map((theme) => + new SingleThemeCreator(theme, this.ruleCreator, undefined, this.options).create() + ); const result = new Stylesheet(); stylesheets.forEach((stylesheet) => { stylesheet.getAllRules().map((rule) => result.appendRuleWithPath(rule, stylesheet.getPath(rule) ?? [])); @@ -48,7 +62,7 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea const [globalTheme] = globalThemes; - const stylesheet = new SingleThemeCreator(globalTheme, this.ruleCreator).create(); + const stylesheet = new SingleThemeCreator(globalTheme, this.ruleCreator, undefined, this.options).create(); const secondaries = this.getThemesWithout(globalTheme); secondaries.forEach((secondary) => { this.appendRulesForSecondary(stylesheet, globalTheme, secondary); @@ -58,8 +72,15 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea } appendRulesForSecondary(stylesheet: Stylesheet, primary: Theme, secondary: Theme) { - const secondaryResolution = resolveTheme(secondary); + const secondaryResolution = resolveTheme(secondary, undefined, this.options); const defaults = reduce(secondaryResolution, secondary, defaultsReducer()); + + // Add CSS variable declarations for reference tokens when useCssVars is enabled + if (this.options?.useCssVars && this.options?.propertiesMap) { + const referenceDefaults = flattenReferenceTokens(secondary); + Object.assign(defaults, referenceDefaults); + } + const rootRule = this.ruleCreator.create({ global: [secondary.selector] }, defaults); const parentRule = this.findRule(stylesheet, { global: [primary.selector] }); MultiThemeCreator.appendRuleToStylesheet(stylesheet, rootRule, compact([parentRule])); @@ -80,7 +101,11 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea }); MultiThemeCreator.forEachContext(secondary, (context) => { - const contextResolution = reduce(resolveContext(secondary, context), secondary, defaultsReducer()); + const contextResolution = reduce( + resolveContext(secondary, context, undefined, undefined, this.options), + secondary, + defaultsReducer() + ); const contextRule = this.ruleCreator.create( { global: [secondary.selector], local: [context.selector] }, contextResolution @@ -110,7 +135,11 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea MultiThemeCreator.forEachContextWithinOptionalModeState(secondary, (context, mode, state) => { const optionalState = mode.states[state] as OptionalState; - const contextResolution = reduce(resolveContext(secondary, context), secondary, modeReducer(mode, state)); + const contextResolution = reduce( + resolveContext(secondary, context, undefined, undefined, this.options), + secondary, + modeReducer(mode, state) + ); const contextRule = this.findRule(stylesheet, { global: [secondary.selector], local: [context.selector] }); const modeRule = this.findRule(stylesheet, { global: [secondary.selector, optionalState.selector], diff --git a/src/shared/declaration/single.ts b/src/shared/declaration/single.ts index f991650..1efa03a 100644 --- a/src/shared/declaration/single.ts +++ b/src/shared/declaration/single.ts @@ -9,6 +9,7 @@ import { resolveContext, resolveTheme, Theme, + ResolveOptions, } from '../theme'; import Stylesheet from './stylesheet'; import { AbstractCreator } from './abstract'; @@ -21,19 +22,22 @@ export class SingleThemeCreator extends AbstractCreator implements StylesheetCre baseTheme?: Theme; resolution: FullResolution; ruleCreator: RuleCreator; + options?: ResolveOptions; - constructor(theme: Theme, ruleCreator: RuleCreator, baseTheme?: Theme) { + constructor(theme: Theme, ruleCreator: RuleCreator, baseTheme?: Theme, options?: ResolveOptions) { super(); this.theme = theme; this.baseTheme = baseTheme; - this.resolution = resolveTheme(theme, this.baseTheme); + this.resolution = resolveTheme(theme, this.baseTheme, options); this.ruleCreator = ruleCreator; + this.options = options; } create(): Stylesheet { const stylesheet = new Stylesheet(); const defaults = reduce(this.resolution, this.theme, defaultsReducer(), this.baseTheme); + const rootRule = this.ruleCreator.create({ global: [this.theme.selector] }, defaults); SingleThemeCreator.appendRuleToStylesheet(stylesheet, rootRule, []); @@ -49,7 +53,7 @@ export class SingleThemeCreator extends AbstractCreator implements StylesheetCre SingleThemeCreator.forEachContext(this.theme, (context) => { const contextResolution = reduce( - resolveContext(this.theme, context, this.baseTheme, this.resolution), + resolveContext(this.theme, context, this.baseTheme, this.resolution, this.options), this.theme, defaultsReducer(), this.baseTheme @@ -69,7 +73,7 @@ export class SingleThemeCreator extends AbstractCreator implements StylesheetCre SingleThemeCreator.forEachContextWithinOptionalModeState(this.theme, (context, mode, state) => { const contextResolution = reduce( - resolveContext(this.theme, context, this.baseTheme, this.resolution), + resolveContext(this.theme, context, this.baseTheme, this.resolution, this.options), this.theme, modeReducer(mode, state), this.baseTheme diff --git a/src/shared/theme/builder.ts b/src/shared/theme/builder.ts index 4baccfb..c956979 100644 --- a/src/shared/theme/builder.ts +++ b/src/shared/theme/builder.ts @@ -1,15 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { - Theme, - Context, - Mode, - ReferenceTokens, - ColorReferenceTokens, - ColorPaletteInput, - PaletteStep, - ColorPaletteDefinition, -} from './interfaces'; +import { Theme, Context, Mode, ReferenceTokens } from './interfaces'; +import { processReferenceTokens } from './process'; export type TokenCategory = Record; @@ -55,13 +47,15 @@ export class ThemeBuilder { return this; } + // Adds processed reference tokens to tokens object addReferenceTokens(referenceTokens: ReferenceTokens): ThemeBuilder { this.theme.referenceTokens = referenceTokens; // Process reference tokens and add generated tokens to theme if (referenceTokens.color) { - const generatedTokens = this.processReferenceTokens(referenceTokens.color); - this.theme.tokens = { ...this.theme.tokens, ...generatedTokens }; + const generatedTokens = processReferenceTokens(referenceTokens.color); + // Reference tokens should override existing tokens + this.theme.tokens = { ...generatedTokens, ...this.theme.tokens }; } return this; @@ -70,45 +64,4 @@ export class ThemeBuilder { build(): Theme { return this.theme; } - - private processReferenceTokens(colorTokens: ColorReferenceTokens): TokenCategory { - const generatedTokens: TokenCategory = {}; - - Object.entries(colorTokens).forEach(([colorName, paletteInput]) => { - const palette = this.processColorPaletteInput(paletteInput); - - // Add generated palette tokens with naming convention: colorPrimary50, colorPrimary600, etc. - Object.entries(palette).forEach(([step, value]) => { - const tokenName = `color${this.capitalize(colorName)}${step}`; - generatedTokens[tokenName] = value; - }); - }); - - return generatedTokens; - } - - // Right now just validates steps, but will also handle seed token color generation in a future PR - private processColorPaletteInput(input: ColorPaletteInput): ColorPaletteDefinition { - const validSteps: number[] = []; - // Add steps 50-1000 in increments of 50 - for (let i = 50; i <= 1000; i += 50) { - validSteps.push(i); - } - - const result: ColorPaletteDefinition = {}; - - // Add explicit step values - Object.entries(input).forEach(([step, value]) => { - const numStep = Number(step); - if (value && validSteps.includes(numStep)) { - result[numStep as PaletteStep] = value; - } - }); - - return result; - } - - private capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); - } } diff --git a/src/shared/theme/index.ts b/src/shared/theme/index.ts index 4c71d4d..d39b658 100644 --- a/src/shared/theme/index.ts +++ b/src/shared/theme/index.ts @@ -29,6 +29,7 @@ export { FullResolution, SpecificResolution, FullResolutionPaths, + ResolveOptions, } from './resolve'; export { validateOverride } from './validate'; export { merge, mergeInPlace } from './merge'; diff --git a/src/shared/theme/interfaces.ts b/src/shared/theme/interfaces.ts index 0e5d101..8d53b72 100644 --- a/src/shared/theme/interfaces.ts +++ b/src/shared/theme/interfaces.ts @@ -89,12 +89,13 @@ export type PaletteStep = /** * Color palette definition with explicit color values for palette steps. */ -export type ColorPaletteDefinition = Partial>; +export type ColorPaletteDefinition = Partial>; type Tokens = Partial>; export interface Override { tokens: Tokens; + referenceTokens?: ReferenceTokens; contexts?: Record; } diff --git a/src/shared/theme/process.ts b/src/shared/theme/process.ts new file mode 100644 index 0000000..be22ba9 --- /dev/null +++ b/src/shared/theme/process.ts @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ColorReferenceTokens, ColorPaletteInput, PaletteStep, ColorPaletteDefinition } from './interfaces'; +import { generateReferenceTokenName } from './utils'; + +export type TokenCategory = Record; + +export function processReferenceTokens(colorTokens: ColorReferenceTokens): TokenCategory { + const generatedTokens: TokenCategory = {}; + + Object.entries(colorTokens).forEach(([colorName, paletteInput]) => { + const palette = processColorPaletteInput(paletteInput); + + // Add generated palette tokens with naming convention: colorPrimary50, colorPrimary600, etc. + Object.entries(palette).forEach(([step, value]) => { + const tokenName = generateReferenceTokenName('color', colorName, step); + generatedTokens[tokenName] = value; + }); + }); + + return generatedTokens; +} + +// Right now just validates steps, but will also handle seed token color generation in a future PR +export function processColorPaletteInput(input: ColorPaletteInput): ColorPaletteDefinition { + const validSteps: number[] = []; + // Add steps 50-1000 in increments of 50 + for (let i = 50; i <= 1000; i += 50) { + validSteps.push(i); + } + + const result: ColorPaletteDefinition = {}; + + // Add explicit step values + Object.entries(input).forEach(([step, value]) => { + const numStep = Number(step); + if (value && validSteps.includes(numStep)) { + result[numStep as PaletteStep] = value; + } + }); + + return result; +} diff --git a/src/shared/theme/resolve.ts b/src/shared/theme/resolve.ts index db6f400..a284054 100644 --- a/src/shared/theme/resolve.ts +++ b/src/shared/theme/resolve.ts @@ -3,7 +3,20 @@ import { Context, Mode } from '.'; import { cloneDeep, values } from '../utils'; import { Theme, Value } from './interfaces'; -import { areAssignmentsEqual, getDefaultState, getMode, getReference, isModeValue, isReference } from './utils'; +import { + areAssignmentsEqual, + getDefaultState, + getMode, + getReference, + isModeValue, + isReference, + isReferenceToken, +} from './utils'; + +export interface ResolveOptions { + useCssVars?: boolean; + propertiesMap?: Record; +} export type ModeTokenResolution = Record; export type SpecificTokenResolution = Value; @@ -27,10 +40,14 @@ interface FullResolutionWithPaths { * If a base theme is provided, only keep tokens that are in the override theme or those that * have an overridden token in their resolution path */ -export function resolveTheme(theme: Theme, baseTheme?: Theme): FullResolution { - return resolveThemeWithPaths(theme, baseTheme).resolvedTheme; +export function resolveTheme(theme: Theme, baseTheme?: Theme, options?: ResolveOptions): FullResolution { + return resolveThemeWithPaths(theme, baseTheme, options).resolvedTheme; } -export function resolveThemeWithPaths(theme: Theme, baseTheme?: Theme): FullResolutionWithPaths { +export function resolveThemeWithPaths( + theme: Theme, + baseTheme?: Theme, + options?: ResolveOptions +): FullResolutionWithPaths { const resolvedTheme: FullResolution = {}; const resolutionPaths: FullResolutionPaths = {}; @@ -40,7 +57,7 @@ export function resolveThemeWithPaths(theme: Theme, baseTheme?: Theme): FullReso const modeTokenResolutionPaths: ModeTokenResolutionPath = {}; const resolvedToken = Object.keys(mode.states).reduce>((acc, state: string) => { modeTokenResolutionPaths[state] = []; - acc[state] = resolveToken(theme, token, modeTokenResolutionPaths[state], state, baseTheme); + acc[state] = resolveToken(theme, token, modeTokenResolutionPaths[state], state, baseTheme, options); return acc; }, {}); @@ -53,7 +70,7 @@ export function resolveThemeWithPaths(theme: Theme, baseTheme?: Theme): FullReso } } else { const tokenResolutionPath: SpecificTokenResolutionPath = []; - const resolvedToken = resolveToken(theme, token, tokenResolutionPath, undefined, baseTheme); + const resolvedToken = resolveToken(theme, token, tokenResolutionPath, undefined, baseTheme, options); if (!baseTheme || tokenResolutionPath.some((pathToken) => pathToken in theme.tokens)) { resolutionPaths[token] = tokenResolutionPath; @@ -65,7 +82,14 @@ export function resolveThemeWithPaths(theme: Theme, baseTheme?: Theme): FullReso return { resolvedTheme, resolutionPaths }; } -function resolveToken(theme: Theme, token: string, path: Array, state?: string, baseTheme?: Theme): string { +function resolveToken( + theme: Theme, + token: string, + path: Array, + state?: string, + baseTheme?: Theme, + options?: ResolveOptions +): string { if (!theme.tokens[token] && !baseTheme?.tokens[token]) { throw new Error(`Token ${token} does not exist in the theme.`); } @@ -90,7 +114,19 @@ function resolveToken(theme: Theme, token: string, path: Array, state?: if (isReference(assignment)) { const ref = getReference(assignment); - return resolveToken(theme, ref, path, state, baseTheme); + const resolvedValue = resolveToken(theme, ref, path, state, baseTheme, options); + + // If CSS vars enabled and the referenced token is a reference token, return CSS variable + if ( + options?.useCssVars && + options?.propertiesMap && + isReferenceToken('color', theme, ref) && + options.propertiesMap[ref] + ) { + return `var(${options.propertiesMap[ref]})`; + } + + return resolvedValue; } else { return assignment; } @@ -100,7 +136,8 @@ export function resolveContext( theme: Theme, context: Context, baseTheme?: Theme, - themeResolution?: FullResolution + themeResolution?: FullResolution, + options?: ResolveOptions ): FullResolution { const tmp = cloneDeep(theme); @@ -109,7 +146,7 @@ export function resolveContext( ...tmp.tokens, ...context.tokens, }; - return resolveTheme(tmp, baseTheme); + return resolveTheme(tmp, baseTheme, options); } /** diff --git a/src/shared/theme/utils.ts b/src/shared/theme/utils.ts index 34e84ec..99317fc 100644 --- a/src/shared/theme/utils.ts +++ b/src/shared/theme/utils.ts @@ -1,8 +1,53 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Assignment, DefaultState, OptionalState, Theme } from './interfaces'; +import { Assignment, DefaultState, OptionalState, ReferenceTokens, Theme } from './interfaces'; import { Value, Reference, ModeValue, Mode } from './interfaces'; +export function isReferenceToken(category: keyof ReferenceTokens, theme: Theme, token: string): boolean { + const categoryTokens = theme.referenceTokens?.[category]; + if (!categoryTokens) return false; + + return Object.entries(categoryTokens).some(([type, set]) => { + if (!set) return false; + return Object.keys(set).some((step) => generateReferenceTokenName(category, type, step) === token); + }); +} + +export function flattenObject(obj: any, prefix: string[] = []): Record { + const result: Record = {}; + + if (!obj || typeof obj !== 'object') { + return result; + } + + for (const [key, value] of Object.entries(obj)) { + const path = [...prefix, key]; + + if (typeof value === 'string') { + result[generateCamelCaseName(...path)] = value; + } else if (value && typeof value === 'object') { + Object.assign(result, flattenObject(value, path)); + } + } + + return result; +} + +export function generateCamelCaseName(...segments: string[]): string { + return segments.reduce( + (acc, segment, index) => acc + (index === 0 ? segment : segment.charAt(0).toUpperCase() + segment.slice(1)), + '' + ); +} + +export function flattenReferenceTokens(theme: Theme): Record { + return theme.referenceTokens?.color ? flattenObject(theme.referenceTokens.color, ['color']) : {}; +} + +export function generateReferenceTokenName(category: string, type: string, step: string): string { + return generateCamelCaseName(category, type, step); +} + export function isValue(val: unknown): val is Value { return typeof val === 'string' && !isReference(val); } diff --git a/src/shared/theme/validate.ts b/src/shared/theme/validate.ts index 2d8536c..5c0e87c 100644 --- a/src/shared/theme/validate.ts +++ b/src/shared/theme/validate.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { entries, fromEntries, includes } from '../utils'; import { Override, Theme, ThemePreset, Token } from './interfaces'; +import { processReferenceTokens } from './process'; /** * This function compares the theme override against the list of tokens that are allowed @@ -40,7 +41,7 @@ export function validateOverride(override: Override, themeable: Token[], availab return isValid; } - const tokensEntries = entries(override.tokens).filter(([token]) => isThemeable(token)); + const tokensEntries: [string, any][] = entries(override.tokens).filter(([token]) => isThemeable(token)); type Context = NonNullable[string]>; @@ -62,9 +63,19 @@ export function validateOverride(override: Override, themeable: Token[], availab return [contextId, newContext] as [string, Context]; }); + let completeTokens = {}; + if (override.referenceTokens?.color) { + const generatedTokens = processReferenceTokens(override.referenceTokens.color); + // Reference tokens should override existing tokens + completeTokens = { ...fromEntries(tokensEntries), ...generatedTokens }; + } else { + completeTokens = fromEntries(tokensEntries); + } + return { contexts: fromEntries(contextEntries), - tokens: fromEntries(tokensEntries), + tokens: completeTokens, + referenceTokens: override.referenceTokens, }; }