From 914477ab9dc46f44dae7378bcf4b083483e9fb9e Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Sat, 4 Jul 2026 07:32:26 +0200 Subject: [PATCH] feat: Adds inherits mode to visual contexts --- src/__fixtures__/inherits-mode.ts | 78 +++++++++ src/build/internal.ts | 2 +- src/build/tasks/preset.ts | 4 +- .../__integ__/layer-override.test.ts | 2 +- .../__snapshots__/inheritance.test.ts.snap | 81 +++++++++ .../declaration/__tests__/inheritance.test.ts | 160 ++++++++++++++++++ .../declaration/__tests__/transformer.test.ts | 65 +++++++ src/shared/declaration/abstract.ts | 69 +++++++- src/shared/declaration/interfaces.ts | 9 + src/shared/declaration/multi.ts | 111 ++++++------ src/shared/declaration/rule.ts | 3 +- src/shared/declaration/single.ts | 72 ++++---- src/shared/declaration/stylesheet.ts | 66 +++++++- src/shared/declaration/transformer.ts | 50 ++---- src/shared/theme/__tests__/resolve.test.ts | 78 ++++++++- src/shared/theme/interfaces.ts | 16 ++ src/shared/theme/merge.ts | 4 +- src/shared/theme/resolve.ts | 61 +++++-- src/shared/theme/utils.ts | 20 ++- 19 files changed, 800 insertions(+), 151 deletions(-) create mode 100644 src/__fixtures__/inherits-mode.ts create mode 100644 src/shared/declaration/__tests__/__snapshots__/inheritance.test.ts.snap create mode 100644 src/shared/declaration/__tests__/inheritance.test.ts diff --git a/src/__fixtures__/inherits-mode.ts b/src/__fixtures__/inherits-mode.ts new file mode 100644 index 0000000..3ecd650 --- /dev/null +++ b/src/__fixtures__/inherits-mode.ts @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Theme, Context } from '../shared/theme'; +import { colorMode, createStubPropertiesMap, densityMode } from './common'; + +// Fixtures for visual contexts inheritance via inheritsMode. +// These contexts inherit tokens from the specified non-default mode ("dark", "compact"). + +export { colorMode, densityMode }; + +export const topNavigationContext: Context = { + id: 'top-navigation', + selector: '.top-navigation', + inheritsMode: 'dark', + // Own override applied on top of the inherited dark values. + tokens: { bgColor: 'navy' }, +}; + +export const compactTableContext: Context = { + id: 'compact-table', + selector: '.compact-table', + inheritsMode: 'compact', + // No own overrides: pure inheritance of the compact density state. + tokens: {}, +}; + +export const theme: Theme = { + id: 'inheritance', + selector: 'body', + tokens: { + fontFamily: 'Arial', + textColor: { light: 'black', dark: 'white' }, + bgColor: { light: 'white', dark: 'black' }, + linkColor: { light: 'blue', dark: 'cyan' }, + spaceScaled: { comfortable: '20px', compact: '4px' }, + }, + tokenModeMap: { textColor: 'color', bgColor: 'color', linkColor: 'color', spaceScaled: 'density' }, + contexts: { 'top-navigation': topNavigationContext, 'compact-table': compactTableContext }, + modes: { color: colorMode, density: densityMode }, +}; + +export const secondaryTheme: Theme = { + ...theme, + id: 'inheritance-secondary', + selector: '.secondary', + // The secondary theme tweaks the dark link color. + tokens: { + ...theme.tokens, + linkColor: { light: 'green', dark: 'lime' }, + }, + contexts: { 'top-navigation': { ...topNavigationContext } }, +}; + +export const propertiesMap = createStubPropertiesMap(theme); + +// Visual context that uses legacy defaultMode property. This must not trigger selectors +// dedup, applied when inheritsMode is used. +export const legacyContext: Context = { + id: 'legacy-top-navigation', + selector: '.legacy-top-navigation', + defaultMode: 'dark', + tokens: { + // Dark values copied into the context (mode-invariant), mirroring how + // components author `defaultMode` contexts via pickState('dark'). + textColor: { light: 'white', dark: 'white' }, + bgColor: { light: 'navy', dark: 'navy' }, + linkColor: { light: 'cyan', dark: 'cyan' }, + }, +}; + +export const legacyTheme: Theme = { + ...theme, + id: 'legacy', + contexts: { + 'legacy-top-navigation': legacyContext, + }, +}; diff --git a/src/build/internal.ts b/src/build/internal.ts index ae12520..6f7aa2c 100644 --- a/src/build/internal.ts +++ b/src/build/internal.ts @@ -80,7 +80,7 @@ export async function buildThemedComponentsInternal(params: BuildThemedComponent const neededTokens = findNeededTokens(scssDir, variablesMap, exposed); const resolution = resolveTheme(primary); - const defaults = reduce(resolution, primary, defaultsReducer()); + const defaults = reduce(resolution, primary, defaultsReducer(null)); const propertiesMap = calculatePropertiesMap([primary, ...secondary], variablesMap); const styleTask = buildStyles( diff --git a/src/build/tasks/preset.ts b/src/build/tasks/preset.ts index bcd8354..0b10071 100644 --- a/src/build/tasks/preset.ts +++ b/src/build/tasks/preset.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { join } from 'path'; import { ThemePreset } from '../../shared/theme'; -import { getMode } from '../../shared/theme/utils'; +import { getThemeMode } from '../../shared/theme/utils'; import { getContexts } from '../../shared/theme/validate'; import { writeFile } from '../file'; @@ -44,7 +44,7 @@ export declare const preset: ThemePreset; function renderTypedOverrideInterface(preset: ThemePreset): string { const tokens = preset.themeable.map((token) => { - const mode = getMode(preset.theme, token); + const mode = getThemeMode(preset.theme, token); if (mode) { const states = Object.keys(mode.states); return `${token}?: GlobalValue | TypedModeValueOverride<${states.map((state) => `'${state}'`).join(' | ')}>`; diff --git a/src/shared/declaration/__integ__/layer-override.test.ts b/src/shared/declaration/__integ__/layer-override.test.ts index e84c9ad..7d2036f 100644 --- a/src/shared/declaration/__integ__/layer-override.test.ts +++ b/src/shared/declaration/__integ__/layer-override.test.ts @@ -23,7 +23,7 @@ import { preset as inputPreset } from '../../../build/__tests__/__fixtures__/tem const propertiesMap = calculatePropertiesMap([inputPreset.theme], inputPreset.variablesMap); const preset: ThemePreset = { ...inputPreset, propertiesMap }; -const resolution = reduce(resolveTheme(preset.theme), preset.theme, defaultsReducer()); +const resolution = reduce(resolveTheme(preset.theme), preset.theme, defaultsReducer(null)); const allTokens = Object.keys(preset.theme.tokens); // getInlineStylesheets produces CSS with :global() wrappers (CSS modules syntax). diff --git a/src/shared/declaration/__tests__/__snapshots__/inheritance.test.ts.snap b/src/shared/declaration/__tests__/__snapshots__/inheritance.test.ts.snap new file mode 100644 index 0000000..84638fc --- /dev/null +++ b/src/shared/declaration/__tests__/__snapshots__/inheritance.test.ts.snap @@ -0,0 +1,81 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`visual context inheritance > legacy theme output snapshot (no dedup) 1`] = ` +"@layer awsui-base-theme { +body{ + --fontFamily-css:Arial; + --textColor-css:black; + --bgColor-css:white; + --linkColor-css:blue; + --spaceScaled-css:20px; +} +@media not print {.dark{ + --textColor-css:white; + --bgColor-css:black; + --linkColor-css:cyan; +}} +.compact{ + --spaceScaled-css:4px; +} +.legacy-top-navigation{ + --textColor-css:white; + --bgColor-css:navy; + --linkColor-css:cyan; +} +}" +`; + +exports[`visual context inheritance > multi theme output snapshot 1`] = ` +"@layer awsui-base-theme { +body{ + --fontFamily-css:Arial; + --textColor-css:black; + --bgColor-css:white; + --linkColor-css:blue; + --spaceScaled-css:20px; +} +@media not print {.dark,.top-navigation{ + --textColor-css:white; + --bgColor-css:black; + --linkColor-css:cyan; +}} +.compact,.compact-table{ + --spaceScaled-css:4px; +} +@media not print {.top-navigation{ + --bgColor-css:navy; +}} +.secondary{ + --linkColor-css:green; +} +@media not print {.dark.secondary,.secondary .top-navigation,.secondary.top-navigation{ + --linkColor-css:lime; +}} +@media not print {.secondary .top-navigation,.secondary.top-navigation{ + --bgColor-css:navy; +}} +}" +`; + +exports[`visual context inheritance > single theme output snapshot 1`] = ` +"@layer awsui-base-theme { +body{ + --fontFamily-css:Arial; + --textColor-css:black; + --bgColor-css:white; + --linkColor-css:blue; + --spaceScaled-css:20px; +} +@media not print {.dark,.top-navigation{ + --textColor-css:white; + --bgColor-css:black; + --linkColor-css:cyan; +}} +.compact,.compact-table{ + --spaceScaled-css:4px; +} +@media not print {.top-navigation{ + --bgColor-css:navy; +}} +}" +`; diff --git a/src/shared/declaration/__tests__/inheritance.test.ts b/src/shared/declaration/__tests__/inheritance.test.ts new file mode 100644 index 0000000..0be9367 --- /dev/null +++ b/src/shared/declaration/__tests__/inheritance.test.ts @@ -0,0 +1,160 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, test, expect } from 'vitest'; +import * as fixtures from '../../../__fixtures__/inherits-mode'; +import { createBuildDeclarations } from '../index'; +import { Theme } from '../../theme'; + +const usedTokens = Object.keys(fixtures.theme.tokens); + +function render(theme: Theme, secondary: Theme[] = []): string { + return createBuildDeclarations(theme, secondary, fixtures.propertiesMap, (s) => s, usedTokens); +} + +function renderLegacy(): string { + return createBuildDeclarations(fixtures.legacyTheme, [], fixtures.propertiesMap, (s) => s, usedTokens); +} + +/** + * Extracts the body of the rule whose selector is exactly `selector`. The + * selector must be a full selector (preceded by a rule/block boundary), so that + * looking up `.top-navigation` does not accidentally match the shared + * `.dark,.top-navigation` rule. + */ +function matchExactRule(css: string, selector: string): null | string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(`(?:^|[\\n{}])${escaped}\\{([^}]*)\\}`)); + return match ? match[1].trim() : null; +} + +describe('visual context inheritance', () => { + test('single theme output snapshot', () => { + expect(render(fixtures.theme, [])).toMatchSnapshot(); + }); + + test('multi theme output snapshot', () => { + expect(render(fixtures.theme, [fixtures.secondaryTheme])).toMatchSnapshot(); + }); + + test('legacy theme output snapshot (no dedup)', () => { + expect(renderLegacy()).toMatchSnapshot(); + }); + + test('top-navigation context inherits from dark mode via selector aliases', () => { + const css = render(fixtures.theme); + expect(css).toMatch(/\.dark,\.top-navigation/); + }); + + test(`shared dark + top-navigation rule stays within mode's media rule`, () => { + const css = render(fixtures.theme); + expect(css).toMatch(/@media not print \{\.dark,\.top-navigation\{/); + }); + + test('top-navigation override rule only includes unique tokens', () => { + const css = render(fixtures.theme); + + // bgColor is the only token top-navigation overrides relative to dark. + const standalone = matchExactRule(css, '.top-navigation'); + expect(standalone).not.toBeNull(); + expect(standalone).toBe('--bgColor-css:navy;'); + + // The override rule stays inside the inherited mode's media rule. + expect(css).toMatch(/@media not print \{\.top-navigation\{/); + + // The standalone rule comes after the shared rule, so it wins the cascade. + expect(Array.from(css.matchAll(/\.top-navigation/g))).toHaveLength(2); + expect(css.indexOf('{.dark,.top-navigation')).toBeLessThan(css.indexOf('{.top-navigation')); + }); + + test('shared compact rule is media-free (compact has no media query)', () => { + const css = render(fixtures.theme); + const shared = matchExactRule(css, '.compact,.compact-table'); + expect(shared).not.toBeNull(); + expect(shared).toContain('--spaceScaled-css:4px;'); + // Not wrapped in any media query. + expect(Array.from(css.matchAll(/\.compact-table/g))).toHaveLength(1); + expect(css).toMatch(/(^|\n)\.compact,\.compact-table\{/); + }); + + test('compact override rule is not emitted (it has no unique tokens)', () => { + const css = render(fixtures.theme); + expect(css).not.toMatch(/(^|\n|\})\.compact-table\{/); + }); + + test('non-inherited orthogonal combinations are not created', () => { + const css = render(fixtures.theme); + expect(css).not.toContain('.dark,.compact-table'); + expect(css).not.toContain('.compact,.top-navigation'); + }); + + test('secondary dark mode rule is shared with the secondary context selector', () => { + const css = render(fixtures.theme, [fixtures.secondaryTheme]); + expect(css).toMatch(/\.dark\.secondary[^{]*\.top-navigation/); + }); + + test('the secondary inheriting context retains the token instead of leaking the primary value', () => { + const primary: Theme = { + id: 'primary', + selector: 'body', + tokens: { + paletteLight: 'plight', + paletteDark: 'pdark', + btnBg: { light: '{paletteLight}', dark: '{paletteDark}' }, + }, + tokenModeMap: { btnBg: 'color' }, + contexts: { ctx: { id: 'ctx', selector: '.ctx', inheritsMode: 'dark', tokens: {} } }, + modes: { color: fixtures.colorMode }, + }; + const secondary: Theme = { + id: 'secondary', + selector: '.secondary', + tokens: { + paletteLight: 'plight', + paletteDark: 'pdark', + borderColor: { light: '{paletteLight}', dark: '{paletteDark}' }, + // Mode-invariant reference in the secondary theme: the emitted var string + // is identical in light and dark, so it would be deduplicated away. + btnBg: '{borderColor}', + }, + tokenModeMap: { borderColor: 'color', btnBg: 'color' }, + contexts: { ctx: { id: 'ctx', selector: '.ctx', inheritsMode: 'dark', tokens: {} } }, + modes: { color: fixtures.colorMode }, + }; + const usedTokens = ['paletteLight', 'paletteDark', 'borderColor', 'btnBg']; + const propertiesMap = usedTokens.reduce((acc, t) => ({ ...acc, [t]: `--${t}-css` }), {}); + const css = createBuildDeclarations(primary, [secondary], propertiesMap, (s) => s, usedTokens); + + // Primary .ctx carries the --btnBg-css with the primary dark value. + expect(matchExactRule(css, '.dark,.ctx')).toBe('--btnBg-css:var(--paletteDark-css);'); + // Secondary .ctx re-declares --btnBg-css with the secondary value so it wins over the primary. + expect(matchExactRule(css, '.secondary .ctx,.ctx.secondary')).toBe('--btnBg-css:var(--borderColor-css);'); + }); + + test('inheritance is ignored if inherited mode state does not exist or is default', () => { + const theme: Theme = { + id: 'theme', + selector: 'body', + tokens: { + btnBg: { light: 'black', dark: 'white' }, + btnFg: 'yellow', + }, + tokenModeMap: { btnBg: 'color', btnFg: 'color' }, + contexts: { + u: { id: 'unknown', selector: '.unknown', inheritsMode: '?', tokens: { btnFg: 'green' } }, + default: { id: 'default', selector: '.default', inheritsMode: 'light', tokens: { btnFg: 'purple' } }, + }, + modes: { color: fixtures.colorMode }, + }; + const usedTokens = ['btnBg', 'btnFg']; + const propertiesMap = usedTokens.reduce((acc, t) => ({ ...acc, [t]: `--${t}-css` }), {}); + const css = createBuildDeclarations(theme, [], propertiesMap, (s) => s, usedTokens); + + expect(matchExactRule(css, 'body')).toBe('--btnBg-css:black;\n\t--btnFg-css:yellow;'); + expect(matchExactRule(css, '.dark')).toBe('--btnBg-css:white;'); + expect(matchExactRule(css, '.unknown')).toBe('--btnFg-css:green;'); + expect(matchExactRule(css, '.default')).toBe('--btnFg-css:purple;'); + expect(Array.from(css.matchAll(/\.unknown/g))).toHaveLength(1); + expect(Array.from(css.matchAll(/\.default/g))).toHaveLength(1); + }); +}); diff --git a/src/shared/declaration/__tests__/transformer.test.ts b/src/shared/declaration/__tests__/transformer.test.ts index 1e6aee0..a47104d 100644 --- a/src/shared/declaration/__tests__/transformer.test.ts +++ b/src/shared/declaration/__tests__/transformer.test.ts @@ -145,4 +145,69 @@ describe('MinimalTransformer', () => { expect(result.findRule('.a')).toBeDefined(); expect(result.findRule('.b')).toBeDefined(); }); + + describe('inheriting context rules', () => { + test('a media-bound context rule keeps only its delta vs the inherited mode parent', () => { + const stylesheet = new Stylesheet(); + const rootRule = new Rule(':root'); + rootRule.appendDeclaration(new Declaration('--text', 'black')); + rootRule.appendDeclaration(new Declaration('--bg', 'white')); + stylesheet.appendRuleWithPath(rootRule, []); + + // Inherited mode rule (e.g. dark), media-bound. + const modeRule = new Rule('.dark', 'not print'); + modeRule.appendDeclaration(new Declaration('--text', 'white')); + modeRule.appendDeclaration(new Declaration('--bg', 'black')); + stylesheet.appendRuleWithPath(modeRule, [rootRule]); + + // Inheriting context rule: same media, parent path includes the mode rule. + // It carries the full inherited values plus its own override (--bg:navy). + const contextRule = new Rule('.ctx', 'not print', true); + contextRule.appendDeclaration(new Declaration('--text', 'white')); + contextRule.appendDeclaration(new Declaration('--bg', 'navy')); + stylesheet.appendRuleWithPath(contextRule, [modeRule, rootRule]); + + new MinimalTransformer().transform(stylesheet); + + // Only the delta vs the inherited mode remains. + expect(contextRule.printAllDeclarations()).toContain('--bg:navy'); + expect(contextRule.printAllDeclarations()).not.toContain('--text'); + }); + + test('appends the registered context selector as a comma alias on the mode rule', () => { + const stylesheet = new Stylesheet(); + const rootRule = new Rule(':root'); + rootRule.appendDeclaration(new Declaration('--text', 'black')); + stylesheet.appendRuleWithPath(rootRule, []); + + const modeRule = new Rule('.dark', 'not print'); + modeRule.appendDeclaration(new Declaration('--text', 'white')); + stylesheet.appendRuleWithPath(modeRule, [rootRule]); + + stylesheet.registerInheritedAlias('.ctx', modeRule); + + new MinimalTransformer().transform(stylesheet); + + expect(modeRule.selector).toBe('.dark,.ctx'); + }); + + test('skips the alias when the inherited mode rule was removed (no shared tokens)', () => { + const stylesheet = new Stylesheet(); + const rootRule = new Rule(':root'); + rootRule.appendDeclaration(new Declaration('--text', 'black')); + stylesheet.appendRuleWithPath(rootRule, []); + + // Mode rule identical to root -> emptied and removed by the transformer. + const modeRule = new Rule('.dark', 'not print'); + modeRule.appendDeclaration(new Declaration('--text', 'black')); + stylesheet.appendRuleWithPath(modeRule, [rootRule]); + + stylesheet.registerInheritedAlias('.ctx', modeRule); + + const result = new MinimalTransformer().transform(stylesheet); + + expect(result.findRule('.dark')).toBeUndefined(); + expect(result.findRule('.dark,.ctx')).toBeUndefined(); + }); + }); }); diff --git a/src/shared/declaration/abstract.ts b/src/shared/declaration/abstract.ts index 0448ede..fe4b24a 100644 --- a/src/shared/declaration/abstract.ts +++ b/src/shared/declaration/abstract.ts @@ -1,15 +1,25 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import type { Mode, Context, Theme } from '../theme'; -import { isOptionalState } from '../theme/utils'; +import { getThemeModeByState, isOptionalState } from '../theme/utils'; import { entries } from '../utils'; import type Stylesheet from './stylesheet'; import type { Rule } from './stylesheet'; +import type { RuleCreator, SelectorConfig } from './rule'; +import { InheritedModeState, PropertiesMap } from './interfaces'; /** * Common used helpers by stylesheet creator */ export abstract class AbstractCreator { + ruleCreator: RuleCreator; + propertiesMap?: PropertiesMap; + + constructor(ruleCreator: RuleCreator, propertiesMap?: PropertiesMap) { + this.ruleCreator = ruleCreator; + this.propertiesMap = propertiesMap; + } + static forEachOptionalModeState(theme: Theme, func: (mode: Mode, stateKey: string) => void) { Object.keys(theme.modes).forEach((key) => { const mode = theme.modes[key]; @@ -38,9 +48,66 @@ export abstract class AbstractCreator { }); }); } + static appendRuleToStylesheet(stylesheet: Stylesheet, rule: Rule, path: Rule[]) { if (rule.size()) { stylesheet.appendRuleWithPath(rule, path); } } + + /** + * Finds stylesheet rule by selector or selector config. + */ + findRule(stylesheet: Stylesheet, config: string | SelectorConfig): undefined | Rule { + return stylesheet.findRule(typeof config === 'string' ? config : this.ruleCreator.selectorFor(config)); + } + + /** + * Finds stylesheet rule or throws. + * + * Rules are creating in reverse order to dependency, which is why we should always have a rule. + */ + findExpectedRule(stylesheet: Stylesheet, config: string | SelectorConfig): Rule { + const rule = this.findRule(stylesheet, config); + if (!rule) { + throw new Error(`No rule for selector ${JSON.stringify(config)} found`); + } + return rule; + } + + /** + * Resolves the optional mode state a context inherits from (e.g. the "dark" mode) to deduplicate output. + */ + findInheritedModeState(theme: Theme, { inheritsMode }: Context): null | InheritedModeState { + if (!inheritsMode) { + return null; + } + const mode = getThemeModeByState(theme, inheritsMode); + if (!mode) { + return null; + } + const state = mode.states[inheritsMode]; + if (!isOptionalState(state)) { + return null; + } + const media = state.media; + const selector = this.ruleCreator.selectorFor({ global: [theme.selector, state.selector] }); + return { state: inheritsMode, mode, media, selector }; + } + + /** + * Resolves the optional mode state of the parent theme context. + */ + findParentInheritedModeState(theme: Theme, contextId: string) { + const parentContext = theme.contexts[contextId]; + return parentContext ? this.findInheritedModeState(theme, parentContext) : null; + } + + /** + * Appends context selector(s) to the inherited mode selector (as comma aliases). + */ + registerInheritedContextAliases(stylesheet: Stylesheet, inheritedModeRule: Rule, configs: SelectorConfig[]) { + const aliasSelectors = Array.from(new Set(configs.map((config) => this.ruleCreator.selectorFor(config)))); + aliasSelectors.forEach((aliasSelector) => stylesheet.registerInheritedAlias(aliasSelector, inheritedModeRule)); + } } diff --git a/src/shared/declaration/interfaces.ts b/src/shared/declaration/interfaces.ts index eb6e4d0..3df03c7 100644 --- a/src/shared/declaration/interfaces.ts +++ b/src/shared/declaration/interfaces.ts @@ -1,5 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + +import type { Mode } from '../theme'; import type Stylesheet from './stylesheet'; export type PropertiesMap = Record; @@ -10,3 +12,10 @@ interface Creator { } export type StylesheetCreator = Creator; + +export interface InheritedModeState { + mode: Mode; + state: string; + selector: string; + media?: string; +} diff --git a/src/shared/declaration/multi.ts b/src/shared/declaration/multi.ts index 5195ab7..3db1a34 100644 --- a/src/shared/declaration/multi.ts +++ b/src/shared/declaration/multi.ts @@ -7,7 +7,7 @@ import { AbstractCreator } from './abstract'; import type { StylesheetCreator } from './interfaces'; import { RuleCreator, SelectorConfig } from './rule'; import { SingleThemeCreator } from './single'; -import Stylesheet, { Rule } from './stylesheet'; +import Stylesheet from './stylesheet'; import { compact } from './utils'; /** @@ -16,14 +16,10 @@ import { compact } from './utils'; */ export class MultiThemeCreator extends AbstractCreator implements StylesheetCreator { themes: Theme[]; - ruleCreator: RuleCreator; - propertiesMap?: PropertiesMap; constructor(themes: Theme[], ruleCreator: RuleCreator, propertiesMap?: PropertiesMap) { - super(); + super(ruleCreator, propertiesMap); this.themes = themes; - this.ruleCreator = ruleCreator; - this.propertiesMap = propertiesMap; } create(): Stylesheet { @@ -64,10 +60,10 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea appendRulesForSecondary(stylesheet: Stylesheet, primary: Theme, secondary: Theme) { const secondaryResolution = resolveTheme(secondary, undefined, this.propertiesMap); - const defaults = reduce(secondaryResolution, secondary, defaultsReducer()); + const defaults = reduce(secondaryResolution, secondary, defaultsReducer(null)); const rootRule = this.ruleCreator.create({ global: [secondary.selector] }, defaults); - const parentRule = this.findRule(stylesheet, { global: [primary.selector] }); + const parentRule = this.findExpectedRule(stylesheet, { global: [primary.selector] }); MultiThemeCreator.appendRuleToStylesheet(stylesheet, rootRule, compact([parentRule])); MultiThemeCreator.forEachOptionalModeState(secondary, (mode, state) => { @@ -77,57 +73,76 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea { global: [secondary.selector, optionalState.selector], media: optionalState.media }, modeResolution, ); - const parentModeRule = stylesheet.findRule( - this.ruleCreator.selectorFor({ - global: [primary.selector, optionalState.selector], - }), - ); + const parentModeRule = this.findRule(stylesheet, { global: [primary.selector, optionalState.selector] }); MultiThemeCreator.appendRuleToStylesheet(stylesheet, modeRule, compact([rootRule, parentModeRule, parentRule])); }); MultiThemeCreator.forEachContext(secondary, (context) => { + const inheritedMode = this.findInheritedModeState(secondary, context); + const contextResolution = reduce( resolveContext(secondary, context, undefined, undefined, this.propertiesMap), secondary, - defaultsReducer(), - ); - const contextRule = this.ruleCreator.create( - { global: [secondary.selector], local: [context.selector] }, - contextResolution, - ); - const parentContextRule = stylesheet.findRule( - this.ruleCreator.selectorFor({ - global: [primary.selector], - local: [context.selector], - }), + defaultsReducer(inheritedMode), ); + + // The mode rule (e.g. the `.dark` rule) is the diff parent for the context that inherits it. + const inheritedModeRule = inheritedMode ? stylesheet.findRule(inheritedMode.selector) : undefined; + + // If primary theme context uses mode inheritance - this can unintentionally override secondary themes. + // To prevent that, we include primary inherited mode rule into the diff order. + const primaryInheritedMode = this.findParentInheritedModeState(primary, context.id); + const primaryInheritedModeRule = primaryInheritedMode + ? stylesheet.findRule(primaryInheritedMode.selector) + : undefined; + + // By default, visual contexts have no media, but they can inherit media from the mode. + const shared = { media: inheritedMode?.media, isContext: true }; + + const parentContextRule = this.findRule(stylesheet, { global: [primary.selector], local: [context.selector] }); + const descendantConfig: SelectorConfig = { global: [secondary.selector], local: [context.selector], ...shared }; + const contextRule = this.ruleCreator.create(descendantConfig, contextResolution); MultiThemeCreator.appendRuleToStylesheet( stylesheet, contextRule, - compact([parentContextRule, rootRule, parentRule]), + compact([inheritedModeRule, primaryInheritedModeRule, parentContextRule, rootRule, parentRule]), ); - const contextRuleGlobal = this.ruleCreator.create( - { global: [secondary.selector, context.selector] }, - contextResolution, - ); + const sameElementConfig: SelectorConfig = { global: [secondary.selector, context.selector], ...shared }; + const contextRuleGlobal = this.ruleCreator.create(sameElementConfig, contextResolution); MultiThemeCreator.appendRuleToStylesheet( stylesheet, contextRuleGlobal, - compact([rootRule, parentContextRule, parentRule]), + compact([inheritedModeRule, primaryInheritedModeRule, rootRule, parentContextRule, parentRule]), ); + + // Make the inherited mode values apply to the context by appending the context selector(s) + // as comma aliases onto the inherited mode rule (e.g. `.dark, .top-navigation { ... }`). + if (inheritedModeRule) { + const configs = [descendantConfig, sameElementConfig]; + this.registerInheritedContextAliases(stylesheet, inheritedModeRule, configs); + } }); MultiThemeCreator.forEachContextWithinOptionalModeState(secondary, (context, mode, state) => { + // We skip resolution for context/state pairs that are already handled via mode inheritance. + const inherited = this.findInheritedModeState(secondary, context); + if (inherited && inherited.mode.id === mode.id && inherited.state === state) { + return; + } + const optionalState = mode.states[state] as OptionalState; 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 contextRuleGlobal = this.findRule(stylesheet, { global: [secondary.selector, context.selector] }); - const modeRule = this.findRule(stylesheet, { + const contextRule = this.findExpectedRule(stylesheet, { + global: [secondary.selector], + local: [context.selector], + }); + const contextRuleGlobal = this.findExpectedRule(stylesheet, { global: [secondary.selector, context.selector] }); + const modeRule = this.findExpectedRule(stylesheet, { global: [secondary.selector, optionalState.selector], }); const contextAndModeRule = this.ruleCreator.create( @@ -138,21 +153,13 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea }, contextResolution, ); - const parentContextRule = stylesheet.findRule( - this.ruleCreator.selectorFor({ global: [primary.selector], local: [context.selector] }), - ); + const parentContextRule = this.findRule(stylesheet, { global: [primary.selector], local: [context.selector] }); - const parentModeRule = stylesheet.findRule( - this.ruleCreator.selectorFor({ - global: [primary.selector, optionalState.selector], - }), - ); - const parentContextAndModeRule = stylesheet.findRule( - this.ruleCreator.selectorFor({ - global: [primary.selector, optionalState.selector], - local: [context.selector], - }), - ); + const parentModeRule = this.findRule(stylesheet, { global: [primary.selector, optionalState.selector] }); + const parentContextAndModeRule = this.findRule(stylesheet, { + global: [primary.selector, optionalState.selector], + local: [context.selector], + }); MultiThemeCreator.appendRuleToStylesheet( stylesheet, @@ -194,16 +201,6 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea return stylesheet; } - private findRule(stylesheet: Stylesheet, config: SelectorConfig): Rule { - const rule = stylesheet.findRule(this.ruleCreator.selectorFor(config)); - if (!rule) { - // Rules are creating in reverse order to dependency, which is why - // we should always have a rule. - throw new Error(`No rule for selector ${JSON.stringify(config)} found`); - } - return rule; - } - private getThemesWithout(theme: Theme) { const idx = this.themes.indexOf(theme); return [...this.themes.slice(0, idx), ...this.themes.slice(idx + 1)]; diff --git a/src/shared/declaration/rule.ts b/src/shared/declaration/rule.ts index feaa1be..be23a04 100644 --- a/src/shared/declaration/rule.ts +++ b/src/shared/declaration/rule.ts @@ -10,6 +10,7 @@ export interface SelectorConfig { global: string[]; local?: string[]; media?: string; + isContext?: boolean; } export class RuleCreator { selector: Selector; @@ -21,7 +22,7 @@ export class RuleCreator { } create(config: SelectorConfig, resolution: SpecificResolution): Rule { - const rule = new Rule(this.selectorFor(config), config.media); + const rule = new Rule(this.selectorFor(config), config.media, config.isContext); entries(resolution).forEach(([token, value]) => { const property = this.registry.get(token); if (property) { diff --git a/src/shared/declaration/single.ts b/src/shared/declaration/single.ts index 9a0dd03..b68de60 100644 --- a/src/shared/declaration/single.ts +++ b/src/shared/declaration/single.ts @@ -14,29 +14,25 @@ import type { PropertiesMap } from './interfaces'; import Stylesheet from './stylesheet'; import { AbstractCreator } from './abstract'; import type { StylesheetCreator } from './interfaces'; -import type { RuleCreator } from './rule'; +import type { RuleCreator, SelectorConfig } from './rule'; import { compact } from './utils'; export class SingleThemeCreator extends AbstractCreator implements StylesheetCreator { theme: Theme; baseTheme?: Theme; resolution: FullResolution; - ruleCreator: RuleCreator; - propertiesMap?: PropertiesMap; constructor(theme: Theme, ruleCreator: RuleCreator, baseTheme?: Theme, propertiesMap?: PropertiesMap) { - super(); + super(ruleCreator, propertiesMap); this.theme = theme; this.baseTheme = baseTheme; - this.propertiesMap = propertiesMap; this.resolution = resolveTheme(theme, this.baseTheme, propertiesMap); - this.ruleCreator = ruleCreator; } create(): Stylesheet { const stylesheet = new Stylesheet(); - const defaults = reduce(this.resolution, this.theme, defaultsReducer(), this.baseTheme); + const defaults = reduce(this.resolution, this.theme, defaultsReducer(null), this.baseTheme); const rootRule = this.ruleCreator.create({ global: [this.theme.selector] }, defaults); SingleThemeCreator.appendRuleToStylesheet(stylesheet, rootRule, []); @@ -52,26 +48,50 @@ export class SingleThemeCreator extends AbstractCreator implements StylesheetCre }); SingleThemeCreator.forEachContext(this.theme, (context) => { + const inheritedMode = this.findInheritedModeState(this.theme, context); + const contextResolution = reduce( resolveContext(this.theme, context, this.baseTheme, this.resolution, this.propertiesMap), this.theme, - defaultsReducer(), + defaultsReducer(inheritedMode), this.baseTheme, ); - const contextRule = this.ruleCreator.create( - { global: [this.theme.selector], local: [context.selector] }, - contextResolution, - ); - SingleThemeCreator.appendRuleToStylesheet(stylesheet, contextRule, [rootRule]); - const contextRule2 = this.ruleCreator.create( - { global: [this.theme.selector, context.selector] }, - contextResolution, - ); - SingleThemeCreator.appendRuleToStylesheet(stylesheet, contextRule2, [rootRule]); + // The mode rule (e.g. the `.dark` rule) is the diff parent for the context that inherits it. + const inheritedModeRule = inheritedMode ? stylesheet.findRule(inheritedMode.selector) : undefined; + const parentPath = inheritedModeRule ? [inheritedModeRule, rootRule] : [rootRule]; + + // By default, visual contexts have no media, but they can inherit media from the mode. + const shared = { media: inheritedMode?.media, isContext: true }; + + // Context rules are emitted in two equivalent forms so they match whether + // the theme selector sits on an ancestor (`descendant`) or on the context + // element itself (`same-element`). For a global theme (body/:root/html) both + // collapse to the same selector and the duplicate is dropped downstream. + + const descendantConfig: SelectorConfig = { global: [this.theme.selector], local: [context.selector], ...shared }; + const contextRule = this.ruleCreator.create(descendantConfig, contextResolution); + SingleThemeCreator.appendRuleToStylesheet(stylesheet, contextRule, parentPath); + + const sameElementConfig: SelectorConfig = { global: [this.theme.selector, context.selector], ...shared }; + const contextRule2 = this.ruleCreator.create(sameElementConfig, contextResolution); + SingleThemeCreator.appendRuleToStylesheet(stylesheet, contextRule2, parentPath); + + // Make the inherited mode values apply to the context by appending the context selector(s) + // as comma aliases onto the inherited mode rule (e.g. `.dark, .top-navigation { ... }`). + if (inheritedModeRule) { + const configs = [descendantConfig, sameElementConfig]; + this.registerInheritedContextAliases(stylesheet, inheritedModeRule, configs); + } }); SingleThemeCreator.forEachContextWithinOptionalModeState(this.theme, (context, mode, state) => { + // We skip resolution for context/state pairs that are already handled via mode inheritance. + const inherited = this.findInheritedModeState(this.theme, context); + if (inherited && inherited.mode.id === mode.id && inherited.state === state) { + return; + } + const contextResolution = reduce( resolveContext(this.theme, context, this.baseTheme, this.resolution, this.propertiesMap), this.theme, @@ -83,17 +103,11 @@ export class SingleThemeCreator extends AbstractCreator implements StylesheetCre { global: [this.theme.selector, stateDetails.selector], local: [context.selector], media: stateDetails.media }, contextResolution, ); - const contextRule = stylesheet.findRule( - this.ruleCreator.selectorFor({ global: [this.theme.selector], local: [context.selector] }), - ); - const contextRuleGlobal = stylesheet.findRule( - this.ruleCreator.selectorFor({ global: [this.theme.selector, context.selector] }), - ); - const modeRule = stylesheet.findRule( - this.ruleCreator.selectorFor({ - global: [this.theme.selector, (mode.states[state] as OptionalState).selector], - }), - ); + const contextRule = this.findRule(stylesheet, { global: [this.theme.selector], local: [context.selector] }); + const contextRuleGlobal = this.findRule(stylesheet, { global: [this.theme.selector, context.selector] }); + const modeRule = this.findRule(stylesheet, { + global: [this.theme.selector, (mode.states[state] as OptionalState).selector], + }); SingleThemeCreator.appendRuleToStylesheet( stylesheet, diff --git a/src/shared/declaration/stylesheet.ts b/src/shared/declaration/stylesheet.ts index 4d86d42..1b53ae5 100644 --- a/src/shared/declaration/stylesheet.ts +++ b/src/shared/declaration/stylesheet.ts @@ -9,6 +9,7 @@ export default class Stylesheet { rulesMap: Map = new Map(); paths: Map = new Map(); counter = 0; + inheritedAliases: Array<{ aliasSelector: string; rule: Rule }> = []; appendRule(rule: Rule) { this.rulesMap.set(rule.selector, [rule, this.counter++]); @@ -51,6 +52,58 @@ export default class Stylesheet { return rules; } + /** + * Records that an inheriting context's selector should be appended (as a comma alias) + * onto an inherited mode state's rule. Applied as a final step by the transformer, + * after all rule lookups are done, so it never interferes with `findRule`. + */ + registerInheritedAlias(aliasSelector: string, rule: Rule) { + this.inheritedAliases.push({ aliasSelector, rule }); + } + + /** + * Appends each inheriting context's selector as a comma alias onto its inherited + * mode state's rule. This makes the inherited mode values apply to the context + * (e.g. `.dark-mode, .top-navigation { ... }`) without duplicating them, while + * the context's standalone rule keeps only the tokens that differ from the mode. + */ + applyInheritedAliases() { + const present = new Set(this.getAllRules()); + this.inheritedAliases.forEach(({ aliasSelector, rule }) => { + if (!present.has(rule)) { + return; + } + rule.selector = `${rule.selector},${aliasSelector}`; + }); + } + + /** + * Merges adjacent rules with identical declarations into a single comma-separated selector. + * + * For example: + * .a { --color-background: red; } + * .b { --color-background: red; } + * + * becomes: + * .a, + * .b { --color-background: red; } + */ + mergeSelectors() { + const rules = this.getAllRules(); + let i = 1; + while (i < rules.length) { + const prev = rules[i - 1]; + const curr = rules[i]; + if (prev.media === curr.media && prev.printAllDeclarations() === curr.printAllDeclarations()) { + prev.selector = `${prev.selector},${curr.selector}`; + this.removeRule(curr); + rules.splice(i, 1); + } else { + i++; + } + } + } + /** * @returns CSS */ @@ -63,12 +116,19 @@ export default class Stylesheet { export class Rule { selector: string; media?: string; + /** + * Whether this rule targets a visual context (a local/context selector). Used by the transformer to decide + * how referenced custom properties should be re-emitted: context rules always re-output overridden references + * (so they resolve in the context's DOM scope), whereas plain mode rules may rely on the natural cascade. + */ + isContext: boolean; declarationsMap: Map = new Map(); counter = 0; - constructor(selector: string, media?: string) { + constructor(selector: string, media?: string, isContext = false) { this.selector = selector; this.media = media; + this.isContext = isContext; } appendDeclaration(declaration: Declaration) { @@ -105,6 +165,10 @@ export class Rule { isModeRule(): boolean { return !!this.media; } + + isContextRule(): boolean { + return this.isContext; + } } export class Declaration { diff --git a/src/shared/declaration/transformer.ts b/src/shared/declaration/transformer.ts index 7dba77d..7b1eedb 100644 --- a/src/shared/declaration/transformer.ts +++ b/src/shared/declaration/transformer.ts @@ -44,12 +44,14 @@ export class MinimalTransformer implements Transformer { // 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. + // However, for plain mode rules (media query, no context selector) we skip tokens that have + // identical values to their parent, even if referenced variables are overridden: these inherit + // properly via the natural CSS variable cascade. Context rules (including inheriting context + // rules that also carry a media query) are always re-output, hence the explicit isContextRule. const firstSelector = getFirstSelector(rule.selector); const isModeRule = rule.isModeRule(); + const isContextRule = rule.isContextRule(); if (isGlobalSelector(firstSelector)) { rule.clear(); @@ -76,9 +78,11 @@ export class MinimalTransformer implements Transformer { 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]; + // This decides whether a token that references an overridden var + // can be left out and resolved via the natural CSS variable cascade. + // - Plain mode rules: yes - identical values inherit fine, so skip re-emitting. + // - Context rules: no - a context rule must resolve in the context's own DOM scope. + const canInherit = isModeRule && !isContextRule && ruleValue[property] === resolvedParent[property]; if (canInherit) return; if (!(property in diff)) { @@ -93,36 +97,14 @@ export class MinimalTransformer implements Transformer { } }); - return mergeSelectors(stylesheet); - } -} + // Inherited aliases are applied as near-last step so that findRule lookups work correctly. + stylesheet.applyInheritedAliases(); -/** - * Merges adjacent rules with identical declarations into a single comma-separated selector. - * - * For example: - * .a { --color-background: red; } - * .b { --color-background: red; } - * - * becomes: - * .a, - * .b { --color-background: red; } - */ -function mergeSelectors(stylesheet: Stylesheet): Stylesheet { - const rules = stylesheet.getAllRules(); - let i = 1; - while (i < rules.length) { - const prev = rules[i - 1]; - const curr = rules[i]; - if (prev.media === curr.media && prev.printAllDeclarations() === curr.printAllDeclarations()) { - prev.selector = `${prev.selector},${curr.selector}`; - stylesheet.removeRule(curr); - rules.splice(i, 1); - } else { - i++; - } + // Selectors are merged after aliases resolution so that final selectors are compared. + stylesheet.mergeSelectors(); + + return stylesheet; } - return stylesheet; } function difference(mapA: Record, mapB: Record): Record { diff --git a/src/shared/theme/__tests__/resolve.test.ts b/src/shared/theme/__tests__/resolve.test.ts index e36bf28..dc7c9b3 100644 --- a/src/shared/theme/__tests__/resolve.test.ts +++ b/src/shared/theme/__tests__/resolve.test.ts @@ -10,8 +10,9 @@ import { themeWithTokenWithoutModeResolution, colorMode, } from '../../../__fixtures__/common'; -import { resolveTheme, resolveThemeWithPaths, resolveContext } from '../resolve'; -import { Theme, Context } from '../interfaces'; +import { resolveTheme, resolveThemeWithPaths, resolveContext, reduce, defaultsReducer } from '../resolve'; +import { getInheritedState } from '../utils'; +import { Theme, Context, Mode } from '../interfaces'; describe('resolve', () => { test('resolves theme to full resolution', () => { @@ -142,3 +143,76 @@ describe('resolveContext', () => { expect(context.tokens.buttonBackground).toBe('{colorNeutral500}'); }); }); + +describe('inheritsMode value seeding', () => { + const colorMode: Mode = { + id: 'color', + states: { light: { default: true }, dark: { selector: '.dark', media: 'not print' } }, + }; + const densityMode: Mode = { + id: 'density', + states: { comfortable: { default: true }, compact: { selector: '.compact' } }, + }; + const theme: Theme = { + id: 'test', + selector: 'body', + tokens: { + textColor: { light: 'black', dark: 'white' }, + bgColor: { light: 'white', dark: 'black' }, + spaceScaled: { comfortable: '20px', compact: '4px' }, + fontFamily: 'Arial', + }, + tokenModeMap: { textColor: 'color', bgColor: 'color', spaceScaled: 'density' }, + contexts: {}, + modes: { color: colorMode, density: densityMode }, + }; + + test('getInheritedState prefers inheritsMode and falls back to defaultMode', () => { + expect(getInheritedState({ id: 'c', selector: '.c', tokens: {}, inheritsMode: 'dark' })).toBe('dark'); + expect(getInheritedState({ id: 'c', selector: '.c', tokens: {}, defaultMode: 'compact' })).toBe('compact'); + expect( + getInheritedState({ id: 'c', selector: '.c', tokens: {}, inheritsMode: 'dark', defaultMode: 'compact' }), + ).toBe('dark'); + expect(getInheritedState({ id: 'c', selector: '.c', tokens: {} })).toBeNull(); + }); + + test('defaultsReducer throws on a resolution that is neither a mode object nor a string', () => { + const reducer = defaultsReducer({ mode: colorMode, state: 'dark', selector: '.dark' }); + // `textColor` is mode-scoped, but the resolution is malformed (a number), + // which is neither a ModeTokenResolution (object) nor a SpecificTokenResolution (string). + expect(() => reducer(123 as never, 'textColor', theme)).toThrow('Mismatch between resolution'); + }); + + test('a dark-inheriting context resolves color tokens to dark, other modes to default', () => { + const context: Context = { id: 'top', selector: '.top', tokens: { bgColor: 'navy' }, inheritsMode: 'dark' }; + + const resolved = reduce( + resolveContext(theme, context), + theme, + defaultsReducer({ mode: colorMode, state: 'dark', selector: '.dark' }), + ); + + // Inherited color mode -> dark values + expect(resolved.textColor).toBe('white'); + // Own override applies on top of the inherited dark value + expect(resolved.bgColor).toBe('navy'); + // Orthogonal density mode keeps its default (comfortable) + expect(resolved.spaceScaled).toBe('20px'); + // Mode-invariant token unchanged + expect(resolved.fontFamily).toBe('Arial'); + }); + + test('a compact-inheriting context resolves density tokens to compact, colors to default', () => { + const context: Context = { id: 'ct', selector: '.ct', tokens: {}, inheritsMode: 'compact' }; + + const resolved = reduce( + resolveContext(theme, context), + theme, + defaultsReducer({ mode: densityMode, state: 'compact', selector: '.compact' }), + ); + + expect(resolved.spaceScaled).toBe('4px'); + expect(resolved.textColor).toBe('black'); + expect(resolved.bgColor).toBe('white'); + }); +}); diff --git a/src/shared/theme/interfaces.ts b/src/shared/theme/interfaces.ts index 6a7d1e9..7165f7e 100644 --- a/src/shared/theme/interfaces.ts +++ b/src/shared/theme/interfaces.ts @@ -26,6 +26,22 @@ export interface Context { id: string; selector: string; tokens: Record; + /** + * Declares that this context inherits the values of a non-default mode state + * (e.g. `dark` of the color mode or `compact` of the density mode). + * + * When set, the generated CSS joins the context selector onto the inherited + * mode state's rule (so the inherited values are shared, not duplicated) and + * the standalone context rule only declares the tokens that differ from the + * inherited mode. The context also respects the inherited mode state's media + * query (e.g. a `dark`-inheriting context reverts to the default mode in + * `@media print`). + */ + inheritsMode?: keyof Mode['states']; + /** + * @deprecated Use {@link Context.inheritsMode} instead. Retained as an alias + * for backwards compatibility; `inheritsMode` takes precedence when both are set. + */ defaultMode?: keyof Mode['states']; } diff --git a/src/shared/theme/merge.ts b/src/shared/theme/merge.ts index e5bfd18..1052a6a 100644 --- a/src/shared/theme/merge.ts +++ b/src/shared/theme/merge.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Theme, Override, Assignment } from './interfaces'; import { cloneDeep, entries } from '../utils'; -import { getMode, isModeValue, isReference, isValue } from './utils'; +import { getThemeMode, isModeValue, isReference, isValue } from './utils'; /** * This function applies all tokens from the override to the theme. @@ -15,7 +15,7 @@ export function mergeInPlace(theme: Theme, override: Override): Theme { update: (typeof override.tokens)[string], ): Assignment | undefined { const isGlobal = isValue(update) || isReference(update); - const mode = getMode(theme, token); + const mode = getThemeMode(theme, token); if (mode && isGlobal) { return Object.keys(mode.states).reduce( diff --git a/src/shared/theme/resolve.ts b/src/shared/theme/resolve.ts index 922bb3b..827064d 100644 --- a/src/shared/theme/resolve.ts +++ b/src/shared/theme/resolve.ts @@ -3,11 +3,13 @@ import { Context, Mode } from '.'; import { cloneDeep, values } from '../utils'; import { Theme, Value } from './interfaces'; -import type { PropertiesMap } from '../declaration/interfaces'; +import type { InheritedModeState, PropertiesMap } from '../declaration/interfaces'; import { areAssignmentsEqual, - getDefaultState, - getMode, + getInheritedState, + getThemeMode, + getThemeModeByState, + getModeState, getReference, isModeValue, isReference, @@ -48,7 +50,7 @@ export function resolveThemeWithPaths( const resolutionPaths: FullResolutionPaths = {}; Object.keys(baseTheme?.tokens ?? theme.tokens).forEach((token) => { - const mode = getMode(baseTheme ?? theme, token); + const mode = getThemeMode(baseTheme ?? theme, token); if (mode) { const modeTokenResolutionPaths: ModeTokenResolutionPath = {}; const resolvedToken = Object.keys(mode.states).reduce>((acc, state: string) => { @@ -135,9 +137,7 @@ export function resolveContext( ): FullResolution { const tmp = cloneDeep(theme); - if (context.defaultMode && theme.modes) { - resolveModeReferenceTokens(tmp, context, baseTheme); - } + resolveModeReferenceTokens(tmp, context, baseTheme); if (!baseTheme || !themeResolution) { tmp.tokens = { ...tmp.tokens, ...context.tokens }; @@ -148,21 +148,37 @@ export function resolveContext( return resolveTheme(tmp, baseTheme, propertiesMap); } +/** + * For a context that defaults to a non-default mode state, resolves the relevant + * mode-specific reference tokens so that path analysis and the context resolution + * use the inherited state's values. No-ops when the context does not inherit a mode. + */ function resolveModeReferenceTokens(theme: Theme, context: Context, baseTheme?: Theme): void { - if (!context.defaultMode || !theme.modes) return; + const inheritedState = getInheritedState(context); + if (!inheritedState) { + return; + } - const defaultMode = context.defaultMode; - const mode = Object.values(theme.modes).find((m) => m.states[defaultMode]); - if (!mode) return; + const mode = getThemeModeByState(theme, inheritedState); + 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. + // + // Only pin a reference token when the inherited state is actually one of its mode states + // (e.g. a `dark`-inheriting context for color palettes). For a context that inherits a + // state of an unrelated mode (e.g. `compact` of the density mode), `inheritedState` is not + // a key of a color palette's `{ light, dark }` object, so we must leave it as a mode object + // and let it resolve normally via the color mode states. (Pinning to `tokenValue['compact']` + // would otherwise corrupt the palette to `undefined`.) Object.keys(theme.tokens).forEach((token) => { if (isReferenceToken('color', theme, token)) { const tokenValue = theme.tokens[token]; - if (isModeValue(tokenValue)) { - theme.tokens[token] = tokenValue[defaultMode]; + if (isModeValue(tokenValue) && inheritedState in tokenValue) { + theme.tokens[token] = tokenValue[inheritedState]; } } }); @@ -252,13 +268,22 @@ export function reduce( }, {} as SpecificResolution); } +/** + * Reduces a full token resolution to a single value by selecting each token's + * default mode state. For example, the resolution { light: 'white', dark: 'black' } + * will resolve to 'white', because light is the default state for color mode. + * + * The function accepts an optional `inherited` override. When defined, the inherited + * mode is used instead of default. Thus, for context tokens that inherit from the + * dark mode - the value will resolve to 'black' instead. + */ export const defaultsReducer = - () => + (inherited: null | InheritedModeState) => (tokenResolution: ModeTokenResolution | SpecificTokenResolution, token: string, theme: Theme, baseTheme?: Theme) => { - const mode = getMode(baseTheme ?? theme, token); + const mode = getThemeMode(baseTheme ?? theme, token); if (mode && isModeTokenResolution(tokenResolution)) { - const defaultState = getDefaultState(mode); - return tokenResolution[defaultState]; + const state = getModeState(mode, inherited); + return tokenResolution[state]; } else if (isSpecificTokenResolution(tokenResolution)) { return tokenResolution; } else { @@ -269,7 +294,7 @@ export const defaultsReducer = export const modeReducer = (mode: Mode, state: string) => (tokenResolution: ModeTokenResolution | SpecificTokenResolution, token: string, theme: Theme, baseTheme?: Theme) => { - const tokenMode = getMode(baseTheme ?? theme, token); + const tokenMode = getThemeMode(baseTheme ?? theme, token); if (tokenMode && tokenMode.id === mode.id && isModeTokenResolution(tokenResolution)) { return tokenResolution[state]; } else if (isSpecificTokenResolution(tokenResolution)) { diff --git a/src/shared/theme/utils.ts b/src/shared/theme/utils.ts index cc61571..0c8202f 100644 --- a/src/shared/theme/utils.ts +++ b/src/shared/theme/utils.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Assignment, DefaultState, OptionalState, ReferenceTokens, Theme } from './interfaces'; +import { InheritedModeState } from '../declaration/interfaces'; +import { Assignment, Context, DefaultState, OptionalState, ReferenceTokens, Theme } from './interfaces'; import { Value, Reference, ModeValue, Mode } from './interfaces'; export function isReferenceToken(category: keyof ReferenceTokens, theme: Theme, token: string): boolean { @@ -127,11 +128,22 @@ export function collectReferencedTokens(theme: Theme, tokens: string[]): string[ return Array.from(referenced); } -export function getMode(theme: Theme, token: string): Mode | null { +export function getThemeMode(theme: Theme, token: string): Mode | null { const modeId = theme.tokenModeMap[token]; return theme.modes[modeId] ?? null; } +export function getThemeModeByState(theme: Theme, stateId: string): null | Mode { + return Object.values(theme.modes).find((mode) => mode.states[stateId]) ?? null; +} + +export function getModeState(mode: Mode, inherited: null | InheritedModeState) { + if (inherited && inherited.mode.id === mode.id) { + return inherited.state; + } + return getDefaultState(mode); +} + export function getDefaultState(mode: Mode): string { const states = Object.keys(mode.states); for (let index = 0; index < states.length; index++) { @@ -144,6 +156,10 @@ export function getDefaultState(mode: Mode): string { throw new Error(`Mode ${JSON.stringify(mode)} does not have a default state`); } +export function getInheritedState(context: Context): null | string { + return context.inheritsMode ?? context.defaultMode ?? null; +} + export function isValidPaletteStep(step: number): boolean { return step >= 50 && step <= 1000 && step % 50 === 0; }