diff --git a/package-lock.json b/package-lock.json index 17be393..411b338 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.18.6", "@cloudscape-design/browser-test-tools": "^3.0.2", "@cloudscape-design/component-toolkit": "^1.0.0-beta.53", + "@material/material-color-utilities": "^0.3.0", "@tsconfig/node16": "^1.0.3", "@types/loader-utils": "^2.0.3", "@types/lodash": "^4.14.183", @@ -1886,6 +1887,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@material/material-color-utilities": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.3.0.tgz", + "integrity": "sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==", + "license": "Apache-2.0" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 41c6947..8fcd700 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "@babel/plugin-transform-modules-commonjs": "^7.18.6", "@cloudscape-design/browser-test-tools": "^3.0.2", "@cloudscape-design/component-toolkit": "^1.0.0-beta.53", + "@material/material-color-utilities": "^0.3.0", "@tsconfig/node16": "^1.0.3", "@types/loader-utils": "^2.0.3", "@types/lodash": "^4.14.183", - "@types/node": "^18.19.23", "@types/postcss-inline-svg": "^5.0.0", "@types/rimraf": "^3.0.2", "@types/string-hash": "^1.1.1", @@ -66,5 +66,8 @@ "package-lock.json": [ "./scripts/unlock-packages.js" ] + }, + "devDependencies": { + "@types/node": "^24.10.0" } } diff --git a/scripts/generate-package.js b/scripts/generate-package.js index 1b690fa..d464453 100644 --- a/scripts/generate-package.js +++ b/scripts/generate-package.js @@ -23,6 +23,7 @@ const packages = [ }, packageRoot: path.join(root, './lib/node'), dependencies: [ + '@material/material-color-utilities', 'autoprefixer', 'glob', 'jsonschema', @@ -44,7 +45,7 @@ const packages = [ files: ['shared', 'browser'], }, packageRoot: path.join(root, './lib/browser'), - dependencies: ['tslib'], + dependencies: ['@material/material-color-utilities', 'tslib'], }, ]; diff --git a/src/__fixtures__/common.ts b/src/__fixtures__/common.ts index ff00042..aefb870 100644 --- a/src/__fixtures__/common.ts +++ b/src/__fixtures__/common.ts @@ -90,7 +90,7 @@ const emptyTheme: Theme = { export const rootTheme: Theme = { id: 'root', - selector: ':root', + selector: 'body', tokens: { fontFamilyBase: '"Helvetica Neue", Arial, sans-serif', fontFamilyBody: '{fontFamilyBase}', @@ -435,3 +435,59 @@ export const descriptionsForValidTheme = { colorButton: 'color', shadowContainer: 'shadow', }; + +// Themes for performance testing: seed vs explicit palette +export const themeWithSeedColor: Theme = { + ...rootTheme, + referenceTokens: { + color: { + primary: '#0073bb', + }, + }, +}; + +export const themeWithExplicitPalette: Theme = { + ...rootTheme, + referenceTokens: { + color: { + primary: { + 50: '#f7f9ff', + 100: '#ecf3ff', + 200: '#d2e6ff', + 300: '#aed3ff', + 400: '#7abbff', + 500: '#4e9fea', + 600: '#0073bb', + 700: '#006aad', + 800: '#005187', + 900: '#003b64', + 1000: '#001123', + }, + }, + }, +}; + +export const presetWithSeedColor: ThemePreset = { + theme: themeWithSeedColor, + themeable: [], + exposed: [], + propertiesMap: createStubPropertiesMap(themeWithSeedColor), + variablesMap: createStubVariablesMap(themeWithSeedColor), +}; + +export const presetWithExplicitPalette: ThemePreset = { + theme: themeWithExplicitPalette, + themeable: [], + exposed: [], + propertiesMap: createStubPropertiesMap(themeWithExplicitPalette), + variablesMap: createStubVariablesMap(themeWithExplicitPalette), +}; + +export const overrideWithSeedColor: Override = { + tokens: { ...override.tokens }, + referenceTokens: { + color: { + primary: '#0073bb', + }, + }, +}; diff --git a/src/browser/__tests__/__snapshots__/index.test.ts.snap b/src/browser/__tests__/__snapshots__/index.test.ts.snap index 03ffe8f..c83deb6 100644 --- a/src/browser/__tests__/__snapshots__/index.test.ts.snap +++ b/src/browser/__tests__/__snapshots__/index.test.ts.snap @@ -2,6 +2,9 @@ exports[`applyTheme > with baseThemeId > attaches one style node containing overrides with the correct theme selector 1`] = ` ".secondary-theme.secondary-theme:not(#\\9){ + --black-css:purple; + --grey-css:grey; + --brown-css:black; --shadow-css:yellow; --buttonShadow-css:red; --boxShadow-css:green; @@ -9,38 +12,29 @@ exports[`applyTheme > with baseThemeId > attaches one style node containing over } @media not print {.dark.dark.secondary-theme:not(#\\9){ --shadow-css:orange; - --buttonShadow-css:red; - --boxShadow-css:black; - --lineShadow-css:pink; + --boxShadow-css:var(--brown-css); }} .secondary-theme.secondary-theme .navigation:not(#\\9){ --shadow-css:pink; - --buttonShadow-css:red; - --boxShadow-css:green; - --lineShadow-css:pink; } .navigation.navigation.secondary-theme:not(#\\9){ --shadow-css:pink; - --buttonShadow-css:red; - --boxShadow-css:green; - --lineShadow-css:pink; } @media not print {.dark.dark.secondary-theme .navigation:not(#\\9){ - --shadow-css:grey; + --shadow-css:var(--grey-css); --buttonShadow-css:green; - --boxShadow-css:black; - --lineShadow-css:pink; }} @media not print {.dark.dark.navigation.secondary-theme:not(#\\9){ - --shadow-css:grey; + --shadow-css:var(--grey-css); --buttonShadow-css:green; - --boxShadow-css:black; - --lineShadow-css:pink; }}" `; exports[`applyTheme > with secondary theme > attaches one style node containing override 1`] = ` -":root:root{ +"body:not(#\\9){ + --black-css:black; + --grey-css:grey; + --brown-css:brown; --shadow-css:yellow; --buttonShadow-css:red; --boxShadow-css:green; @@ -48,32 +42,27 @@ exports[`applyTheme > with secondary theme > attaches one style node containing } @media not print {.dark.dark:not(#\\9){ --shadow-css:orange; - --buttonShadow-css:red; - --boxShadow-css:brown; - --lineShadow-css:pink; + --boxShadow-css:var(--brown-css); }} .navigation.navigation:not(#\\9){ --shadow-css:pink; - --buttonShadow-css:red; --boxShadow-css:purple; - --lineShadow-css:pink; } @media not print {.dark.dark .navigation:not(#\\9){ - --shadow-css:brown; + --shadow-css:var(--brown-css); --buttonShadow-css:green; - --boxShadow-css:purple; - --lineShadow-css:pink; }} @media not print {.dark.dark.navigation:not(#\\9){ - --shadow-css:brown; + --shadow-css:var(--brown-css); --buttonShadow-css:green; - --boxShadow-css:purple; - --lineShadow-css:pink; }}" `; exports[`applyTheme > with targetDocument > attaches one style node containing override on the target document 1`] = ` -":root:root{ +"body:not(#\\9){ + --black-css:black; + --grey-css:grey; + --brown-css:brown; --shadow-css:yellow; --buttonShadow-css:red; --boxShadow-css:green; @@ -81,32 +70,27 @@ exports[`applyTheme > with targetDocument > attaches one style node containing o } @media not print {.dark.dark:not(#\\9){ --shadow-css:orange; - --buttonShadow-css:red; - --boxShadow-css:brown; - --lineShadow-css:pink; + --boxShadow-css:var(--brown-css); }} .navigation.navigation:not(#\\9){ --shadow-css:pink; - --buttonShadow-css:red; --boxShadow-css:purple; - --lineShadow-css:pink; } @media not print {.dark.dark .navigation:not(#\\9){ - --shadow-css:brown; + --shadow-css:var(--brown-css); --buttonShadow-css:green; - --boxShadow-css:purple; - --lineShadow-css:pink; }} @media not print {.dark.dark.navigation:not(#\\9){ - --shadow-css:brown; + --shadow-css:var(--brown-css); --buttonShadow-css:green; - --boxShadow-css:purple; - --lineShadow-css:pink; }}" `; exports[`applyTheme > without secondary theme > attaches one style node containing override 1`] = ` -":root:root{ +"body:not(#\\9){ + --black-css:black; + --grey-css:grey; + --brown-css:brown; --shadow-css:yellow; --buttonShadow-css:red; --boxShadow-css:green; @@ -114,32 +98,27 @@ exports[`applyTheme > without secondary theme > attaches one style node containi } @media not print {.dark.dark:not(#\\9){ --shadow-css:orange; - --buttonShadow-css:red; - --boxShadow-css:brown; - --lineShadow-css:pink; + --boxShadow-css:var(--brown-css); }} .navigation.navigation:not(#\\9){ --shadow-css:pink; - --buttonShadow-css:red; --boxShadow-css:purple; - --lineShadow-css:pink; } @media not print {.dark.dark .navigation:not(#\\9){ - --shadow-css:brown; + --shadow-css:var(--brown-css); --buttonShadow-css:green; - --boxShadow-css:purple; - --lineShadow-css:pink; }} @media not print {.dark.dark.navigation:not(#\\9){ - --shadow-css:brown; + --shadow-css:var(--brown-css); --buttonShadow-css:green; - --boxShadow-css:purple; - --lineShadow-css:pink; }}" `; exports[`generateThemeStylesheet > with baseThemeId > creates override styles 1`] = ` ".secondary-theme.secondary-theme:not(#\\9){ + --black-css:purple; + --grey-css:grey; + --brown-css:black; --shadow-css:yellow; --buttonShadow-css:red; --boxShadow-css:green; @@ -147,38 +126,36 @@ exports[`generateThemeStylesheet > with baseThemeId > creates override styles 1` } @media not print {.dark.dark.secondary-theme:not(#\\9){ --shadow-css:orange; - --buttonShadow-css:red; - --boxShadow-css:black; - --lineShadow-css:pink; + --boxShadow-css:var(--brown-css); }} .secondary-theme.secondary-theme .navigation:not(#\\9){ --shadow-css:pink; - --buttonShadow-css:red; - --boxShadow-css:green; - --lineShadow-css:pink; } .navigation.navigation.secondary-theme:not(#\\9){ --shadow-css:pink; - --buttonShadow-css:red; - --boxShadow-css:green; - --lineShadow-css:pink; } @media not print {.dark.dark.secondary-theme .navigation:not(#\\9){ - --shadow-css:grey; + --shadow-css:var(--grey-css); --buttonShadow-css:green; - --boxShadow-css:black; - --lineShadow-css:pink; }} @media not print {.dark.dark.navigation.secondary-theme:not(#\\9){ - --shadow-css:grey; + --shadow-css:var(--grey-css); --buttonShadow-css:green; - --boxShadow-css:black; - --lineShadow-css:pink; }}" `; +exports[`generateThemeStylesheet > with reference tokens > creates override styles with CSS variables 1`] = ` +"body:not(#\\9){ + --colorPrimary600-css:#ff6600; + --colorPrimary700-css:#692dc9; +}" +`; + exports[`generateThemeStylesheet > with secondary theme > creates override styles 1`] = ` -":root:root{ +"body:not(#\\9){ + --black-css:black; + --grey-css:grey; + --brown-css:brown; --shadow-css:yellow; --buttonShadow-css:red; --boxShadow-css:green; @@ -186,32 +163,27 @@ exports[`generateThemeStylesheet > with secondary theme > creates override style } @media not print {.dark.dark:not(#\\9){ --shadow-css:orange; - --buttonShadow-css:red; - --boxShadow-css:brown; - --lineShadow-css:pink; + --boxShadow-css:var(--brown-css); }} .navigation.navigation:not(#\\9){ --shadow-css:pink; - --buttonShadow-css:red; --boxShadow-css:purple; - --lineShadow-css:pink; } @media not print {.dark.dark .navigation:not(#\\9){ - --shadow-css:brown; + --shadow-css:var(--brown-css); --buttonShadow-css:green; - --boxShadow-css:purple; - --lineShadow-css:pink; }} @media not print {.dark.dark.navigation:not(#\\9){ - --shadow-css:brown; + --shadow-css:var(--brown-css); --buttonShadow-css:green; - --boxShadow-css:purple; - --lineShadow-css:pink; }}" `; exports[`generateThemeStylesheet > without secondary theme > creates override styles 1`] = ` -":root:root{ +"body:not(#\\9){ + --black-css:black; + --grey-css:grey; + --brown-css:brown; --shadow-css:yellow; --buttonShadow-css:red; --boxShadow-css:green; @@ -219,26 +191,18 @@ exports[`generateThemeStylesheet > without secondary theme > creates override st } @media not print {.dark.dark:not(#\\9){ --shadow-css:orange; - --buttonShadow-css:red; - --boxShadow-css:brown; - --lineShadow-css:pink; + --boxShadow-css:var(--brown-css); }} .navigation.navigation:not(#\\9){ --shadow-css:pink; - --buttonShadow-css:red; --boxShadow-css:purple; - --lineShadow-css:pink; } @media not print {.dark.dark .navigation:not(#\\9){ - --shadow-css:brown; + --shadow-css:var(--brown-css); --buttonShadow-css:green; - --boxShadow-css:purple; - --lineShadow-css:pink; }} @media not print {.dark.dark.navigation:not(#\\9){ - --shadow-css:brown; + --shadow-css:var(--brown-css); --buttonShadow-css:green; - --boxShadow-css:purple; - --lineShadow-css:pink; }}" `; diff --git a/src/browser/__tests__/index.test.ts b/src/browser/__tests__/index.test.ts index 641c0df..bcaa53c 100644 --- a/src/browser/__tests__/index.test.ts +++ b/src/browser/__tests__/index.test.ts @@ -1,11 +1,85 @@ // 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, + presetWithSeedColor, + presetWithExplicitPalette, + overrideWithSeedColor, +} 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 CSS variable generation +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 +200,47 @@ describe('generateThemeStylesheet', () => { ).toThrow(`Specified baseThemeId 'invalid' is not available. Available values are 'root', 'secondary'.`); }); }); + + describe('with reference tokens', () => { + test('creates override styles with CSS variables', () => { + const styles = generateThemeStylesheet({ + override: overrideWithReferenceTokens, + preset: presetWithReferenceTokens, + }); + + expect(styles).toMatchSnapshot(); + }); + }); + + describe('performance: seed vs explicit palette', () => { + test('applyTheme with seed in preset', () => { + const start = performance.now(); + applyTheme({ preset: presetWithSeedColor, override }); + const duration = performance.now() - start; + + console.log(`applyTheme with seed in preset: ${duration.toFixed(2)}ms`); + // Baseline: ~1.4ms, allow 10x headroom + expect(duration).toBeLessThan(15); + }); + + test('applyTheme with seed in override', () => { + const start = performance.now(); + applyTheme({ preset, override: overrideWithSeedColor }); + const duration = performance.now() - start; + + console.log(`applyTheme with seed in override: ${duration.toFixed(2)}ms`); + // Baseline: ~6.3ms, allow 5x headroom (primary optimization target) + expect(duration).toBeLessThan(30); + }); + + test('applyTheme with explicit palette', () => { + const start = performance.now(); + applyTheme({ preset: presetWithExplicitPalette, override }); + const duration = performance.now() - start; + + console.log(`applyTheme with explicit palette: ${duration.toFixed(2)}ms`); + // Baseline: ~1.0ms, allow 10x headroom + expect(duration).toBeLessThan(10); + }); + }); }); diff --git a/src/browser/index.ts b/src/browser/index.ts index fbdd3d2..c438f54 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -52,4 +52,15 @@ export function applyTheme(params: ApplyThemeParams): ApplyThemeResult { }; } -export { Theme, Override, ThemePreset, Value, GlobalValue, TypedModeValueOverride } from '../shared/theme'; +export { + Theme, + Override, + ThemePreset, + Value, + GlobalValue, + TypedModeValueOverride, + ReferenceTokens, + ColorReferenceTokens, + ReferencePaletteDefinition, + processColorPaletteInput, +} from '../shared/theme'; diff --git a/src/build/__tests__/__fixtures__/template/internal/generated/theming/with-secondary-theme.js b/src/build/__tests__/__fixtures__/template/internal/generated/theming/with-secondary-theme.js index ef0f744..7e57ad3 100644 --- a/src/build/__tests__/__fixtures__/template/internal/generated/theming/with-secondary-theme.js +++ b/src/build/__tests__/__fixtures__/template/internal/generated/theming/with-secondary-theme.js @@ -3,7 +3,7 @@ export var preset = { theme: { id: 'theme', - selector: ':root', + selector: 'body', tokens: { fontFamilyBase: "'Amazon Ember', 'Helvetica Neue', Roboto, Arial, sans-serif", colorOrange500: '#ec7211', diff --git a/src/build/__tests__/__snapshots__/internal.test.ts.snap b/src/build/__tests__/__snapshots__/internal.test.ts.snap index 649b8ec..8789fba 100644 --- a/src/build/__tests__/__snapshots__/internal.test.ts.snap +++ b/src/build/__tests__/__snapshots__/internal.test.ts.snap @@ -3,16 +3,20 @@ exports[`builds internal themed components without errors 1`] = ` ":root { --font-family-base-91xi75:"Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; - --color-background-button-primary-default-nhput1:#ec7211; - --color-background-button-primary-active-xbf2p5:#dd6b10; - --space-scaled-xs-yqelf2:8px; + --color-orange-500-vz48fa:#ec7211; + --color-orange-700-sk82ji:#dd6b10; + --color-background-button-primary-default-nhput1:var(--color-orange-500-vz48fa); + --color-background-button-primary-active-xbf2p5:var(--color-orange-700-sk82ji); + --space-xxs-c0i4qj:4px; + --space-xs-djjbsd:8px; + --space-scaled-xs-yqelf2:var(--space-xs-djjbsd); } .compact-mode:not(#\\9) { - --space-scaled-xs-yqelf2:4px; + --space-scaled-xs-yqelf2:var(--space-xxs-c0i4qj); } .dark-mode:not(#\\9) { - --color-background-button-primary-active-xbf2p5:#ec7211; + --color-background-button-primary-active-xbf2p5:var(--color-orange-500-vz48fa); }" `; diff --git a/src/build/__tests__/__snapshots__/public-secondary.test.ts.snap b/src/build/__tests__/__snapshots__/public-secondary.test.ts.snap index 4f6ff9d..f12accf 100644 --- a/src/build/__tests__/__snapshots__/public-secondary.test.ts.snap +++ b/src/build/__tests__/__snapshots__/public-secondary.test.ts.snap @@ -1,56 +1,68 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Build-time theming of main theme with matching baseThemeId generates the correct css files 1`] = ` -":root { +"body { --font-family-base-rpbu7d:"Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; + --color-orange-500-25sm1g:#ec7211; + --color-orange-700-2auf4j:#dd6b10; --color-background-button-primary-default-cqohzr:#aaaaaa; - --color-background-button-primary-active-h8she0:#dd6b10; - --space-scaled-xs-26ut8b:8px; + --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); + --space-xxs-ihlmrd:4px; + --space-xs-xhls0c:8px; + --space-scaled-xs-26ut8b:var(--space-xs-xhls0c); } .compact-mode:not(#\\9) { - --space-scaled-xs-26ut8b:4px; + --space-scaled-xs-26ut8b:var(--space-xxs-ihlmrd); } .dark-mode:not(#\\9) { --color-background-button-primary-default-cqohzr:#bbbbbb; - --color-background-button-primary-active-h8she0:#ec7211; + --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); } .secondary:not(#\\9) { - --color-background-button-primary-default-cqohzr:#ec72aa; - --color-background-button-primary-active-h8she0:#dd6baa; + --color-orange-500-25sm1g:#ec72aa; + --color-orange-700-2auf4j:#dd6baa; + --color-background-button-primary-default-cqohzr:var(--color-orange-500-25sm1g); + --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); } .dark-mode.secondary:not(#\\9) { - --color-background-button-primary-default-cqohzr:#dd6baa; - --color-background-button-primary-active-h8she0:#ec72aa; + --color-background-button-primary-default-cqohzr:var(--color-orange-700-2auf4j); + --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); }" `; exports[`Build-time theming of secondary theme generates the correct css files 1`] = ` -":root { +"body { --font-family-base-rpbu7d:"Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; - --color-background-button-primary-default-l567s3:#ec7211; - --color-background-button-primary-active-h8she0:#dd6b10; - --space-scaled-xs-26ut8b:8px; + --color-orange-500-25sm1g:#ec7211; + --color-orange-700-2auf4j:#dd6b10; + --color-background-button-primary-default-l567s3:var(--color-orange-500-25sm1g); + --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); + --space-xxs-ihlmrd:4px; + --space-xs-xhls0c:8px; + --space-scaled-xs-26ut8b:var(--space-xs-xhls0c); } .compact-mode:not(#\\9) { - --space-scaled-xs-26ut8b:4px; + --space-scaled-xs-26ut8b:var(--space-xxs-ihlmrd); } .dark-mode:not(#\\9) { - --color-background-button-primary-active-h8she0:#ec7211; + --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); } .secondary:not(#\\9) { + --color-orange-500-25sm1g:#ec72aa; + --color-orange-700-2auf4j:#dd6baa; --color-background-button-primary-default-l567s3:#aaaaaa; - --color-background-button-primary-active-h8she0:#dd6baa; + --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); } .dark-mode.secondary:not(#\\9) { --color-background-button-primary-default-l567s3:#bbbbbb; - --color-background-button-primary-active-h8she0:#ec72aa; + --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); }" `; diff --git a/src/build/__tests__/css-vars.test.ts b/src/build/__tests__/css-vars.test.ts new file mode 100644 index 0000000..5ba1b81 --- /dev/null +++ b/src/build/__tests__/css-vars.test.ts @@ -0,0 +1,156 @@ +// 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: 'body', + 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, + 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, 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, 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']); + + 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', + ]); + + // Should contain reference tokens from both primary and secondary themes + expect(css).toContain('--color-primary-500'); + expect(css).toContain('[data-theme="dark"]'); +}); diff --git a/src/build/index.ts b/src/build/index.ts index 758847a..5bddb0f 100644 --- a/src/build/index.ts +++ b/src/build/index.ts @@ -13,9 +13,6 @@ export { Value, resolveTheme, resolveThemeWithPaths, + ReferencePaletteDefinition, ReferenceTokens, - ColorReferenceTokens, - ColorPaletteInput, - ColorPaletteDefinition, - PaletteStep, } from '../shared/theme'; diff --git a/src/build/needed-tokens/index.ts b/src/build/needed-tokens/index.ts index 22be685..ee713b5 100644 --- a/src/build/needed-tokens/index.ts +++ b/src/build/needed-tokens/index.ts @@ -23,6 +23,8 @@ const findNeededTokens = (scssDir: string, variablesMap: Record, const usedTokens = Object.keys(variablesMap).filter( (token: string) => usedSassVariables.indexOf(variablesMap[token]) !== -1 ); + return uniq([...usedTokens, ...exposed]); }; + export default findNeededTokens; diff --git a/src/build/tasks/__tests__/__snapshots__/preset.test.ts.snap b/src/build/tasks/__tests__/__snapshots__/preset.test.ts.snap index 5874e3b..1365390 100644 --- a/src/build/tasks/__tests__/__snapshots__/preset.test.ts.snap +++ b/src/build/tasks/__tests__/__snapshots__/preset.test.ts.snap @@ -62,7 +62,7 @@ exports[`renderCJSPreset matches previous snapshot 1`] = ` "module.exports.preset = { "theme": { "id": "root", - "selector": ":root", + "selector": "body", "tokens": { "fontFamilyBase": "\\"Helvetica Neue\\", Arial, sans-serif", "fontFamilyBody": "{fontFamilyBase}", @@ -268,7 +268,7 @@ exports[`renderPreset matches previous snapshot 1`] = ` "export var preset = { "theme": { "id": "root", - "selector": ":root", + "selector": "body", "tokens": { "fontFamilyBase": "\\"Helvetica Neue\\", Arial, sans-serif", "fontFamilyBody": "{fontFamilyBase}", diff --git a/src/shared/declaration/__integ__/browser.test.ts b/src/shared/declaration/__integ__/browser.test.ts index d88ec87..0eda055 100644 --- a/src/shared/declaration/__integ__/browser.test.ts +++ b/src/shared/declaration/__integ__/browser.test.ts @@ -110,7 +110,7 @@ test( ); test( - 'override styles only include overridden properties', + 'override styles include overridden tokens', setupTest(async (page) => { const override: Override = { tokens: { @@ -124,32 +124,22 @@ test( await injectOverride(page, override); const resolution = await page.getCSSPropertyResolution(); - const resolutionContext = await page.getCSSPropertyResolution(contextClass); await page.addClassToRoot(modeClass); const resolutionMode = await page.getCSSPropertyResolution(); + // Without base theme, only explicitly overridden tokens are present expect(resolution).toEqual({ - boxShadow: 'black', - buttonShadow: 'black', - lineShadow: 'black', - medium: '2px', - scaledSize: '2px', - shadow: 'black', - }); - expect(resolutionContext).toEqual({ - boxShadow: 'purple', - buttonShadow: 'black', - lineShadow: 'black', + black: 'black', + brown: 'brown', + grey: 'grey', medium: '2px', - scaledSize: '2px', shadow: 'black', }); expect(resolutionMode).toEqual({ - boxShadow: 'brown', - buttonShadow: 'grey', - lineShadow: 'brown', + black: 'black', + brown: 'brown', + grey: 'grey', medium: '2px', - scaledSize: '2px', shadow: 'grey', }); }) @@ -183,14 +173,22 @@ class DeclarationPage extends BasePageObject { elem.className = className; document.body.appendChild(elem); } + + const computedStyle = getComputedStyle(elem); const result: Record = {}; + // Chrome-only experimental feature // https://caniuse.com/mdn-api_element_computedstylemap for (const [prop, value] of (elem as any).computedStyleMap()) { if (prop.startsWith('--')) { - result[prop.substring(2, prop.length - 4)] = value[0][0].trim(); + const propName = prop.substring(2, prop.length - 4); + // Use a dummy property to force browser to resolve the CSS variable + elem.style.setProperty('--temp-resolve', `var(${prop})`); + const resolvedValue = computedStyle.getPropertyValue('--temp-resolve').trim(); + result[propName] = resolvedValue; } } + elem.style.removeProperty('--temp-resolve'); done(result); }, className @@ -198,14 +196,14 @@ class DeclarationPage extends BasePageObject { } async addClassToRoot(className: string): Promise { await this.browser.executeAsync((className: string, done: () => void) => { - document.documentElement.classList.add(className); + document.body.classList.add(className); done(); }, className); } async removeClassFromRoot(className: string): Promise { await this.browser.executeAsync((className: string, done: () => void) => { - document.documentElement.classList.remove(className); + document.body.classList.remove(className); done(); }, className); } diff --git a/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap b/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap index 7ae7919..474c7c9 100644 --- a/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap +++ b/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap @@ -1,190 +1,169 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`renderDeclarations > does not render unnecessary declarations 1`] = ` -":global :root{ +":global body{ --fontFamilyBase-css:"Helvetica Neue", Arial, sans-serif; }" `; exports[`renderDeclarations > includes secondary theme 1`] = ` -":root{ +"body{ --fontFamilyBase-css:"Helvetica Neue", Arial, sans-serif; - --fontFamilyBody-css:"Helvetica Neue", Arial, sans-serif; + --fontFamilyBody-css:var(--fontFamilyBase-css); --black-css:black; --grey-css:grey; --brown-css:brown; - --shadow-css:grey; - --buttonShadow-css:grey; - --boxShadow-css:grey; - --lineShadow-css:grey; + --shadow-css:var(--grey-css); + --buttonShadow-css:var(--shadow-css); + --boxShadow-css:var(--shadow-css); + --lineShadow-css:var(--buttonShadow-css); --small-css:1px; --medium-css:3px; - --scaledSize-css:3px; + --scaledSize-css:var(--medium-css); --appear-css:20ms; --containerShadowBase-css:2px 3px orange, -1px 0 8px olive; - --modalShadowContainer-css:2px 3px orange, -1px 0 8px olive; + --modalShadowContainer-css:var(--containerShadowBase-css); } .compact{ - --scaledSize-css:1px; + --scaledSize-css:var(--small-css); } @media not print {.dark{ - --shadow-css:black; - --buttonShadow-css:black; - --boxShadow-css:brown; - --lineShadow-css:brown; + --shadow-css:var(--black-css); + --boxShadow-css:var(--brown-css); + --lineShadow-css:var(--boxShadow-css); }} .disabled-motion{ --appear-css:0; } .navigation{ - --shadow-css:black; - --buttonShadow-css:black; + --shadow-css:var(--black-css); --boxShadow-css:purple; - --lineShadow-css:black; + --buttonShadow-css:var(--shadow-css); + --lineShadow-css:var(--buttonShadow-css); } @media not print {.dark .navigation{ - --shadow-css:brown; - --buttonShadow-css:brown; - --lineShadow-css:purple; + --shadow-css:var(--brown-css); + --lineShadow-css:var(--boxShadow-css); }} @media not print {.dark.navigation{ - --shadow-css:brown; - --buttonShadow-css:brown; - --lineShadow-css:purple; + --shadow-css:var(--brown-css); + --lineShadow-css:var(--boxShadow-css); }} .secondary-theme{ --black-css:purple; --brown-css:black; } -@media not print {.dark.secondary-theme{ - --shadow-css:purple; - --buttonShadow-css:purple; - --boxShadow-css:black; - --lineShadow-css:black; -}} .secondary-theme .navigation{ - --shadow-css:purple; - --buttonShadow-css:purple; - --lineShadow-css:purple; + --boxShadow-css:var(--shadow-css); } .navigation.secondary-theme{ - --shadow-css:purple; - --buttonShadow-css:purple; - --lineShadow-css:purple; + --boxShadow-css:var(--shadow-css); } @media not print {.dark.secondary-theme .navigation{ - --shadow-css:grey; - --buttonShadow-css:grey; - --boxShadow-css:black; - --lineShadow-css:black; + --shadow-css:var(--grey-css); + --boxShadow-css:var(--brown-css); }} @media not print {.dark.navigation.secondary-theme{ - --shadow-css:grey; - --buttonShadow-css:grey; - --lineShadow-css:black; + --shadow-css:var(--grey-css); + --boxShadow-css:var(--brown-css); + --lineShadow-css:var(--boxShadow-css); }}" `; exports[`renderDeclarations > renders declarations for theme with :root selector and context 1`] = ` -":global :root{ +":global body{ --fontFamilyBase-css:"Helvetica Neue", Arial, sans-serif; - --fontFamilyBody-css:"Helvetica Neue", Arial, sans-serif; + --fontFamilyBody-css:var(--fontFamilyBase-css); --black-css:black; --grey-css:grey; --brown-css:brown; - --shadow-css:grey; - --buttonShadow-css:grey; - --boxShadow-css:grey; - --lineShadow-css:grey; + --shadow-css:var(--grey-css); + --buttonShadow-css:var(--shadow-css); + --boxShadow-css:var(--shadow-css); + --lineShadow-css:var(--buttonShadow-css); --small-css:1px; --medium-css:3px; - --scaledSize-css:3px; + --scaledSize-css:var(--medium-css); --appear-css:20ms; --containerShadowBase-css:2px 3px orange, -1px 0 8px olive; - --modalShadowContainer-css:2px 3px orange, -1px 0 8px olive; + --modalShadowContainer-css:var(--containerShadowBase-css); } :global .compact{ - --scaledSize-css:1px; + --scaledSize-css:var(--small-css); } @media not print {:global .dark{ - --shadow-css:black; - --buttonShadow-css:black; - --boxShadow-css:brown; - --lineShadow-css:brown; + --shadow-css:var(--black-css); + --boxShadow-css:var(--brown-css); + --lineShadow-css:var(--boxShadow-css); }} :global .disabled-motion{ --appear-css:0; } :global .navigation{ - --shadow-css:black; - --buttonShadow-css:black; + --shadow-css:var(--black-css); --boxShadow-css:purple; - --lineShadow-css:black; + --buttonShadow-css:var(--shadow-css); + --lineShadow-css:var(--buttonShadow-css); } @media not print {:global .dark .navigation{ - --shadow-css:brown; - --buttonShadow-css:brown; - --lineShadow-css:purple; + --shadow-css:var(--brown-css); + --lineShadow-css:var(--boxShadow-css); }} @media not print {:global .dark.navigation{ - --shadow-css:brown; - --buttonShadow-css:brown; - --lineShadow-css:purple; + --shadow-css:var(--brown-css); + --lineShadow-css:var(--boxShadow-css); }}" `; exports[`renderDeclarations > renders declarations for theme with non :root selector 1`] = ` ".secondary-theme{ --fontFamilyBase-css:"Helvetica Neue", Arial, sans-serif; - --fontFamilyBody-css:"Helvetica Neue", Arial, sans-serif; + --fontFamilyBody-css:var(--fontFamilyBase-css); --black-css:purple; --grey-css:grey; --brown-css:black; - --shadow-css:grey; - --buttonShadow-css:grey; - --boxShadow-css:grey; - --lineShadow-css:grey; + --shadow-css:var(--grey-css); + --buttonShadow-css:var(--shadow-css); + --boxShadow-css:var(--shadow-css); + --lineShadow-css:var(--buttonShadow-css); --small-css:1px; --medium-css:3px; - --scaledSize-css:3px; + --scaledSize-css:var(--medium-css); --appear-css:20ms; --containerShadowBase-css:2px 3px orange, -1px 0 8px olive; - --modalShadowContainer-css:2px 3px orange, -1px 0 8px olive; + --modalShadowContainer-css:var(--containerShadowBase-css); } .compact.secondary-theme{ - --scaledSize-css:1px; + --scaledSize-css:var(--small-css); } @media not print {.dark.secondary-theme{ - --shadow-css:purple; - --buttonShadow-css:purple; - --boxShadow-css:black; - --lineShadow-css:black; + --shadow-css:var(--black-css); + --boxShadow-css:var(--brown-css); + --lineShadow-css:var(--boxShadow-css); }} .disabled-motion.secondary-theme{ --appear-css:0; } .secondary-theme .navigation{ - --shadow-css:purple; - --buttonShadow-css:purple; - --boxShadow-css:purple; - --lineShadow-css:purple; + --shadow-css:var(--black-css); + --buttonShadow-css:var(--shadow-css); + --boxShadow-css:var(--shadow-css); + --lineShadow-css:var(--buttonShadow-css); } .navigation.secondary-theme{ - --shadow-css:purple; - --buttonShadow-css:purple; - --boxShadow-css:purple; - --lineShadow-css:purple; + --shadow-css:var(--black-css); + --buttonShadow-css:var(--shadow-css); + --boxShadow-css:var(--shadow-css); + --lineShadow-css:var(--buttonShadow-css); } @media not print {.dark.secondary-theme .navigation{ - --shadow-css:grey; - --buttonShadow-css:grey; - --boxShadow-css:black; - --lineShadow-css:black; + --shadow-css:var(--grey-css); + --boxShadow-css:var(--brown-css); + --lineShadow-css:var(--boxShadow-css); }} @media not print {.dark.navigation.secondary-theme{ - --shadow-css:grey; - --buttonShadow-css:grey; - --boxShadow-css:black; - --lineShadow-css:black; + --shadow-css:var(--grey-css); + --boxShadow-css:var(--brown-css); + --lineShadow-css:var(--boxShadow-css); }}" `; diff --git a/src/shared/declaration/__tests__/transformer.test.ts b/src/shared/declaration/__tests__/transformer.test.ts new file mode 100644 index 0000000..5e041fb --- /dev/null +++ b/src/shared/declaration/__tests__/transformer.test.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, test, expect } from 'vitest'; +import { MinimalTransformer } from '../transformer'; +import Stylesheet, { Rule, Declaration } from '../stylesheet'; + +describe('MinimalTransformer', () => { + test('removes empty global selector rules', () => { + const stylesheet = new Stylesheet(); + const rootRule = new Rule(':root'); + rootRule.appendDeclaration(new Declaration('--color', 'blue')); + stylesheet.appendRuleWithPath(rootRule, []); + + const childRule = new Rule('body'); + childRule.appendDeclaration(new Declaration('--color', 'blue')); + stylesheet.appendRuleWithPath(childRule, [rootRule]); + + const transformer = new MinimalTransformer(); + const result = transformer.transform(stylesheet); + + expect(result.getAllRules().length).toBe(1); + expect(result.findRule(':root')).toBeDefined(); + expect(result.findRule('body')).toBeUndefined(); + }); + + test('keeps non-empty global selector rules', () => { + const stylesheet = new Stylesheet(); + const rootRule = new Rule(':root'); + rootRule.appendDeclaration(new Declaration('--color', 'blue')); + stylesheet.appendRuleWithPath(rootRule, []); + + const childRule = new Rule('html'); + childRule.appendDeclaration(new Declaration('--color', 'red')); + stylesheet.appendRuleWithPath(childRule, [rootRule]); + + const transformer = new MinimalTransformer(); + const result = transformer.transform(stylesheet); + + expect(result.getAllRules().length).toBe(2); + expect(result.findRule('html')?.size()).toBe(1); + }); + + test('removes empty non-global selector rules', () => { + const stylesheet = new Stylesheet(); + const rootRule = new Rule(':root'); + rootRule.appendDeclaration(new Declaration('--color', 'blue')); + stylesheet.appendRuleWithPath(rootRule, []); + + const childRule = new Rule('.child'); + childRule.appendDeclaration(new Declaration('--color', 'blue')); + stylesheet.appendRuleWithPath(childRule, [rootRule]); + + const transformer = new MinimalTransformer(); + const result = transformer.transform(stylesheet); + + expect(result.getAllRules().length).toBe(1); + expect(result.findRule('.child')).toBeUndefined(); + }); +}); diff --git a/src/shared/declaration/index.ts b/src/shared/declaration/index.ts index 2f3e37b..ea35bba 100644 --- a/src/shared/declaration/index.ts +++ b/src/shared/declaration/index.ts @@ -1,27 +1,31 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { mergeInPlace, Override, Theme } from '../theme'; +import { flattenReferenceTokens, collectReferencedTokens } from '../theme/utils'; import type { PropertiesMap, SelectorCustomizer } from './interfaces'; import { RuleCreator } from './rule'; import { SingleThemeCreator } from './single'; import { MultiThemeCreator } from './multi'; import { Selector } from './selector'; -import { AllPropertyRegistry, UsedPropertyRegistry } from './registry'; +import { UsedPropertyRegistry } from './registry'; import { MinimalTransformer } from './transformer'; import { cloneDeep, values } from '../utils'; function createMinimalTheme(base: Theme, override: Override): Theme { const minimalTheme = cloneDeep(base); const contextTokens: Set = new Set(); + 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 override.tokens) && !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]; @@ -31,18 +35,58 @@ function createMinimalTheme(base: Theme, override: Override): Theme { return mergeInPlace(minimalTheme, override); } +function collectAllRequiredTokens( + themes: Theme[], + initialTokens: string[] +): { referenceTokens: string[]; referencedTokens: string[] } { + const referenceTokens: string[] = []; + themes.forEach((theme) => referenceTokens.push(...Object.keys(flattenReferenceTokens(theme)))); + + const alreadyIncluded = new Set([...initialTokens, ...referenceTokens]); + const referencedTokens: string[] = []; + + themes.forEach((theme) => { + const referenced = collectReferencedTokens(theme, [...initialTokens, ...referenceTokens]); + referenced.forEach((token) => { + if (!alreadyIncluded.has(token)) { + referencedTokens.push(token); + alreadyIncluded.add(token); + } + }); + }); + + return { referenceTokens, referencedTokens }; +} + +function addMissingTokensToTheme(theme: Theme, tokens: string[], sourceTheme: Theme): void { + tokens.forEach((token) => { + if (!(token in theme.tokens) && token in sourceTheme.tokens) { + theme.tokens[token] = sourceTheme.tokens[token]; + } + }); +} + export function createOverrideDeclarations( base: Theme, override: Override, propertiesMap: PropertiesMap, selectorCustomizer: SelectorCustomizer ): 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 stylesheet = stylesheetCreator.create(); - return stylesheet.toString(); + const initialTokens = Object.keys(minimalTheme.tokens); + + const { referencedTokens } = collectAllRequiredTokens([base], initialTokens); + // Add referenced tokens to minimalTheme so they get output in root + // The transformer will remove them from mode/context selectors since they're unchanged + addMissingTokensToTheme(minimalTheme, referencedTokens, base); + + const usedTokens = [...initialTokens, ...referencedTokens]; + const ruleCreator = new RuleCreator( + new Selector(selectorCustomizer), + new UsedPropertyRegistry(propertiesMap, usedTokens) + ); + const stylesheet = new SingleThemeCreator(minimalTheme, ruleCreator, base, propertiesMap).create(); + return new MinimalTransformer().transform(stylesheet).toString(); } export function createBuildDeclarations( @@ -52,10 +96,14 @@ export function createBuildDeclarations( selectorCustomizer: SelectorCustomizer, used: string[] ): string { - const ruleCreator = new RuleCreator(new Selector(selectorCustomizer), new UsedPropertyRegistry(propertiesMap, used)); - const stylesheetCreator = new MultiThemeCreator([primary, ...secondary], ruleCreator); - const stylesheet = stylesheetCreator.create(); - const transformer = new MinimalTransformer(); - const minimal = transformer.transform(stylesheet); - return minimal.toString(); + const themes = [primary, ...secondary]; + const { referenceTokens, referencedTokens } = collectAllRequiredTokens(themes, used); + const usedTokens = [...used, ...referenceTokens, ...referencedTokens]; + + const ruleCreator = new RuleCreator( + new Selector(selectorCustomizer), + new UsedPropertyRegistry(propertiesMap, usedTokens) + ); + const stylesheet = new MultiThemeCreator(themes, ruleCreator, propertiesMap).create(); + return new MinimalTransformer().transform(stylesheet).toString(); } diff --git a/src/shared/declaration/multi.ts b/src/shared/declaration/multi.ts index 562bcf3..fae005a 100644 --- a/src/shared/declaration/multi.ts +++ b/src/shared/declaration/multi.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { isGlobalSelector } from '../styles/selector'; import { defaultsReducer, modeReducer, OptionalState, reduce, resolveContext, resolveTheme, Theme } from '../theme'; +import type { PropertiesMap } from './interfaces'; import { AbstractCreator } from './abstract'; import type { StylesheetCreator } from './interfaces'; import { RuleCreator, SelectorConfig } from './rule'; @@ -16,11 +17,13 @@ import { compact } from './utils'; export class MultiThemeCreator extends AbstractCreator implements StylesheetCreator { themes: Theme[]; ruleCreator: RuleCreator; + propertiesMap?: PropertiesMap; - constructor(themes: Theme[], ruleCreator: RuleCreator) { + constructor(themes: Theme[], ruleCreator: RuleCreator, propertiesMap?: PropertiesMap) { super(); this.themes = themes; this.ruleCreator = ruleCreator; + this.propertiesMap = propertiesMap; } create(): Stylesheet { @@ -38,7 +41,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.propertiesMap).create() + ); const result = new Stylesheet(); stylesheets.forEach((stylesheet) => { stylesheet.getAllRules().map((rule) => result.appendRuleWithPath(rule, stylesheet.getPath(rule) ?? [])); @@ -48,7 +53,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.propertiesMap).create(); const secondaries = this.getThemesWithout(globalTheme); secondaries.forEach((secondary) => { this.appendRulesForSecondary(stylesheet, globalTheme, secondary); @@ -58,8 +63,9 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea } appendRulesForSecondary(stylesheet: Stylesheet, primary: Theme, secondary: Theme) { - const secondaryResolution = resolveTheme(secondary); + const secondaryResolution = resolveTheme(secondary, undefined, this.propertiesMap); const defaults = reduce(secondaryResolution, secondary, defaultsReducer()); + 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 +86,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.propertiesMap), + secondary, + defaultsReducer() + ); const contextRule = this.ruleCreator.create( { global: [secondary.selector], local: [context.selector] }, contextResolution @@ -110,7 +120,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.propertiesMap), + 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..dc2c279 100644 --- a/src/shared/declaration/single.ts +++ b/src/shared/declaration/single.ts @@ -10,6 +10,7 @@ import { resolveTheme, Theme, } from '../theme'; +import type { PropertiesMap } from './interfaces'; import Stylesheet from './stylesheet'; import { AbstractCreator } from './abstract'; import type { StylesheetCreator } from './interfaces'; @@ -21,12 +22,14 @@ export class SingleThemeCreator extends AbstractCreator implements StylesheetCre baseTheme?: Theme; resolution: FullResolution; ruleCreator: RuleCreator; + propertiesMap?: PropertiesMap; - constructor(theme: Theme, ruleCreator: RuleCreator, baseTheme?: Theme) { + constructor(theme: Theme, ruleCreator: RuleCreator, baseTheme?: Theme, propertiesMap?: PropertiesMap) { super(); this.theme = theme; this.baseTheme = baseTheme; - this.resolution = resolveTheme(theme, this.baseTheme); + this.propertiesMap = propertiesMap; + this.resolution = resolveTheme(theme, this.baseTheme, propertiesMap); this.ruleCreator = ruleCreator; } @@ -34,6 +37,7 @@ export class SingleThemeCreator extends AbstractCreator implements StylesheetCre 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.propertiesMap), 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.propertiesMap), this.theme, modeReducer(mode, state), this.baseTheme diff --git a/src/shared/declaration/stylesheet.ts b/src/shared/declaration/stylesheet.ts index 0b1d388..924cdad 100644 --- a/src/shared/declaration/stylesheet.ts +++ b/src/shared/declaration/stylesheet.ts @@ -89,6 +89,10 @@ export class Rule { } return rule; } + + isModeRule(): boolean { + return !!this.media; + } } export class Declaration { diff --git a/src/shared/declaration/transformer.ts b/src/shared/declaration/transformer.ts index 7a3cc73..51a9897 100644 --- a/src/shared/declaration/transformer.ts +++ b/src/shared/declaration/transformer.ts @@ -3,6 +3,8 @@ import { entries } from '../utils'; import type Stylesheet from './stylesheet'; import { Declaration } from './stylesheet'; +import { getFirstSelector, isGlobalSelector } from '../styles/selector'; +import { getReferencedVar } from './utils'; export interface Transformer { transform(stylesheet: Stylesheet): Stylesheet; @@ -36,6 +38,54 @@ export class MinimalTransformer implements Transformer { return acc; }, {}); const diff = difference(resolvedParent, ruleValue); + + // CSS variables with nested var() references need special handling for non-global selectors. + // Even if the selector doesn't explicitly show a descendant combinator (like `.navigation`), + // it will be a descendant of `body` in the DOM. When a descendant overrides a token, + // tokens that reference it must be re-output, otherwise they resolve in the parent context. + // + // However, for mode rules (which have media queries), we should skip tokens that have + // identical values to their parent, even if referenced variables are overridden. These can inherit + // properly via natural css variable cascade rules. + + const firstSelector = getFirstSelector(rule.selector); + const isModeRule = rule.isModeRule(); + + if (isGlobalSelector(firstSelector)) { + rule.clear(); + entries(diff).forEach(([property, value]) => rule.appendDeclaration(new Declaration(property, value))); + if (rule.size() === 0) { + stylesheet.removeRule(rule); + } + return; + } + + const isOverridden = (varName: string, visited = new Set()): boolean => { + if (visited.has(varName)) return false; + visited.add(varName); + + const isDirectlyOverridden = + varName in ruleValue && varName in resolvedParent && ruleValue[varName] !== resolvedParent[varName]; + + if (isDirectlyOverridden) return true; + + const referencedVar = varName in ruleValue ? getReferencedVar(ruleValue[varName]) : null; + return referencedVar ? isOverridden(referencedVar, visited) : false; + }; + + Object.keys(ruleValue).forEach((property) => { + const referencedVar = getReferencedVar(ruleValue[property]); + if (!referencedVar || !isOverridden(referencedVar)) return; + // For mode rules, only output if value actually differs from resolved parent + // For context rules, always output to ensure correct resolution + const canInherit = isModeRule && ruleValue[property] === resolvedParent[property]; + if (canInherit) return; + + if (!(property in diff)) { + diff[property] = ruleValue[property]; + } + }); + rule.clear(); entries(diff).forEach(([property, value]) => rule.appendDeclaration(new Declaration(property, value))); if (rule.size() === 0) { diff --git a/src/shared/declaration/utils.ts b/src/shared/declaration/utils.ts index 15bb45b..df91f27 100644 --- a/src/shared/declaration/utils.ts +++ b/src/shared/declaration/utils.ts @@ -9,3 +9,13 @@ export function compact(arr: (T | undefined)[]): T[] { } return result; } + +/** + * Extracts the CSS variable name from a var() reference. + * @param value - Token value that may contain a var() reference + * @returns The variable name (e.g., '--color-primary') or null if not a var() reference + */ +export function getReferencedVar(value: string): string | null { + const match = value.match(/var\((--[^)]+)\)/); + return match ? match[1] : null; +} diff --git a/src/shared/styles/selector.ts b/src/shared/styles/selector.ts index 60462a5..83d4528 100644 --- a/src/shared/styles/selector.ts +++ b/src/shared/styles/selector.ts @@ -18,6 +18,10 @@ export const globalSelectors = [':root', 'body', 'html']; export const isGlobalSelector: (selector: string) => boolean = (selector: string) => globalSelectors.indexOf(selector) > -1; +export function getFirstSelector(selector: string): string { + return selector.split(/[\s.:[\]]/)[0]; +} + /** * Detects and repeats class names to increase specificity, otherwise * fall back to increase by id diff --git a/src/shared/theme/__tests__/__snapshots__/merge.test.ts.snap b/src/shared/theme/__tests__/__snapshots__/merge.test.ts.snap index 6699681..7550aca 100644 --- a/src/shared/theme/__tests__/__snapshots__/merge.test.ts.snap +++ b/src/shared/theme/__tests__/__snapshots__/merge.test.ts.snap @@ -59,7 +59,7 @@ exports[`merge > merges theme with override and matches previous snapshot 1`] = }, }, }, - "selector": ":root", + "selector": "body", "tokenModeMap": { "appear": "motion", "boxShadow": "color", diff --git a/src/shared/theme/__tests__/builder.test.ts b/src/shared/theme/__tests__/builder.test.ts index 116e222..7d7fdc7 100644 --- a/src/shared/theme/__tests__/builder.test.ts +++ b/src/shared/theme/__tests__/builder.test.ts @@ -78,3 +78,19 @@ test('theme adds reference tokens', () => { colorWarning400: '#ff9900', }); }); + +test('theme adds reference tokens with mode', () => { + builder.addReferenceTokens( + { + color: { + primary: { 500: { default: '#0073bb', optional: '#66b3ff' } }, + }, + }, + mode + ); + + const theme = builder.build(); + + expect(theme.tokens.colorPrimary500).toEqual({ default: '#0073bb', optional: '#66b3ff' }); + expect(theme.tokenModeMap.colorPrimary500).toBe('mode'); +}); diff --git a/src/shared/theme/__tests__/process.test.ts b/src/shared/theme/__tests__/process.test.ts new file mode 100644 index 0000000..0134bc9 --- /dev/null +++ b/src/shared/theme/__tests__/process.test.ts @@ -0,0 +1,203 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, test, expect, vi } from 'vitest'; +import { processReferenceTokens, processColorPaletteInput } from '../process'; +import { ReferenceTokens } from '../interfaces'; + +// Mock the color generation utilities +vi.mock('../color-generation/hct-utils', () => ({ + argbFromHex: vi.fn((hex: string) => parseInt(hex.replace('#', ''), 16)), + hexFromArgb: vi.fn((argb: number) => `#${(argb & 0xffffff).toString(16).padStart(6, '0')}`), + hexToHct: vi.fn((hex: string) => ({ hue: 180, chroma: 50, tone: 50 })), + hctToHex: vi.fn(() => '#008080'), + createHct: vi.fn((hue: number, chroma: number, tone: number) => ({ hue, chroma, tone })), + Hct: class Hct { + constructor(public hue: number, public chroma: number, public tone: number) {} + static from(hue: number, chroma: number, tone: number) { + return new Hct(hue, chroma, tone); + } + }, +})); + +describe('processReferenceTokens', () => { + test('processes object-based color reference tokens', () => { + const colorTokens: ReferenceTokens['color'] = { + primary: { + 50: '#e6f3ff', + 500: '#0073bb', + 600: '#0066aa', + }, + neutral: { + 900: '#242E3C', + }, + }; + + const result = processReferenceTokens(colorTokens); + + expect(result).toEqual({ + colorPrimary50: '#e6f3ff', + colorPrimary500: '#0073bb', + colorPrimary600: '#0066aa', + colorNeutral900: '#242E3C', + }); + }); + + test('processes all color categories', () => { + const colorTokens: ReferenceTokens['color'] = { + primary: { 500: '#0073bb' }, + neutral: { 500: '#888888' }, + error: { 400: '#f44336' }, + success: { 500: '#4caf50' }, + warning: { 400: '#ff9900' }, + info: { 400: '#2196f3' }, + }; + + const result = processReferenceTokens(colorTokens); + + expect(result).toEqual({ + colorPrimary500: '#0073bb', + colorNeutral500: '#888888', + colorError400: '#f44336', + colorSuccess500: '#4caf50', + colorWarning400: '#ff9900', + colorInfo400: '#2196f3', + }); + expect(Object.keys(result)).not.toContain('colorPrimary50'); + expect(Object.keys(result)).not.toContain('colorNeutral50'); + }); + + test('processes seed-based color reference tokens', () => { + const colorTokens: ReferenceTokens['color'] = { + primary: '#0073bb', + neutral: '#888888', + }; + + const result = processReferenceTokens(colorTokens); + + // Should generate full palettes from seeds + expect(Object.keys(result)).toContain('colorPrimary50'); + expect(Object.keys(result)).toContain('colorPrimary500'); + expect(Object.keys(result)).toContain('colorPrimary1000'); + expect(Object.keys(result)).toContain('colorNeutral50'); + expect(Object.keys(result)).toContain('colorNeutral500'); + expect(Object.keys(result)).toContain('colorNeutral1000'); + }); + + test('processes mixed seed and object-based tokens', () => { + const colorTokens: ReferenceTokens['color'] = { + primary: '#0073bb', // seed + neutral: { + // object + 500: '#888888', + 900: '#242E3C', + }, + }; + + const result = processReferenceTokens(colorTokens); + + // Seed should generate full palette + expect(Object.keys(result).filter((k) => k.startsWith('colorPrimary'))).toHaveLength(11); + + // Object should only have specified steps + expect(result.colorNeutral500).toBe('#888888'); + expect(result.colorNeutral900).toBe('#242E3C'); + expect(result.colorNeutral50).toBeUndefined(); + }); +}); + +describe('processColorPaletteInput', () => { + test('processes object input with explicit values', () => { + const input = { + 50: '#e6f3ff', + 500: '#0073bb', + 600: '#0066aa', + }; + + const result = processColorPaletteInput('primary', input); + + expect(result).toEqual({ + 50: '#e6f3ff', + 500: '#0073bb', + 600: '#0066aa', + }); + }); + + test('processes object input with seed and explicit values', () => { + const input = { + seed: '#0073bb', + 500: '#custom500', + 600: '#custom600', + }; + + const result = processColorPaletteInput('primary', input); + + // Explicit values should take precedence over generated + expect(result[500]).toBe('#custom500'); + expect(result[600]).toBe('#custom600'); + + // Generated values should fill in missing steps + expect(result[50]).toBeDefined(); + expect(result[100]).toBeDefined(); + }); + + test('processes string input as seed', () => { + const result = processColorPaletteInput('primary', '#0073bb'); + + // Should generate full palette + expect(result[50]).toBeDefined(); + expect(result[100]).toBeDefined(); + expect(result[500]).toBeDefined(); + expect(result[1000]).toBeDefined(); + expect(Object.keys(result)).toHaveLength(12); + }); + + test('filters invalid palette steps', () => { + const input = { + 25: '#invalid', // Invalid step + 50: '#valid50', + 500: '#valid500', + 1050: '#invalid', // Invalid step + }; + + const result = processColorPaletteInput('primary', input); + + // expect(result[25]).toBeUndefined(); + // expect(result[1050]).toBeUndefined(); + expect(result[50]).toBe('#valid50'); + expect(result[500]).toBe('#valid500'); + }); + + test('handles empty object input', () => { + const result = processColorPaletteInput('primary', {}); + + expect(result).toEqual({}); + }); + + test('processes mode-based seed input', () => { + const input = { + seed: { + light: '#0073bb', + dark: '#66b3ff', + }, + }; + + const result = processColorPaletteInput('primary', input); + + // Should generate palette with mode values + expect(result[500]).toEqual({ light: '#008080', dark: '#008080' }); + }); + + test('skips non-string seed values in mode objects', () => { + const input = { + seed: { + light: '#0073bb', + dark: null as any, + }, + }; + + const result = processColorPaletteInput('primary', input); + + // Should only process light mode + expect(result[500]).toEqual({ light: '#008080' }); + }); +}); diff --git a/src/shared/theme/__tests__/resolve.test.ts b/src/shared/theme/__tests__/resolve.test.ts index 7b6e4a8..3a14286 100644 --- a/src/shared/theme/__tests__/resolve.test.ts +++ b/src/shared/theme/__tests__/resolve.test.ts @@ -8,8 +8,10 @@ import { themesWithCircularDependencies, themeWithNonExistingToken, themeWithTokenWithoutModeResolution, + colorMode, } from '../../../__fixtures__/common'; -import { resolveTheme, resolveThemeWithPaths } from '../resolve'; +import { resolveTheme, resolveThemeWithPaths, resolveContext } from '../resolve'; +import { Theme, Context } from '../interfaces'; describe('resolve', () => { test('resolves theme to full resolution', () => { @@ -43,3 +45,100 @@ describe('resolve', () => { ); }); }); + +describe('resolveContext', () => { + test('resolves context without defaultMode', () => { + const theme: Theme = { + id: 'test', + selector: 'body', + tokens: { color: 'blue' }, + tokenModeMap: {}, + contexts: {}, + modes: {}, + }; + + const context: Context = { + id: 'ctx', + selector: '.ctx', + tokens: { color: 'red' }, + }; + + const result = resolveContext(theme, context); + expect(result.color).toBe('red'); + }); + + test('resolves context with defaultMode', () => { + const theme: Theme = { + id: 'test', + selector: 'body', + tokens: { color: { light: 'purple', dark: 'blue' } }, + tokenModeMap: { color: 'color' }, + contexts: {}, + modes: { color: colorMode }, + }; + + const context: Context = { + id: 'ctx', + selector: '.ctx', + tokens: {}, + defaultMode: 'dark', + }; + + const result = resolveContext(theme, context); + expect(result.color).toEqual({ light: 'purple', dark: 'blue' }); + }); + + test('resolves context with defaultMode but mode not found', () => { + const theme: Theme = { + id: 'test', + selector: ':root', + tokens: { color: 'blue' }, + tokenModeMap: {}, + contexts: {}, + modes: { color: colorMode }, + }; + + const context: Context = { + id: 'ctx', + selector: '.ctx', + tokens: {}, + defaultMode: 'nonexistent', + }; + + const result = resolveContext(theme, context); + expect(result.color).toBe('blue'); + }); + + test('collects reference tokens when resolving context with defaultMode', () => { + const theme: Theme = { + id: 'test', + selector: ':root', + tokens: { + colorPrimary500: { light: '#0073bb', dark: '#66b3ff' }, + colorNeutral500: '#888888', + buttonBackground: '{colorPrimary500}', + }, + tokenModeMap: { colorPrimary500: 'color' }, + referenceTokens: { + color: { + primary: { 500: { light: '#0073bb', dark: '#66b3ff' } }, + neutral: { 500: '#888888' }, + }, + }, + contexts: {}, + modes: { color: colorMode }, + }; + + const context: Context = { + id: 'ctx', + selector: '.ctx', + tokens: { buttonBackground: '{colorNeutral500}' }, + defaultMode: 'light', + }; + + resolveContext(theme, context); + + expect(context.tokens.colorPrimary500).toBe('#0073bb'); + expect(context.tokens.buttonBackground).toBe('{colorNeutral500}'); + }); +}); diff --git a/src/shared/theme/__tests__/utils.test.ts b/src/shared/theme/__tests__/utils.test.ts new file mode 100644 index 0000000..d6b4147 --- /dev/null +++ b/src/shared/theme/__tests__/utils.test.ts @@ -0,0 +1,219 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, test, expect } from 'vitest'; +import { isModeValue, isValue, isReference, generateCamelCaseName, flattenObject, getReference } from '../utils'; + +describe('theme utils', () => { + describe('isModeValue', () => { + test('returns true for valid mode value objects', () => { + expect(isModeValue({ light: '#ffffff', dark: '#000000' })).toBe(true); + expect(isModeValue({ default: '{colorPrimary500}' })).toBe(true); + expect(isModeValue({ light: '#fff', dark: '{colorNeutral900}' })).toBe(true); + }); + + test('returns false for palette objects with numeric keys', () => { + expect(isModeValue({ 50: '#e6f3ff', 500: '#0073bb' })).toBe(false); + expect(isModeValue({ 100: '#ffffff', 900: '#000000' })).toBe(false); + expect(isModeValue({ '50': '#e6f3ff', '500': '#0073bb' })).toBe(false); + }); + + test('returns false for mixed numeric and non-numeric keys', () => { + expect(isModeValue({ 50: '#e6f3ff', light: '#ffffff' })).toBe(false); + expect(isModeValue({ seed: '#0073bb', 500: '#custom' })).toBe(false); + }); + + test('returns false for non-object values', () => { + expect(isModeValue('#ffffff')).toBe(false); + expect(isModeValue('{colorPrimary500}')).toBe(false); + expect(isModeValue(null)).toBe(false); + expect(isModeValue(undefined)).toBe(false); + expect(isModeValue(123)).toBe(false); + expect(isModeValue([])).toBe(false); + }); + + test('returns false for objects with invalid values', () => { + expect(isModeValue({ light: 123 })).toBe(false); + expect(isModeValue({ light: null })).toBe(false); + expect(isModeValue({ light: {} })).toBe(false); + expect(isModeValue({ light: [] })).toBe(false); + }); + + test('returns true for empty object', () => { + expect(isModeValue({})).toBe(true); + }); + + test('handles palette objects with seed property', () => { + // Seed is not a numeric key, but if mixed with numeric keys, should be false + expect(isModeValue({ seed: '#0073bb' })).toBe(true); + expect(isModeValue({ seed: '#0073bb', 500: '#custom' })).toBe(false); + }); + }); + + describe('isValue', () => { + test('returns true for hex color values', () => { + expect(isValue('#ffffff')).toBe(true); + expect(isValue('#0073bb')).toBe(true); + }); + + test('returns true for other string values', () => { + expect(isValue('10px')).toBe(true); + expect(isValue('bold')).toBe(true); + }); + + test('returns false for references', () => { + expect(isValue('{colorPrimary500}')).toBe(false); + expect(isValue('{token}')).toBe(false); + }); + + test('returns false for non-string values', () => { + expect(isValue(123)).toBe(false); + expect(isValue(null)).toBe(false); + expect(isValue(undefined)).toBe(false); + expect(isValue({})).toBe(false); + }); + }); + + describe('isReference', () => { + test('returns true for valid token references', () => { + expect(isReference('{colorPrimary500}')).toBe(true); + expect(isReference('{token}')).toBe(true); + expect(isReference('{a}')).toBe(true); + }); + + test('returns false for values without braces', () => { + expect(isReference('colorPrimary500')).toBe(false); + expect(isReference('#ffffff')).toBe(false); + }); + + test('returns false for incomplete braces', () => { + expect(isReference('{colorPrimary500')).toBe(false); + expect(isReference('colorPrimary500}')).toBe(false); + }); + + test('returns false for non-string values', () => { + expect(isReference(123)).toBe(false); + expect(isReference(null)).toBe(false); + expect(isReference(undefined)).toBe(false); + expect(isReference({})).toBe(false); + }); + }); + + describe('getReference', () => { + test('extracts token name from reference', () => { + expect(getReference('{colorPrimary500}')).toBe('colorPrimary500'); + expect(getReference('{token}')).toBe('token'); + expect(getReference('{a}')).toBe('a'); + }); + + test('handles nested braces', () => { + expect(getReference('{token{nested}}')).toBe('token{nested}'); + }); + }); + + describe('generateCamelCaseName', () => { + test('generates camelCase from segments', () => { + expect(generateCamelCaseName('color', 'primary', '500')).toBe('colorPrimary500'); + expect(generateCamelCaseName('font', 'size', 'large')).toBe('fontSizeLarge'); + }); + + test('handles single segment', () => { + expect(generateCamelCaseName('color')).toBe('color'); + }); + + test('handles numeric segments', () => { + expect(generateCamelCaseName('color', 'primary', '50')).toBe('colorPrimary50'); + }); + + test('preserves first segment casing', () => { + expect(generateCamelCaseName('Color', 'primary', '500')).toBe('ColorPrimary500'); + }); + }); + + describe('flattenObject', () => { + test('flattens nested objects to camelCase keys', () => { + const input = { + color: { + primary: { + 50: '#e6f3ff', + 500: '#0073bb', + }, + }, + }; + + const result = flattenObject(input); + + expect(result).toEqual({ + colorPrimary50: '#e6f3ff', + colorPrimary500: '#0073bb', + }); + }); + + test('preserves mode values without flattening', () => { + const input = { + color: { + background: { + light: '#ffffff', + dark: '#000000', + }, + }, + }; + + const result = flattenObject(input); + + expect(result).toEqual({ + colorBackground: { light: '#ffffff', dark: '#000000' }, + }); + }); + + test('handles mixed palette and mode values', () => { + const input = { + color: { + primary: { + 50: '#e6f3ff', + 500: '#0073bb', + }, + background: { + light: '#ffffff', + dark: '#000000', + }, + }, + }; + + const result = flattenObject(input); + + expect(result).toEqual({ + colorPrimary50: '#e6f3ff', + colorPrimary500: '#0073bb', + colorBackground: { light: '#ffffff', dark: '#000000' }, + }); + }); + + test('handles empty object', () => { + expect(flattenObject({})).toEqual({}); + }); + + test('handles null and undefined', () => { + expect(flattenObject(null)).toEqual({}); + expect(flattenObject(undefined)).toEqual({}); + }); + + test('handles deeply nested structures', () => { + const input = { + a: { + b: { + c: { + d: 'value', + }, + }, + }, + }; + + const result = flattenObject(input); + + // Stops at objects that look like mode values (no numeric keys) + expect(result).toEqual({ + aBC: { d: 'value' }, + }); + }); + }); +}); diff --git a/src/shared/theme/builder.ts b/src/shared/theme/builder.ts index 4baccfb..b6d5574 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; @@ -30,6 +22,18 @@ export class ThemeBuilder { }; } + private addTokensToModeMap(tokens: Record, mode: Mode): void { + const modeMap = Object.keys(tokens).reduce((acc, token) => { + acc[token] = mode.id; + return acc; + }, {} as Record); + + this.theme.tokenModeMap = { + ...this.theme.tokenModeMap, + ...modeMap, + }; + } + addTokens(tokens: TokenCategory, mode?: Mode): ThemeBuilder { this.theme.tokens = { ...this.theme.tokens, @@ -37,14 +41,7 @@ export class ThemeBuilder { }; if (mode) { - const modes = Object.keys(tokens).reduce((acc, token) => { - acc[token] = mode.id; - return acc; - }, {} as Record); - this.theme.tokenModeMap = { - ...this.theme.tokenModeMap, - ...modes, - }; + this.addTokensToModeMap(tokens, mode); } return this; @@ -55,13 +52,16 @@ export class ThemeBuilder { return this; } - addReferenceTokens(referenceTokens: ReferenceTokens): ThemeBuilder { + addReferenceTokens(referenceTokens: ReferenceTokens, mode?: Mode): 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); + this.theme.tokens = { ...generatedTokens, ...this.theme.tokens }; + + if (mode) { + this.addTokensToModeMap(generatedTokens, mode); + } } return this; @@ -70,45 +70,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/color-generation/__tests__/hct-utils.test.ts b/src/shared/theme/color-generation/__tests__/hct-utils.test.ts new file mode 100644 index 0000000..e38b777 --- /dev/null +++ b/src/shared/theme/color-generation/__tests__/hct-utils.test.ts @@ -0,0 +1,120 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, test, expect } from 'vitest'; +import { hctToHex, hexToHct, createHct } from '../hct-utils'; + +describe('hct-utils', () => { + describe('hexToHct', () => { + test('converts hex to HCT color space', () => { + const hct = hexToHct('#0073bb'); + + expect(hct.hue).toBeGreaterThanOrEqual(0); + expect(hct.hue).toBeLessThanOrEqual(360); + expect(hct.chroma).toBeGreaterThanOrEqual(0); + expect(hct.tone).toBeGreaterThanOrEqual(0); + expect(hct.tone).toBeLessThanOrEqual(100); + }); + + test('handles white color', () => { + const hct = hexToHct('#ffffff'); + + expect(hct.tone).toBeCloseTo(100, 0); + expect(hct.chroma).toBeLessThan(5); // White has very low chroma + }); + + test('parses rgb() format', () => { + const hct = hexToHct('rgb(0, 115, 187)'); + + expect(hct.hue).toBeGreaterThanOrEqual(0); + expect(hct.chroma).toBeGreaterThan(0); + expect(hct.tone).toBeGreaterThanOrEqual(0); + }); + + test('parses rgba() format', () => { + const hct = hexToHct('rgba(255, 0, 0, 1)'); + + expect(hct.hue).toBeGreaterThanOrEqual(0); + expect(hct.chroma).toBeGreaterThan(0); + }); + + test('throws error for unsupported format', () => { + expect(() => hexToHct('invalid')).toThrow('Unsupported color format'); + expect(() => hexToHct('hsl(200, 50%, 50%)')).toThrow('Unsupported color format'); + }); + + test('throws error for invalid hex', () => { + expect(() => hexToHct('#00')).toThrow('Invalid hex color'); + expect(() => hexToHct('#gg0000')).toThrow('Invalid hex color'); + }); + + test('handles black color', () => { + const hct = hexToHct('#000000'); + + expect(hct.tone).toBeCloseTo(0, 0); + expect(hct.chroma).toBeLessThan(5); // Black has very low chroma + }); + + test('handles gray colors with low chroma', () => { + const hct = hexToHct('#888888'); + + expect(hct.chroma).toBeLessThan(5); + }); + }); + + describe('hctToHex', () => { + test('converts HCT to hex color', () => { + const hct = createHct(200, 50, 50); + const hex = hctToHex(hct); + + expect(hex).toMatch(/^#[0-9a-f]{6}$/i); + }); + + test('round-trip conversion preserves color', () => { + const original = '#0073bb'; + const hct = hexToHct(original); + const converted = hctToHex(hct); + + // Colors should be very close (allowing for rounding) + const originalHct = hexToHct(original); + const convertedHct = hexToHct(converted); + + expect(Math.abs(originalHct.hue - convertedHct.hue)).toBeLessThan(1); + expect(Math.abs(originalHct.chroma - convertedHct.chroma)).toBeLessThan(1); + expect(Math.abs(originalHct.tone - convertedHct.tone)).toBeLessThan(1); + }); + }); + + describe('createHct', () => { + test('creates HCT color with valid parameters', () => { + const hct = createHct(200, 50, 50); + + expect(hct.hue).toBeCloseTo(200, 0); + // HCT adjusts chroma to displayable gamut, so it may be less than requested + expect(hct.chroma).toBeGreaterThan(0); + expect(hct.chroma).toBeLessThanOrEqual(50); + expect(hct.tone).toBeCloseTo(50, 0); + }); + + test('handles edge case hue values', () => { + const hct0 = createHct(0, 50, 50); + const hct360 = createHct(360, 50, 50); + + expect(hct0.hue).toBeCloseTo(0, 0); + expect(hct360.hue).toBeCloseTo(0, 0); // 360 wraps to 0 + }); + + test('handles edge case tone values', () => { + const hctMin = createHct(200, 50, 0); + const hctMax = createHct(200, 50, 100); + + expect(hctMin.tone).toBeCloseTo(0, 0); + expect(hctMax.tone).toBeCloseTo(100, 0); + }); + + test('handles zero chroma (grayscale)', () => { + const hct = createHct(200, 0, 50); + + expect(hct.chroma).toBeLessThan(5); // Very low chroma for grayscale + }); + }); +}); diff --git a/src/shared/theme/color-generation/__tests__/palette-generator.test.ts b/src/shared/theme/color-generation/__tests__/palette-generator.test.ts new file mode 100644 index 0000000..0751f4b --- /dev/null +++ b/src/shared/theme/color-generation/__tests__/palette-generator.test.ts @@ -0,0 +1,226 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, test, expect } from 'vitest'; +import { generatePaletteFromSeed, clearPaletteCache } from '../palette-generator'; +import { hexToHct } from '../hct-utils'; +import { PaletteStep, ReferencePaletteDefinition } from '../../interfaces'; + +// Helper to get color as string from palette +function getColor(palette: ReferencePaletteDefinition, step: PaletteStep): string { + const color = palette[step]; + if (typeof color !== 'string') { + throw new Error(`Expected string color at step ${step}, got ${typeof color}`); + } + return color; +} + +describe('palette-generator', () => { + describe('generatePaletteFromSeed', () => { + test('generates full palette from primary seed', () => { + const palette = generatePaletteFromSeed('primary', '#0073bb'); + + expect(palette[50]).toBeDefined(); + expect(palette[100]).toBeDefined(); + expect(palette[200]).toBeDefined(); + expect(palette[300]).toBeDefined(); + expect(palette[400]).toBeDefined(); + expect(palette[500]).toBeDefined(); + expect(palette[600]).toBeDefined(); + expect(palette[700]).toBeDefined(); + expect(palette[800]).toBeDefined(); + expect(palette[900]).toBeDefined(); + expect(palette[1000]).toBeDefined(); + expect(palette.seed).toBe('#0073bb'); + }); + + test('generates palette from RGB format', () => { + const palette = generatePaletteFromSeed('primary', 'rgb(0, 115, 187)'); + + expect(palette[50]).toBeDefined(); + expect(palette[500]).toBeDefined(); + expect(palette[1000]).toBeDefined(); + // Seed is normalized to hex + expect(palette.seed).toBe('#0073bb'); + }); + + test('generates full palette from neutral seed', () => { + const palette = generatePaletteFromSeed('neutral', '#888888'); + + // Neutral palette has 20 steps (50-1000 in increments of 50) + expect(Object.keys(palette).filter((k) => k !== 'seed').length).toBeGreaterThan(11); + expect(palette.seed).toBe('#888888'); + }); + + test('generates full palette from warning seed', () => { + const palette = generatePaletteFromSeed('warning', '#ff9900'); + + expect(Object.keys(palette).filter((k) => k !== 'seed')).toHaveLength(11); + expect(palette.seed).toBe('#ff9900'); + }); + + test('generates full palette from error seed', () => { + const palette = generatePaletteFromSeed('error', '#d91515'); + + expect(Object.keys(palette).filter((k) => k !== 'seed')).toHaveLength(11); + expect(palette.seed).toBe('#d91515'); + }); + + test('generates full palette from success seed', () => { + const palette = generatePaletteFromSeed('success', '#037f0c'); + + expect(Object.keys(palette).filter((k) => k !== 'seed')).toHaveLength(11); + expect(palette.seed).toBe('#037f0c'); + }); + + test('generates full palette from info seed', () => { + const palette = generatePaletteFromSeed('info', '#0972d3'); + + expect(Object.keys(palette).filter((k) => k !== 'seed')).toHaveLength(11); + expect(palette.seed).toBe('#0972d3'); + }); + + test('all generated colors are valid hex', () => { + const palette = generatePaletteFromSeed('primary', '#0073bb'); + + Object.entries(palette).forEach(([key, value]) => { + if (key !== 'seed' && typeof value === 'string') { + expect(value).toMatch(/^#[0-9a-f]{6}$/i); + } + }); + }); + + test('palette maintains consistent hue', () => { + const palette = generatePaletteFromSeed('primary', '#0073bb'); + const seedHct = hexToHct('#0073bb'); + + Object.entries(palette).forEach(([key, value]) => { + if (key !== 'seed' && typeof value === 'string') { + const hct = hexToHct(value); + // Hue should be consistent across palette (within tolerance) + expect(Math.abs(hct.hue - seedHct.hue)).toBeLessThan(5); + } + }); + }); + + test('palette tones progress from light to dark', () => { + const palette = generatePaletteFromSeed('primary', '#0073bb'); + + const tone50 = hexToHct(getColor(palette, 50)).tone; + const tone500 = hexToHct(getColor(palette, 500)).tone; + const tone1000 = hexToHct(getColor(palette, 1000)).tone; + + expect(tone50).toBeGreaterThan(tone500); + expect(tone500).toBeGreaterThan(tone1000); + }); + + describe('WCAG accessibility guarantees', () => { + test('tone difference ≥49 between adjacent steps for AA compliance', () => { + const palette = generatePaletteFromSeed('primary', '#0073bb'); + const steps: PaletteStep[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]; + + for (let i = 0; i < steps.length - 1; i++) { + const currentTone = hexToHct(getColor(palette, steps[i])).tone; + const nextTone = hexToHct(getColor(palette, steps[i + 1])).tone; + const toneDiff = Math.abs(currentTone - nextTone); + + expect(toneDiff).toBeGreaterThan(0); + } + }); + + test('50 and 1000 steps have maximum contrast', () => { + const palette = generatePaletteFromSeed('primary', '#0073bb'); + + const tone50 = hexToHct(getColor(palette, 50)).tone; + const tone1000 = hexToHct(getColor(palette, 1000)).tone; + const toneDiff = Math.abs(tone50 - tone1000); + + expect(toneDiff).toBeGreaterThanOrEqual(60); + }); + + test('neutral palette maintains low chroma for grayscale', () => { + const palette = generatePaletteFromSeed('neutral', '#888888'); + + Object.entries(palette).forEach(([key, value]) => { + if (key !== 'seed' && typeof value === 'string') { + const hct = hexToHct(value); + // Neutral colors should have very low chroma + expect(hct.chroma).toBeLessThan(10); + } + }); + }); + }); + + describe('autoAdjust parameter', () => { + test('autoAdjust=true adjusts seed color if needed', () => { + const paletteAdjusted = generatePaletteFromSeed('primary', '#0073bb', true); + const paletteNotAdjusted = generatePaletteFromSeed('primary', '#0073bb', false); + + // Both should generate valid palettes + expect(paletteAdjusted[500]).toBeDefined(); + expect(paletteNotAdjusted[500]).toBeDefined(); + }); + + test('autoAdjust=false uses seed color directly', () => { + const palette = generatePaletteFromSeed('primary', '#0073bb', false); + + expect(palette.seed).toBe('#0073bb'); + }); + }); + + describe('edge cases', () => { + test('handles very light seed colors', () => { + const palette = generatePaletteFromSeed('primary', '#e6f3ff'); + + expect(Object.keys(palette).filter((k) => k !== 'seed')).toHaveLength(11); + }); + + test('handles very dark seed colors', () => { + const palette = generatePaletteFromSeed('primary', '#001122'); + + expect(Object.keys(palette).filter((k) => k !== 'seed')).toHaveLength(11); + }); + + test('handles low saturation seed colors', () => { + const palette = generatePaletteFromSeed('primary', '#888888'); + + expect(Object.keys(palette).filter((k) => k !== 'seed')).toHaveLength(11); + }); + + test('handles high saturation seed colors', () => { + const palette = generatePaletteFromSeed('primary', '#ff0000'); + + expect(Object.keys(palette).filter((k) => k !== 'seed')).toHaveLength(11); + }); + }); + + describe('memoization', () => { + test('returns same object reference for identical calls', () => { + clearPaletteCache(); + + const palette1 = generatePaletteFromSeed('primary', '#0073bb'); + const palette2 = generatePaletteFromSeed('primary', '#0073bb'); + + // Should return exact same object from cache + expect(palette1).toBe(palette2); + }); + + test('returns different objects for different seeds', () => { + clearPaletteCache(); + + const palette1 = generatePaletteFromSeed('primary', '#0073bb'); + const palette2 = generatePaletteFromSeed('primary', '#ff0000'); + + expect(palette1).not.toBe(palette2); + }); + + test('returns different objects for different categories', () => { + clearPaletteCache(); + + const palette1 = generatePaletteFromSeed('primary', '#0073bb'); + const palette2 = generatePaletteFromSeed('neutral', '#0073bb'); + + expect(palette1).not.toBe(palette2); + }); + }); + }); +}); diff --git a/src/shared/theme/color-generation/__tests__/palette-spec.test.ts b/src/shared/theme/color-generation/__tests__/palette-spec.test.ts new file mode 100644 index 0000000..4c45b97 --- /dev/null +++ b/src/shared/theme/color-generation/__tests__/palette-spec.test.ts @@ -0,0 +1,447 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, test, expect } from 'vitest'; +import { PrimaryPaletteSpecification } from '../primary-spec'; +import { NeutralPaletteSpecification } from '../neutral-spec'; +import { WarningPaletteSpecification } from '../warning-spec'; +import { hexToHct } from '../hct-utils'; +import { PaletteStep, ReferencePaletteDefinition } from '../../interfaces'; + +// While WCAG specifies contrast ratios (e.g., 4.5:1), the HCT (hue, chroma, tone) system converts these into a simple tone difference. +// A difference of 40 in HCT tone guarantees a contrast ratio >= 3:1, and a difference of 50 guarantees a contrast ratio >= 4.5:1. +// We add a little bit of buffer because while those are the guaranteed thresholds, we also try and follow our default palette tones as close as possible. +const WCAG_AA_NORMAL_TONE_DIFFERENCE = 47; +const WCAG_AA_LARGE_TONE_DIFFERENCE = 37; + +function getColor(palette: ReferencePaletteDefinition, step: PaletteStep): string { + const color = palette[step]; + if (typeof color !== 'string') { + throw new Error(`Expected string color at step ${step}, got ${typeof color}`); + } + return color; +} + +describe('palette specifications', () => { + describe('PrimaryPaletteSpecification', () => { + const spec = new PrimaryPaletteSpecification(); + + test('generates palette with all required steps', () => { + const palette = spec.getPalette('#0073bb'); + + expect(palette[50]).toBeDefined(); + expect(palette[100]).toBeDefined(); + expect(palette[200]).toBeDefined(); + expect(palette[300]).toBeDefined(); + expect(palette[400]).toBeDefined(); + expect(palette[500]).toBeDefined(); + expect(palette[600]).toBeDefined(); + expect(palette[700]).toBeDefined(); + expect(palette[800]).toBeDefined(); + expect(palette[900]).toBeDefined(); + expect(palette[1000]).toBeDefined(); + }); + + test('step 50 has highest tone (lightest)', () => { + const palette = spec.getPalette('#0073bb'); + const tone50 = hexToHct(getColor(palette, 50)).tone; + + expect(tone50).toBeGreaterThan(90); + }); + + test('step 1000 has lowest tone (darkest)', () => { + const palette = spec.getPalette('#0073bb'); + const tone1000 = hexToHct(getColor(palette, 1000)).tone; + + expect(tone1000).toBeLessThan(10); + }); + + test('tones decrease monotonically from 50 to 1000', () => { + const palette = spec.getPalette('#0073bb'); + const steps: PaletteStep[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]; + + for (let i = 0; i < steps.length - 1; i++) { + const currentTone = hexToHct(getColor(palette, steps[i])).tone; + const nextTone = hexToHct(getColor(palette, steps[i + 1])).tone; + + expect(currentTone).toBeGreaterThan(nextTone); + } + }); + + test('step 600 meets WCAG AA contrast with white (tone ≤47)', () => { + const palette = spec.getPalette('#0073bb'); + const tone600 = hexToHct(getColor(palette, 600)).tone; + + expect(tone600).toBeLessThanOrEqual(47); + }); + + test('adjusts seed color for light mode when tone > 47', () => { + const palette = spec.getPalette('#e6f3ff', true, 'light'); + const tone600 = hexToHct(getColor(palette, 600)).tone; + + expect(tone600).toBeLessThanOrEqual(47); + expect(tone600).toBeGreaterThanOrEqual(44); + }); + + test('adjusts seed color for dark mode when tone < 65', () => { + const palette = spec.getPalette('#001122', true, 'dark'); + const tone400 = hexToHct(getColor(palette, 400)).tone; + + expect(tone400).toBeGreaterThanOrEqual(65); + expect(tone400).toBeLessThanOrEqual(75); + }); + + test('maintains consistent hue across palette', () => { + const palette = spec.getPalette('#0073bb'); + const seedHue = hexToHct('#0073bb').hue; + + Object.values(palette).forEach((color) => { + if (typeof color === 'string' && color !== palette.seed) { + const hct = hexToHct(color); + expect(Math.abs(hct.hue - seedHue)).toBeLessThan(5); + } + }); + }); + }); + + describe('NeutralPaletteSpecification', () => { + const spec = new NeutralPaletteSpecification(); + + test('generates palette with all required steps', () => { + const palette = spec.getPalette('#888888'); + + expect(palette[50]).toBeDefined(); + expect(palette[100]).toBeDefined(); + expect(palette[1000]).toBeDefined(); + }); + + test('maintains low chroma across all steps', () => { + const palette = spec.getPalette('#888888'); + + Object.values(palette).forEach((color) => { + if (typeof color === 'string' && color !== palette.seed) { + const hct = hexToHct(color); + expect(hct.chroma).toBeLessThan(20); + } + }); + }); + + test('adjusts high chroma seed colors to max chroma', () => { + const palette = spec.getPalette('#ff0000'); + + Object.values(palette).forEach((color) => { + if (typeof color === 'string' && color !== palette.seed) { + const hct = hexToHct(color); + expect(hct.chroma).toBeLessThanOrEqual(15); + } + }); + }); + + test('step 50 is near white (tone ~99)', () => { + const palette = spec.getPalette('#888888'); + const tone50 = hexToHct(getColor(palette, 50)).tone; + + expect(tone50).toBeGreaterThan(95); + }); + + test('step 1000 is near black (tone ~2)', () => { + const palette = spec.getPalette('#888888'); + const tone1000 = hexToHct(getColor(palette, 1000)).tone; + + expect(tone1000).toBeLessThan(10); + }); + + test('has more granular steps than primary palette', () => { + const palette = spec.getPalette('#888888'); + const steps = Object.keys(palette).filter((k) => k !== 'seed' && !isNaN(Number(k))); + + expect(steps.length).toBeGreaterThan(11); + }); + }); + + describe('WarningPaletteSpecification', () => { + const spec = new WarningPaletteSpecification(); + + test('generates palette with all required steps', () => { + const palette = spec.getPalette('#ff9900'); + + expect(palette[50]).toBeDefined(); + expect(palette[100]).toBeDefined(); + expect(palette[1000]).toBeDefined(); + }); + + test('maintains warm hue for warning colors', () => { + const palette = spec.getPalette('#ff9900'); + + Object.values(palette).forEach((color) => { + if (typeof color === 'string' && color !== palette.seed) { + const hct = hexToHct(color); + expect(hct.hue).toBeGreaterThanOrEqual(0); + expect(hct.hue).toBeLessThanOrEqual(90); + } + }); + }); + }); + + describe('accessibility guarantees across specifications', () => { + describe('primary palette', () => { + const spec = new PrimaryPaletteSpecification(); + + // Lightest background color and lightest (non-disabled) text color in light mode + test('step 50 (minTone) and step 600 (maxTone) meet WCAG AA', () => { + const palette = spec.getPalette('#0073bb'); + const background = hexToHct(getColor(palette, 50)).tone; + const foreground = hexToHct(getColor(palette, 600)).tone; + + expect(background - foreground).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('step 400 (maxTone) and step 900 (minTone) meet WCAG AA', () => { + const palette = spec.getPalette('#0073bb'); + const foreground = hexToHct(getColor(palette, 400)).tone; + const background = hexToHct(getColor(palette, 900)).tone; + + expect(foreground - background).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('dark mode: step 400 (maxTone) and step 1000 (minTone) meet WCAG AA', () => { + const palette = spec.getPalette('#0073bb', true, 'dark'); + const foreground = hexToHct(getColor(palette, 400)).tone; + const background = hexToHct(getColor(palette, 1000)).tone; + + expect(foreground - background).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + }); + + describe('neutral palette', () => { + const spec = new NeutralPaletteSpecification(); + + // Darkest background color and lightest (non-disabled) text color in light mode + test('step 200 (minTone) and step 600 (maxTone) meet WCAG AA', () => { + const palette = spec.getPalette('#888888'); + const background = hexToHct(getColor(palette, 200)).tone; + const foreground = hexToHct(getColor(palette, 600)).tone; + + expect(background - foreground).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) background color and lightest interactive element color in light mode + test('step 200 (minTone) and step 500 (maxTone) meet WCAG AA', () => { + const palette = spec.getPalette('#888888'); + const background = hexToHct(getColor(palette, 200)).tone; + const foreground = hexToHct(getColor(palette, 500)).tone; + + expect(background - foreground).toBeGreaterThanOrEqual(WCAG_AA_LARGE_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('step 450 (maxTone) and step 700 (minTone) meet WCAG AA', () => { + const palette = spec.getPalette('#888888'); + const foreground = hexToHct(getColor(palette, 450)).tone; + const background = hexToHct(getColor(palette, 700)).tone; + + expect(foreground - background).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('dark mode: step 450 (maxTone) and step 700 (minTone) meet WCAG AA', () => { + const palette = spec.getPalette('#888888', true, 'dark'); + const foreground = hexToHct(getColor(palette, 450)).tone; + const background = hexToHct(getColor(palette, 700)).tone; + + expect(foreground - background).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + }); + + describe('warning palette', () => { + const spec = new WarningPaletteSpecification(); + + // Lightest background color and lightest (non-disabled) text color in light mode + test('light mode: step 50 (minTone) and step 900 (maxTone) meet WCAG AA', () => { + const palette = spec.getPalette('#ff9900'); + const background = hexToHct(getColor(palette, 50)).tone; + const foreground = hexToHct(getColor(palette, 900)).tone; + + expect(background - foreground).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('dark mode: step 500 (maxTone) and step 1000 (minTone) meet WCAG AA', () => { + const palette = spec.getPalette('#ff9900'); + const foreground = hexToHct(getColor(palette, 500)).tone; + const background = hexToHct(getColor(palette, 1000)).tone; + + expect(foreground - background).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + }); + + describe('cross-palette comparisons', () => { + const primarySpec = new PrimaryPaletteSpecification(); + const neutralSpec = new NeutralPaletteSpecification(); + const warningSpec = new WarningPaletteSpecification(); + + // Darkest (non-disabled) background color and lightest primary text color in light mode + test('step neutral 200 (minTone) and step primary 600 (maxTone) meet WCAG AA', () => { + const primary = primarySpec.getPalette('#0073bb'); + const neutral = neutralSpec.getPalette('#58480d'); + const background = hexToHct(getColor(neutral, 200)).tone; + const foreground = hexToHct(getColor(primary, 600)).tone; + + expect(background - foreground).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) background color and lightest warning text color in light mode + test('step neutral 200 (minTone) and step primary 900 (maxTone) meet WCAG AA', () => { + const warning = warningSpec.getPalette('#e2ba19'); + const neutral = neutralSpec.getPalette('#af95bd'); + const background = hexToHct(getColor(neutral, 200)).tone; + const foreground = hexToHct(getColor(warning, 900)).tone; + + expect(background - foreground).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('dark mode: step primary 300 (maxTone) and step neutral 700 (minTone) meet WCAG AA', () => { + const primary = primarySpec.getPalette('#0073bb'); + const neutral = neutralSpec.getPalette('#58480d'); + const foreground = hexToHct(getColor(primary, 300)).tone; + const background = hexToHct(getColor(neutral, 700)).tone; + + expect(foreground - background).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('dark mode: step 500 (maxTone) and step 700 (minTone) meet WCAG AA', () => { + const warning = warningSpec.getPalette('#e2ba19'); + const neutral = neutralSpec.getPalette('#af95bd'); + const foreground = hexToHct(getColor(warning, 500)).tone; + const background = hexToHct(getColor(neutral, 700)).tone; + + expect(foreground - background).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + }); + + describe('WCAG accessibility - spec range validation', () => { + describe('primary palette spec ranges', () => { + const spec = new PrimaryPaletteSpecification(); + const colorSpecs = (spec as any).colorSpecifications; + + // Darkest (non-disabled) background color and lightest text color in light mode + test('light mode: step 50 (minTone) and step 600 (maxTone) meet WCAG AA', () => { + const step50 = colorSpecs.find((s: any) => s.position === 50); + const step600 = colorSpecs.find((s: any) => s.position === 600); + + expect(step50.minTone - step600.maxTone).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('dark mode: step 400 (minTone) and step 1000 (maxTone) meet WCAG AA', () => { + const step400 = colorSpecs.find((s: any) => s.position === 400); + const step1000 = colorSpecs.find((s: any) => s.position === 1000); + + expect(step400.minTone - step1000.maxTone).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + }); + + describe('neutral palette spec ranges', () => { + const spec = new NeutralPaletteSpecification(); + const colorSpecs = (spec as any).colorSpecifications; + + // Lightest background color and lightest (non-disabled) text color in light mode + test('light mode: step 200 (minTone) and step 600 (maxTone) meet WCAG AA', () => { + const step200 = colorSpecs.find((s: any) => s.position === 200); + const step600 = colorSpecs.find((s: any) => s.position === 600); + + expect(step200.minTone - step600.maxTone).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) background color and lightest interactive element color in light mode + test('light mode: step 200 (minTone) and step 500 (maxTone) meet WCAG AA for interactive elements', () => { + const step200 = colorSpecs.find((s: any) => s.position === 200); + const step500 = colorSpecs.find((s: any) => s.position === 500); + + expect(step200.minTone - step500.maxTone).toBeGreaterThanOrEqual(WCAG_AA_LARGE_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background (non-inverted) color in dark mode + test('dark mode: step 450 (minTone) and step 800 (maxTone) meet WCAG AA', () => { + const step450 = colorSpecs.find((s: any) => s.position === 450); + const step800 = colorSpecs.find((s: any) => s.position === 800); + + expect(step450.minTone - step800.maxTone).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) input border color and darkest background color in dark mode + test('dark mode: step 600 (minTone) and step 850 (maxTone) meet WCAG AA for interactive elements', () => { + const step600 = colorSpecs.find((s: any) => s.position === 600); + const step850 = colorSpecs.find((s: any) => s.position === 850); + + expect(step600.minTone - step850.maxTone).toBeGreaterThanOrEqual(WCAG_AA_LARGE_TONE_DIFFERENCE); + }); + }); + + describe('warning palette spec ranges', () => { + const spec = new WarningPaletteSpecification(); + const colorSpecs = (spec as any).colorSpecifications; + + // Lightest background color and lightest (non-disabled) text color in light mode + test('light mode: step 50 (minTone) and step 900 (maxTone) meet WCAG AA', () => { + const step50 = colorSpecs.find((s: any) => s.position === 50); + const step900 = colorSpecs.find((s: any) => s.position === 900); + + expect(step50.minTone - step900.maxTone).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('dark mode: step 500 (minTone) and step 1000 (maxTone) meet WCAG AA', () => { + const step500 = colorSpecs.find((s: any) => s.position === 500); + const step1000 = colorSpecs.find((s: any) => s.position === 1000); + + expect(step500.minTone - step1000.maxTone).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + }); + + describe('cross-palette spec ranges', () => { + const primarySpec = new PrimaryPaletteSpecification(); + const neutralSpec = new NeutralPaletteSpecification(); + const warningSpec = new WarningPaletteSpecification(); + const primarySpecs = (primarySpec as any).colorSpecifications; + const neutralSpecs = (neutralSpec as any).colorSpecifications; + const warningSpecs = (warningSpec as any).colorSpecifications; + + // Darkest (non-disabled) background color and lightest primary text color in light mode + test('light mode: neutral 200 (minTone) and primary 600 (maxTone) meet WCAG AA', () => { + const neutral200 = neutralSpecs.find((s: any) => s.position === 200); + const primary600 = primarySpecs.find((s: any) => s.position === 600); + + expect(neutral200.minTone - primary600.maxTone).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) background color and lightest warning text color in light mode + test('light mode: neutral 250 (minTone) and warning 900 (maxTone) meet WCAG AA', () => { + const neutral200 = neutralSpecs.find((s: any) => s.position === 200); + const warning900 = warningSpecs.find((s: any) => s.position === 900); + + expect(neutral200.minTone - warning900.maxTone).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('dark mode: primary 300 (minTone) and neutral 700 (maxTone) meet WCAG AA', () => { + const primary300 = primarySpecs.find((s: any) => s.position === 300); + const neutral700 = neutralSpecs.find((s: any) => s.position === 700); + + expect(primary300.minTone - neutral700.maxTone).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + + // Darkest (non-disabled) text color and darkest background color in dark mode + test('dark mode: warning 500 (maxTone) and neutral 700 (minTone) meet WCAG AA', () => { + const warning500 = warningSpecs.find((s: any) => s.position === 500); + const neutral700 = neutralSpecs.find((s: any) => s.position === 700); + + expect(warning500.maxTone - neutral700.minTone).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TONE_DIFFERENCE); + }); + }); + }); + }); +}); diff --git a/src/shared/theme/color-generation/__tests__/performance.test.ts b/src/shared/theme/color-generation/__tests__/performance.test.ts new file mode 100644 index 0000000..d950893 --- /dev/null +++ b/src/shared/theme/color-generation/__tests__/performance.test.ts @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, test, expect } from 'vitest'; +import { generatePaletteFromSeed } from '../palette-generator'; + +describe('performance benchmarks', () => { + test('palette generation completes within reasonable time', () => { + const start = performance.now(); + + generatePaletteFromSeed('primary', '#0073bb'); + + const duration = performance.now() - start; + + // Baseline: ~0.3-8ms (cold start), will catch 10x+ regressions + expect(duration).toBeLessThan(10); + }); + + test('repeated generation with same seed', () => { + const seed = '#0073bb'; + const iterations = 100; + + const start = performance.now(); + + for (let i = 0; i < iterations; i++) { + generatePaletteFromSeed('primary', seed); + } + + const duration = performance.now() - start; + const avgDuration = duration / iterations; + + console.log(`Average palette generation: ${avgDuration.toFixed(2)}ms`); + // With memoization, should be near-instant (<0.1ms per call) + expect(avgDuration).toBeLessThan(0.1); + }); + + test('generation for different categories', () => { + const categories: Array<'primary' | 'neutral' | 'warning' | 'error' | 'success' | 'info'> = [ + 'primary', + 'neutral', + 'warning', + 'error', + 'success', + 'info', + ]; + + const start = performance.now(); + + categories.forEach((category) => { + generatePaletteFromSeed(category, '#0073bb'); + }); + + const duration = performance.now() - start; + + // 6 categories * ~0.3ms = ~2ms, allow 10x headroom + expect(duration).toBeLessThan(20); + }); + + test('generation with different seeds', () => { + const seeds = ['#0073bb', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff']; + + const start = performance.now(); + + seeds.forEach((seed) => { + generatePaletteFromSeed('primary', seed); + }); + + const duration = performance.now() - start; + + // 6 seeds * ~0.3ms = ~2ms, allow 10x headroom + expect(duration).toBeLessThan(20); + }); +}); diff --git a/src/shared/theme/color-generation/hct-utils.ts b/src/shared/theme/color-generation/hct-utils.ts new file mode 100644 index 0000000..cab7996 --- /dev/null +++ b/src/shared/theme/color-generation/hct-utils.ts @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { argbFromHex, argbFromRgb, Hct, hexFromArgb } from '@material/material-color-utilities'; + +function isValidHex(hex: string): boolean { + return /^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(hex); +} + +/** + * Parses CSS color formats and converts to ARGB. + * Supports: #hex and rgb()/rgba() + */ +function parseColorToArgb(color: string): number { + const trimmed = color.trim(); + + // Hex format + if (trimmed.startsWith('#')) { + if (!isValidHex(trimmed)) { + throw new Error(`Invalid hex color: ${color}`); + } + return argbFromHex(trimmed); + } + + // rgb() or rgba() format + const rgbMatch = trimmed.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (rgbMatch) { + const r = parseInt(rgbMatch[1]); + const g = parseInt(rgbMatch[2]); + const b = parseInt(rgbMatch[3]); + return argbFromRgb(r, g, b); + } + + throw new Error(`Unsupported color format: ${color}. Supported formats: #hex, rgb(), rgba()`); +} + +export function hctToHex(hctColor: Hct): string { + return hexFromArgb(hctColor.toInt()); +} + +export function hexToHct(color: string): Hct { + const argb = parseColorToArgb(color); + return Hct.fromInt(argb); +} + +export function createHct(hue: number, chroma: number, tone: number): Hct { + return Hct.from(hue, chroma, tone); +} + +export { hexFromArgb, argbFromHex, Hct }; diff --git a/src/shared/theme/color-generation/neutral-spec.ts b/src/shared/theme/color-generation/neutral-spec.ts new file mode 100644 index 0000000..b2b19f0 --- /dev/null +++ b/src/shared/theme/color-generation/neutral-spec.ts @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { PaletteStep } from '../interfaces'; +import { createHct, Hct } from './hct-utils'; +import { PaletteSpecification } from './palette-spec'; + +const MIN_TONE = 1; +const MAX_TONE = 99; +const MAX_CHROMA = 15; + +export class NeutralPaletteSpecification extends PaletteSpecification { + public constructor() { + super( + [ + // Near white - very light neutrals + { position: 50, chromaFraction: 0.5, minTone: 98, maxTone: MAX_TONE }, // 99 + { position: 100, chromaFraction: 0.5, minTone: 97, maxTone: 98 }, // 98 + { position: 150, chromaFraction: 0.5, minTone: 96, maxTone: 97 }, // 97 + { position: 200, chromaFraction: 0.5, minTone: 96, maxTone: 96 }, // 96 + // Light neutrals + { position: 250, chromaFraction: 0.5, minTone: 93, maxTone: 95 }, // 93 + { position: 300, chromaFraction: 0.5, minTone: 88, maxTone: 92 }, // 89 + { position: 350, chromaFraction: 0.5, minTone: 80, maxTone: 85 }, // 82 + // Medium neutrals + { position: 400, chromaFraction: 0.75, minTone: 72, maxTone: 76 }, // 74 + { position: 450, chromaFraction: 0.75, minTone: 68, maxTone: 72 }, // 68 + { position: 500, chromaFraction: 0.75, minTone: 55, maxTone: 58 }, // 58 + { position: 550, chromaFraction: 0.75, minTone: 46, maxTone: 52 }, // 49 + { position: 600, chromaFraction: 0.75, minTone: 44, maxTone: 46 }, // 44 + // Dark neutrals + { position: 650, chromaFraction: 0.75, minTone: 28, maxTone: 35 }, // 30 + { position: 700, chromaFraction: 0.75, minTone: 22, maxTone: 27 }, // 23 + { position: 750, chromaFraction: 0.75, minTone: 14, maxTone: 20 }, // 17 + { position: 800, chromaFraction: 0.75, minTone: 10, maxTone: 14 }, // 13 + // Very dark neutrals + { position: 850, chromaFraction: 0.75, minTone: 5, maxTone: 7 }, // 10 + { position: 900, chromaFraction: 0.75, minTone: 3, maxTone: 5 }, // 8 + { position: 950, chromaFraction: 0.75, minTone: 2, maxTone: 3 }, // 6 + { position: 1000, chromaFraction: 0.75, minTone: MIN_TONE, maxTone: 2 }, // 2 + ], + MAX_CHROMA + ); + } + + protected adjustSeedColor(hct: Hct): Hct { + if (hct.chroma > MAX_CHROMA) { + return createHct(hct.hue, MAX_CHROMA, this.findNearestValidTone(hct.tone)); + } + return hct; + } +} diff --git a/src/shared/theme/color-generation/palette-generator.ts b/src/shared/theme/color-generation/palette-generator.ts new file mode 100644 index 0000000..4136f6b --- /dev/null +++ b/src/shared/theme/color-generation/palette-generator.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ColorReferenceTokens, ReferencePaletteDefinition } from '../interfaces'; +import { NeutralPaletteSpecification } from './neutral-spec'; +import { PrimaryPaletteSpecification } from './primary-spec'; +import { WarningPaletteSpecification } from './warning-spec'; + +// Memoization cache for palette generation +const paletteCache = new Map(); + +function getCacheKey(category: keyof ColorReferenceTokens, seed: string, autoAdjust: boolean, mode?: string): string { + return `${category}:${seed}:${autoAdjust}:${mode ?? 'none'}`; +} + +// Export for testing +export function clearPaletteCache(): void { + paletteCache.clear(); +} + +export function generatePaletteFromSeed( + category: keyof ColorReferenceTokens, + seed: string, + autoAdjust = true, + mode?: string +): ReferencePaletteDefinition { + const cacheKey = getCacheKey(category, seed, autoAdjust, mode); + + const cached = paletteCache.get(cacheKey); + if (cached) { + return cached; + } + + const primaryPaletteSpec = new PrimaryPaletteSpecification(); + const neutralPaletteSpec = new NeutralPaletteSpecification(); + const warningPaletteSpec = new WarningPaletteSpecification(); + + let paletteSpec: PrimaryPaletteSpecification | NeutralPaletteSpecification | WarningPaletteSpecification; + + switch (category) { + case 'neutral': + paletteSpec = neutralPaletteSpec; + break; + case 'warning': + paletteSpec = warningPaletteSpec; + break; + case 'primary': + case 'error': + case 'success': + case 'info': + default: + paletteSpec = primaryPaletteSpec; + } + + const generated = paletteSpec.getPalette(seed, autoAdjust, mode); + paletteCache.set(cacheKey, generated); + + return generated; +} diff --git a/src/shared/theme/color-generation/palette-spec.ts b/src/shared/theme/color-generation/palette-spec.ts new file mode 100644 index 0000000..d845ce3 --- /dev/null +++ b/src/shared/theme/color-generation/palette-spec.ts @@ -0,0 +1,181 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ReferencePaletteDefinition } from '../interfaces'; +import { createHct, Hct, hctToHex, hexToHct } from './hct-utils'; + +interface ColorSpecification { + position: PaletteKeys; + // 0 - 1 mulitplier that creates variation across palette positions. It scales relatively based on the chroma of the seed color + chromaFraction: number; + // 0 - 100 + minTone: number; + // 0 - 100 + maxTone: number; +} + +export class PaletteSpecification { + maxChroma: number; + colorSpecifications: ColorSpecification[]; + + constructor(positionRequirements: ColorSpecification[], maxChroma?: number) { + this.colorSpecifications = positionRequirements; + this.maxChroma = maxChroma ?? 200; // High default for unrestricted palettes + } + + private findColorSpecification(hctColor: Hct): ColorSpecification | undefined { + const tone = Math.round(hctColor.tone); + for (const position of this.colorSpecifications) { + if (tone <= position.maxTone && tone >= position.minTone) { + return position; + } + } + // No exact range match - find nearest valid tone + const nearestTone = this.findNearestValidTone(tone); + for (const position of this.colorSpecifications) { + if (nearestTone <= position.maxTone && nearestTone >= position.minTone) { + return position; + } + } + return undefined; + } + + protected findNearestValidTone(inputTone: number): number { + let closestTone = inputTone; + let minDistance = Infinity; + const preferDarker = inputTone < 50; + + for (const spec of this.colorSpecifications) { + const midTone = (spec.minTone + spec.maxTone) / 2; + const distance = Math.abs(inputTone - midTone); + + if (distance < minDistance) { + minDistance = distance; + closestTone = midTone; + } else if (distance === minDistance && preferDarker && midTone < closestTone) { + closestTone = midTone; + } + } + return Math.round(closestTone); + } + + private getColorToneProportion(position: ColorSpecification, hctColor: Hct): number { + const proportion = (hctColor.tone - position.minTone) / (position.maxTone - position.minTone); + return Math.max(0, Math.min(1, proportion)); + } + + private getColorToneForProportion(position: ColorSpecification, proportion: number): number { + const baseTone = position.minTone + (position.maxTone - position.minTone) * proportion; + + // Bias toward range edges to maximize contrast + // Lower position numbers (50, 100, etc.) are light - bias toward maxTone (lighter) + // Higher position numbers (700, 800, etc.) are dark - bias toward minTone (darker) + const BIAS_STRENGTH = 0.5; + const positionNum = Number(position.position); + + if (positionNum <= 500) { + // Light half of palette - push toward maxTone (lighter) + return baseTone + (position.maxTone - baseTone) * BIAS_STRENGTH; + } else { + // Dark half of palette - push toward minTone (darker) + return baseTone - (baseTone - position.minTone) * BIAS_STRENGTH; + } + } + + protected adjustSeedColor(hct: Hct, mode?: string): Hct { + return hct; + } + + protected getExactSeedPosition(hct: Hct, mode?: string): PaletteKeys | undefined { + return undefined; + } + + private validateAndAdjustSeed(hexColor: string, mode?: string): string { + let hct = hexToHct(hexColor); + hct = this.adjustSeedColor(hct, mode); + return hctToHex(hct); + } + + public getPalette(hexBaseColor: string, autoAdjust = true, mode?: string): ReferencePaletteDefinition { + const adjustedSeed = this.prepareBaseSeed(hexBaseColor, autoAdjust, mode); + const baseColorInfo = this.extractBaseColorInfo(adjustedSeed, mode); + const colors = this.generatePaletteColors(baseColorInfo); + + return { + seed: adjustedSeed.hex, + ...colors, + }; + } + + private prepareBaseSeed(hexColor: string, autoAdjust: boolean, mode?: string) { + let seedWasAdjusted = false; + if (autoAdjust) { + const original = hexColor; + hexColor = this.validateAndAdjustSeed(hexColor, mode); + seedWasAdjusted = original !== hexColor; + } + return { hex: hexColor, wasAdjusted: seedWasAdjusted }; + } + + private extractBaseColorInfo(seed: { hex: string; wasAdjusted: boolean }, mode?: string) { + const hctBaseColor = hexToHct(seed.hex); + const exactSeedPosition = this.getExactSeedPosition(hctBaseColor, mode); + const baseColorPalettePosition = exactSeedPosition + ? this.colorSpecifications.find((s) => s.position === exactSeedPosition) + : this.findColorSpecification(hctBaseColor); + + if (!baseColorPalettePosition) { + throw new Error(`Seed color ${seed.hex} does not match any palette position specification`); + } + + const baseColorToneRangePosition = exactSeedPosition + ? 0.5 + : this.getColorToneProportion(baseColorPalettePosition, hctBaseColor); + + return { + hue: hctBaseColor.hue, + chroma: this.calculateBaseChroma(hctBaseColor.chroma, baseColorPalettePosition), + basePosition: baseColorPalettePosition, + toneRangePosition: baseColorToneRangePosition, + seedHex: seed.hex, + seedWasAdjusted: seed.wasAdjusted, + exactSeedPosition, + }; + } + + private calculateBaseChroma(seedChroma: number, position: ColorSpecification): number { + const useDirectChroma = this.maxChroma < 50; + return useDirectChroma ? seedChroma : seedChroma / position.chromaFraction; + } + + private generatePaletteColors(baseInfo: { + hue: number; + chroma: number; + basePosition: ColorSpecification; + toneRangePosition: number; + seedHex: string; + seedWasAdjusted: boolean; + exactSeedPosition: PaletteKeys | undefined; + }): ReferencePaletteDefinition { + const colors: ReferencePaletteDefinition = {}; + + for (const color of this.colorSpecifications) { + const tone = this.getColorToneForProportion(color, baseInfo.toneRangePosition); + const isPaletteBase = baseInfo.basePosition.position === color.position; + const isExactSeedPosition = baseInfo.exactSeedPosition === color.position; + + let adjustedChroma = color.chromaFraction * baseInfo.chroma; + if (adjustedChroma > this.maxChroma) { + adjustedChroma = this.maxChroma; + } + + const paletteColor = + (isPaletteBase && !baseInfo.seedWasAdjusted) || isExactSeedPosition + ? baseInfo.seedHex + : hctToHex(createHct(baseInfo.hue, adjustedChroma, tone)); + + colors[color.position as keyof ReferencePaletteDefinition] = paletteColor; + } + + return colors; + } +} diff --git a/src/shared/theme/color-generation/primary-spec.ts b/src/shared/theme/color-generation/primary-spec.ts new file mode 100644 index 0000000..2db0092 --- /dev/null +++ b/src/shared/theme/color-generation/primary-spec.ts @@ -0,0 +1,110 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PaletteStep } from '../interfaces'; +import { Hct } from './hct-utils'; +import { PaletteSpecification } from './palette-spec'; + +const MIN_TONE = 3; +const MAX_TONE = 98; + +export class PrimaryPaletteSpecification extends PaletteSpecification { + public constructor() { + super([ + { + position: 50, + chromaFraction: 0.2, + minTone: 97, + maxTone: MAX_TONE, // 98 - Contrasts with 600 in light mode + }, + { + position: 100, + chromaFraction: 0.3, + minTone: 91, + maxTone: 96, // 94 + }, + { + position: 200, + chromaFraction: 0.5, + minTone: 84, + maxTone: 91, // 88 + }, + { + position: 300, + chromaFraction: 0.7, + minTone: 75, + maxTone: 84, // 80 + }, + { + position: 400, + chromaFraction: 1.0, + minTone: 65, + maxTone: 75, // 70 - Contrasts with 800 in dark mode + }, + { + position: 500, + chromaFraction: 1.0, + minTone: 48, + maxTone: 65, // 60 + }, + { + position: 600, + chromaFraction: 1.0, + minTone: 44, + maxTone: 47, // 46 - Contrasts with 50 in light mode + }, + { + position: 700, + chromaFraction: 1.1, + minTone: 34, + maxTone: 44, // 40 + }, + { + position: 800, + chromaFraction: 1.15, + minTone: 25, + maxTone: 34, // 30 - Contrasts with 400 in dark mode + }, + { + position: 900, + chromaFraction: 1.2, + minTone: 11, + maxTone: 25, // 20 + }, + { + position: 1000, + chromaFraction: 1.25, + minTone: MIN_TONE, + maxTone: 5, // 3 + }, + ]); + } + + protected adjustSeedColor(hct: Hct, mode?: string): Hct { + const tone = hct.tone; + const position600 = this.colorSpecifications.find((s) => s.position === 600); + const position400 = this.colorSpecifications.find((s) => s.position === 400); + + if (mode === 'light' && position600 && tone > position600.maxTone) { + return Hct.from(hct.hue, hct.chroma, (position600.minTone + position600.maxTone) / 2); + } + if (mode === 'dark' && position400 && tone < position400.minTone) { + return Hct.from(hct.hue, hct.chroma, (position400.minTone + position400.maxTone) / 2); + } + return hct; + } + + protected getExactSeedPosition(hct: Hct, mode?: string): PaletteStep | undefined { + const tone = hct.tone; + const position600 = this.colorSpecifications.find((s) => s.position === 600); + const position400 = this.colorSpecifications.find((s) => s.position === 400); + + if (mode === 'light' && position600 && tone <= position600.maxTone) { + return 600; + } + if (mode === 'dark' && position400 && tone >= position400.minTone) { + return 400; + } + return undefined; + } +} diff --git a/src/shared/theme/color-generation/warning-spec.ts b/src/shared/theme/color-generation/warning-spec.ts new file mode 100644 index 0000000..312278a --- /dev/null +++ b/src/shared/theme/color-generation/warning-spec.ts @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PaletteStep } from '../interfaces'; +import { PaletteSpecification } from './palette-spec'; + +const MIN_TONE = 5; +const MAX_TONE = 98; + +export class WarningPaletteSpecification extends PaletteSpecification { + public constructor() { + super([ + { + position: 50, + chromaFraction: 0.5, + minTone: 97, + maxTone: MAX_TONE, // >97 + }, + { + position: 100, + chromaFraction: 0.5, + minTone: 95, + maxTone: 97, // 95 + }, + { + position: 200, + chromaFraction: 0.5, + minTone: 90, + maxTone: 95, // 90 + }, + { + position: 300, + chromaFraction: 0.75, + minTone: 86, + maxTone: 90, // 80s + }, + { + position: 400, + chromaFraction: 1.5, + minTone: 82, + maxTone: 86, // 80-90s + }, + { + position: 500, + chromaFraction: 1.5, + minTone: 75, + maxTone: 82, // 70-80s + }, + { + position: 600, + chromaFraction: 1.0, + minTone: 65, + maxTone: 75, // 60-70s + }, + { + position: 700, + chromaFraction: 1.1, + minTone: 55, + maxTone: 65, // 50-60s + }, + { + position: 800, + chromaFraction: 1.15, + minTone: 47, + maxTone: 55, // 40-50s + }, + { + position: 900, + chromaFraction: 1.2, + minTone: 41, + maxTone: 47, // 40s + }, + { + position: 1000, + chromaFraction: 1.25, + minTone: MIN_TONE, + maxTone: 15, // <10 + }, + ]); + } +} diff --git a/src/shared/theme/index.ts b/src/shared/theme/index.ts index 4c71d4d..7a8ab4c 100644 --- a/src/shared/theme/index.ts +++ b/src/shared/theme/index.ts @@ -13,9 +13,7 @@ export { TypedModeValueOverride, ReferenceTokens, ColorReferenceTokens, - ColorPaletteInput, - ColorPaletteDefinition, - PaletteStep, + ReferencePaletteDefinition, } from './interfaces'; export { ThemeBuilder, TokenCategory } from './builder'; export { @@ -32,3 +30,4 @@ export { } from './resolve'; export { validateOverride } from './validate'; export { merge, mergeInPlace } from './merge'; +export { processColorPaletteInput } from './process'; diff --git a/src/shared/theme/interfaces.ts b/src/shared/theme/interfaces.ts index 0e5d101..6a7d1e9 100644 --- a/src/shared/theme/interfaces.ts +++ b/src/shared/theme/interfaces.ts @@ -26,6 +26,7 @@ export interface Context { id: string; selector: string; tokens: Record; + defaultMode?: keyof Mode['states']; } export interface Theme { @@ -54,11 +55,12 @@ export interface ColorReferenceTokens { info?: ColorPaletteInput; } -/** - * Color reference tokens organized by semantic color categories. - * Each category is defined as a palette definition with explicit color values. - */ -export type ColorPaletteInput = ColorPaletteDefinition; +// String allows for shorthand seed definition +export type ColorPaletteInput = string | ReferencePaletteDefinition; +export interface ReferencePaletteDefinition extends ColorPalette { + seed?: Assignment; +} +export type ColorPalette = Partial>; /** * Palette steps available across all color types. Different color categories @@ -86,15 +88,11 @@ export type PaletteStep = | 950 | 1000; -/** - * Color palette definition with explicit color values for palette steps. - */ -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..1df2311 --- /dev/null +++ b/src/shared/theme/process.ts @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { generatePaletteFromSeed } from './color-generation/palette-generator'; +import { + ColorReferenceTokens, + ColorPaletteInput, + PaletteStep, + ReferencePaletteDefinition, + Assignment, +} from './interfaces'; +import { generateReferenceTokenName, isValidPaletteStep } from './utils'; + +export type TokenCategory = Record; + +export function processReferenceTokens(colorTokens: ColorReferenceTokens): TokenCategory { + const generatedTokens: TokenCategory = {}; + + Object.entries(colorTokens).forEach(([colorName, paletteInput]) => { + const palette = processColorPaletteInput(colorName as keyof ColorReferenceTokens, paletteInput); + + Object.entries(palette).forEach(([step, value]) => { + if (step !== 'seed') { + const tokenName = generateReferenceTokenName('color', colorName, step); + generatedTokens[tokenName] = value; + } + }); + }); + + return generatedTokens; +} + +function processSeedInput( + category: keyof ColorReferenceTokens, + seed: ReferencePaletteDefinition['seed'] +): ReferencePaletteDefinition { + if (!seed) return {}; + if (typeof seed === 'string') { + return generatePaletteFromSeed(category, seed); + } + + const palette: ReferencePaletteDefinition = {}; + + Object.entries(seed).forEach(([mode, seedColor]) => { + if (typeof seedColor !== 'string') return; + + const modePalette = generatePaletteFromSeed(category, seedColor, true, mode); + + Object.entries(modePalette).forEach(([step, value]) => { + const paletteStep = Number(step) as PaletteStep; + if (!isValidPaletteStep(paletteStep)) return; + + const existing = palette[paletteStep]; + palette[paletteStep] = typeof existing === 'object' ? { ...existing, [mode]: value } : { [mode]: value }; + }); + }); + + return palette; +} + +function mergeExplicitSteps( + generated: ReferencePaletteDefinition, + input: Exclude +): ReferencePaletteDefinition { + const result = { ...generated }; + + Object.entries(input).forEach(([step, value]) => { + if (step === 'seed') { + result.seed = value; + return; + } + + const paletteStep = Number(step) as PaletteStep; + if (!value || !isValidPaletteStep(paletteStep)) return; + + const generatedValue = generated[paletteStep]; + // Merge mode objects, otherwise use explicit value + result[paletteStep] = + typeof generatedValue === 'object' && typeof value === 'object' ? { ...generatedValue, ...value } : value; + }); + + return result; +} + +export function processColorPaletteInput( + category: keyof ColorReferenceTokens, + input: ColorPaletteInput +): ReferencePaletteDefinition { + if (typeof input === 'string') { + return generatePaletteFromSeed(category, input); + } + + const generated = processSeedInput(category, input.seed); + return mergeExplicitSteps(generated, input); +} diff --git a/src/shared/theme/resolve.ts b/src/shared/theme/resolve.ts index db6f400..d1fa61c 100644 --- a/src/shared/theme/resolve.ts +++ b/src/shared/theme/resolve.ts @@ -3,7 +3,16 @@ import { Context, Mode } from '.'; import { cloneDeep, values } from '../utils'; import { Theme, Value } from './interfaces'; -import { areAssignmentsEqual, getDefaultState, getMode, getReference, isModeValue, isReference } from './utils'; +import type { PropertiesMap } from '../declaration/interfaces'; +import { + areAssignmentsEqual, + getDefaultState, + getMode, + getReference, + isModeValue, + isReference, + isReferenceToken, +} from './utils'; export type ModeTokenResolution = Record; export type SpecificTokenResolution = Value; @@ -27,10 +36,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, propertiesMap?: PropertiesMap): FullResolution { + return resolveThemeWithPaths(theme, baseTheme, propertiesMap).resolvedTheme; } -export function resolveThemeWithPaths(theme: Theme, baseTheme?: Theme): FullResolutionWithPaths { +export function resolveThemeWithPaths( + theme: Theme, + baseTheme?: Theme, + propertiesMap?: PropertiesMap +): FullResolutionWithPaths { const resolvedTheme: FullResolution = {}; const resolutionPaths: FullResolutionPaths = {}; @@ -40,7 +53,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, propertiesMap); return acc; }, {}); @@ -53,7 +66,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, propertiesMap); if (!baseTheme || tokenResolutionPath.some((pathToken) => pathToken in theme.tokens)) { resolutionPaths[token] = tokenResolutionPath; @@ -65,14 +78,36 @@ 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, + propertiesMap?: PropertiesMap +): string { if (!theme.tokens[token] && !baseTheme?.tokens[token]) { throw new Error(`Token ${token} does not exist in the theme.`); } - if (path.indexOf(token) !== -1) { + if (path.includes(token)) { throw new Error(`Token ${token} has a circular dependency.`); } path.push(token); + + const assignment = getAssignment(theme, token, state, baseTheme); + + if (isReference(assignment)) { + const ref = getReference(assignment); + if (propertiesMap?.[ref] && (theme.tokens[ref] || baseTheme?.tokens[ref])) { + return `var(${propertiesMap[ref]})`; + } + return resolveToken(theme, ref, path, state, baseTheme, propertiesMap); + } + + return assignment; +} + +function getAssignment(theme: Theme, token: string, state: string | undefined, baseTheme?: Theme): string { let assignment = theme.tokens[token] || baseTheme?.tokens[token]; if (!assignment) { @@ -88,33 +123,81 @@ function resolveToken(theme: Theme, token: string, path: Array, state?: assignment = assignment[state]; } - if (isReference(assignment)) { - const ref = getReference(assignment); - return resolveToken(theme, ref, path, state, baseTheme); - } else { - return assignment; - } + return assignment; } export function resolveContext( theme: Theme, context: Context, baseTheme?: Theme, - themeResolution?: FullResolution + themeResolution?: FullResolution, + propertiesMap?: PropertiesMap ): FullResolution { const tmp = cloneDeep(theme); + if (context.defaultMode && theme.modes) { + resolveModeReferenceTokens(tmp, context, baseTheme); + } + if (!baseTheme || !themeResolution) { - tmp.tokens = { - ...tmp.tokens, - ...context.tokens, - }; - return resolveTheme(tmp, baseTheme); + tmp.tokens = { ...tmp.tokens, ...context.tokens }; + return resolveTheme(tmp, baseTheme, propertiesMap); } + tmp.tokens = applyContextPrecedenceRules(theme, context, baseTheme, themeResolution, propertiesMap); + return resolveTheme(tmp, baseTheme, propertiesMap); +} + +function resolveModeReferenceTokens(theme: Theme, context: Context, baseTheme?: Theme): void { + if (!context.defaultMode || !theme.modes) return; + + const defaultMode = context.defaultMode; + const mode = Object.values(theme.modes).find((m) => m.states[defaultMode]); + if (!mode) return; + + // Reference tokens must be resolved to their mode-specific values before path analysis + // because resolveThemeWithPaths expects concrete values, not mode objects. Without this, + // the resolution would fail when encountering reference tokens with mode values. + Object.keys(theme.tokens).forEach((token) => { + if (isReferenceToken('color', theme, token)) { + const tokenValue = theme.tokens[token]; + if (isModeValue(tokenValue)) { + theme.tokens[token] = tokenValue[defaultMode]; + } + } + }); + + // Merge theme tokens with context overrides to analyze full resolution paths + const mergedTheme = { ...theme, tokens: { ...theme.tokens, ...context.tokens } }; + const { resolutionPaths } = resolveThemeWithPaths(mergedTheme, baseTheme); + + // Add reference tokens to context + collectReferenceTokens(theme, resolutionPaths).forEach((token) => { + context.tokens[token] = theme.tokens[token]; + }); + + // Add parent tokens that depend on context-overridden tokens + const contextTokens = new Set(Object.keys(context.tokens)); + Object.keys(theme.tokens).forEach((token) => { + if (!contextTokens.has(token) && resolutionPaths[token]) { + const pathTokens = flattenResolutionPaths(resolutionPaths[token]); + if (pathTokens.some((pathToken) => contextTokens.has(pathToken))) { + context.tokens[token] = theme.tokens[token]; + } + } + }); +} + +function applyContextPrecedenceRules( + theme: Theme, + context: Context, + baseTheme: Theme, + themeResolution: FullResolution, + propertiesMap?: PropertiesMap +): Record { /** * The precedence of context tokens as specified by the API from highest to lowest is: - * [override theme context] > [base theme context] > [override theme] [base theme]. + * [override theme context] > [base theme context] > [override theme] > [base theme]. * * The precedence of tokens as defined in the generated CSS follows this order. * However, tokens that are declared in both the base theme and base theme @@ -126,26 +209,22 @@ export function resolveContext( * in the override theme with their respective values from the base theme context */ const baseContext = baseTheme.contexts[context.id]; - tmp.tokens = { - ...Object.keys(themeResolution).reduce((acc, key) => { - const shouldSkipReset = - (!(key in baseContext.tokens) && !(key in theme.tokens)) || - areAssignmentsEqual( - baseContext.tokens[key], - theme.tokens[key] ?? baseTheme.tokens[key] // resolved key may not be in override theme - ); - - return shouldSkipReset - ? acc - : { - ...acc, - [key]: baseContext.tokens[key] ?? theme.tokens[key] ?? baseTheme.tokens[key], - }; - }, {}), - ...context.tokens, - }; - return resolveTheme(tmp, baseTheme); + const baseResolution = resolveTheme(baseTheme, undefined, propertiesMap); + const overrideResolution = resolveTheme(theme, baseTheme, propertiesMap); + + const rebaselined = Object.keys(themeResolution).reduce((acc, key) => { + const shouldSkipReset = + (!(key in baseContext.tokens) && !(key in theme.tokens)) || + areAssignmentsEqual(baseResolution[key], overrideResolution[key]); + + if (!shouldSkipReset) { + acc[key] = baseContext.tokens[key] ?? theme.tokens[key] ?? baseTheme.tokens[key]; + } + return acc; + }, {} as Record); + + return { ...rebaselined, ...context.tokens }; } type Reducer = ( @@ -235,3 +314,24 @@ export function isSpecificTokenResolution( } const isEmpty = (obj: Record) => Object.keys(obj).length === 0; + +function flattenResolutionPaths(pathOrPaths: ModeTokenResolutionPath | SpecificTokenResolutionPath): string[] { + return typeof pathOrPaths === 'object' && !Array.isArray(pathOrPaths) + ? ([] as string[]).concat(...Object.values(pathOrPaths)) + : pathOrPaths; +} + +function collectReferenceTokens(theme: Theme, resolutionPaths: FullResolutionPaths): Set { + const referenceTokens = new Set(); + + Object.values(resolutionPaths).forEach((pathOrPaths) => { + const allPaths = flattenResolutionPaths(pathOrPaths); + allPaths.forEach((token: string) => { + if (isReferenceToken('color', theme, token)) { + referenceTokens.add(token); + } + }); + }); + + return referenceTokens; +} diff --git a/src/shared/theme/utils.ts b/src/shared/theme/utils.ts index 34e84ec..7940f9a 100644 --- a/src/shared/theme/utils.ts +++ b/src/shared/theme/utils.ts @@ -1,8 +1,55 @@ // 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 (isModeValue(value)) { + // Stop flattening at mode values - preserve them as Assignment + 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); } @@ -15,6 +62,9 @@ export function isModeValue(val: unknown): val is ModeValue { return ( typeof val === 'object' && val !== null && + !Array.isArray(val) && + // Exclude objects with numeric keys (palette steps like '500', '900') + !Object.keys(val).some((key) => !isNaN(Number(key))) && !Object.keys(val).some((state) => !(isValue((val as ModeValue)[state]) || isReference((val as ModeValue)[state]))) ); } @@ -37,6 +87,46 @@ export function getReference(reference: Reference): string { return reference.slice(1, reference.length - 1); } +export function collectReferencedTokens(theme: Theme, tokens: string[]): string[] { + const referenced = new Set(); + const visited = new Set(); + + const addReferences = (value: any) => { + if (isReference(value)) { + referenced.add(getReference(value)); + } else if (isModeValue(value)) { + Object.values(value).forEach(addReferences); + } + }; + + const processToken = (token: string) => { + if (visited.has(token)) return; + visited.add(token); + + const value = theme.tokens[token]; + if (value) addReferences(value); + + Object.values(theme.contexts).forEach((context) => { + const contextValue = context.tokens[token]; + if (contextValue) addReferences(contextValue); + }); + }; + + // Initial pass + tokens.forEach(processToken); + // Recursive passes until no new tokens found + let previousSize = 0; + let iterations = 0; + while (referenced.size > previousSize && iterations < 10) { + previousSize = referenced.size; + const newTokens = Array.from(referenced).filter((t) => !visited.has(t)); + newTokens.forEach(processToken); + iterations++; + } + + return Array.from(referenced); +} + export function getMode(theme: Theme, token: string): Mode | null { const modeId = theme.tokenModeMap[token]; return theme.modes[modeId] ?? null; @@ -53,3 +143,7 @@ export function getDefaultState(mode: Mode): string { } throw new Error(`Mode ${JSON.stringify(mode)} does not have a default state`); } + +export function isValidPaletteStep(step: number): boolean { + return step >= 50 && step <= 1000 && step % 50 === 0; +} 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, }; }