From f292d310f8f3a7acd90654d17bbca35e559b64e9 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Wed, 29 Apr 2026 22:00:06 +0200 Subject: [PATCH 01/18] Add data-color-scheme attribute for color mode scheme derivation --- .../react/src/Accordion/Accordion.module.css | 2 +- .../src/Card/Card.stories.shared.module.css | 2 +- packages/react/src/Heading/Heading.module.css | 2 +- .../react/src/Hero/Hero.stories.module.css | 4 +- .../src/InlineLink/InlineLink.module.css | 2 +- packages/react/src/Label/Label.module.css | 2 +- .../MinimalFooter/MinimalFooter.module.css | 2 +- packages/react/src/SubNav/SubNav.module.css | 2 +- packages/react/src/Text/Text.module.css | 2 +- .../src/ThemeProvider/ThemeProvider.test.tsx | 46 ++++++++++++++++++- .../react/src/ThemeProvider/ThemeProvider.tsx | 29 ++++++++++-- packages/react/src/Token/Token.module.css | 2 +- .../FeaturePreviewLevelOne.module.css | 4 +- .../FeaturePreviewLevelTwo.module.css | 2 +- .../Overview/FlexSuiteAIOverview.module.css | 4 +- .../CategoryPage/CategoryPage.module.css | 8 ++-- .../SolutionPage/SolutionPage.module.css | 8 ++-- .../seo/Category/CategoryPage.module.css | 4 +- 18 files changed, 96 insertions(+), 31 deletions(-) diff --git a/packages/react/src/Accordion/Accordion.module.css b/packages/react/src/Accordion/Accordion.module.css index 9ecb41c0ec..be54023e10 100644 --- a/packages/react/src/Accordion/Accordion.module.css +++ b/packages/react/src/Accordion/Accordion.module.css @@ -276,7 +276,7 @@ details[open] > .Accordion__summary.Accordion__summary--default .Accordion__summ line-height: var(--brand-text-lineHeight-200); } -[data-color-mode='dark'] .Accordion__content { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .Accordion__content { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/Card/Card.stories.shared.module.css b/packages/react/src/Card/Card.stories.shared.module.css index 01070f8006..434542aedc 100644 --- a/packages/react/src/Card/Card.stories.shared.module.css +++ b/packages/react/src/Card/Card.stories.shared.module.css @@ -34,7 +34,7 @@ var(--brand-color-canvas-default); } -[data-color-mode='dark'] .gridColumnArrowHover .gridItem:is(:hover, :focus-within) { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .gridColumnArrowHover .gridItem:is(:hover, :focus-within) { background: linear-gradient(180deg, rgba(242, 245, 243, 0.1) 0%, rgba(242, 245, 243, 0) 100%), var(--brand-color-canvas-default); } diff --git a/packages/react/src/Heading/Heading.module.css b/packages/react/src/Heading/Heading.module.css index 98beabb78d..532388a03a 100644 --- a/packages/react/src/Heading/Heading.module.css +++ b/packages/react/src/Heading/Heading.module.css @@ -15,7 +15,7 @@ --condensed-stretch: 90%; } -[data-color-mode='dark'] .Heading { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .Heading { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/packages/react/src/Hero/Hero.stories.module.css b/packages/react/src/Hero/Hero.stories.module.css index 02c4930f69..43864f2963 100644 --- a/packages/react/src/Hero/Hero.stories.module.css +++ b/packages/react/src/Hero/Hero.stories.module.css @@ -12,10 +12,10 @@ .heroWithCardsExample { --customCardsBackgroundColor: transparent; } -[data-color-mode='light'] .heroWithCardsExample { +:is([data-color-mode='light'], [data-color-scheme='light']) .heroWithCardsExample { --customCardsBackgroundColor: var(--base-color-scale-gray-1); } -[data-color-mode='dark'] .heroWithCardsExample { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .heroWithCardsExample { --customCardsBackgroundColor: var(--base-color-scale-gray-9); } diff --git a/packages/react/src/InlineLink/InlineLink.module.css b/packages/react/src/InlineLink/InlineLink.module.css index 42224c1adb..9f0dbd77c9 100644 --- a/packages/react/src/InlineLink/InlineLink.module.css +++ b/packages/react/src/InlineLink/InlineLink.module.css @@ -24,7 +24,7 @@ color: var(--brand-InlineLink-color-pressed); } -[data-color-mode='dark'] .InlineLink { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .InlineLink { -webkit-font-smoothing: subpixel-antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/Label/Label.module.css b/packages/react/src/Label/Label.module.css index 737941a86b..8d9436e1ec 100644 --- a/packages/react/src/Label/Label.module.css +++ b/packages/react/src/Label/Label.module.css @@ -32,7 +32,7 @@ text-align: center; } -[data-color-mode='dark'] .Label { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .Label { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/MinimalFooter/MinimalFooter.module.css b/packages/react/src/MinimalFooter/MinimalFooter.module.css index b3c7b1a0ad..49bf2243c1 100644 --- a/packages/react/src/MinimalFooter/MinimalFooter.module.css +++ b/packages/react/src/MinimalFooter/MinimalFooter.module.css @@ -81,7 +81,7 @@ height: auto; } -[data-color-mode='light'] .Footer__social-icon { +:is([data-color-mode='light'], [data-color-scheme='light']) .Footer__social-icon { filter: brightness(95%) saturate(100%); } diff --git a/packages/react/src/SubNav/SubNav.module.css b/packages/react/src/SubNav/SubNav.module.css index 08a23b4447..0903601a22 100644 --- a/packages/react/src/SubNav/SubNav.module.css +++ b/packages/react/src/SubNav/SubNav.module.css @@ -21,7 +21,7 @@ background-color: var(--brand-SubNav-bgColor); } -[data-color-mode='dark'] .SubNav { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .SubNav { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/packages/react/src/Text/Text.module.css b/packages/react/src/Text/Text.module.css index f810f2e96b..22b4bafa26 100644 --- a/packages/react/src/Text/Text.module.css +++ b/packages/react/src/Text/Text.module.css @@ -2,7 +2,7 @@ font-family: var(--brand-body-fontFamily); } -[data-color-mode='dark'] .Text--antialiased { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .Text--antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/ThemeProvider/ThemeProvider.test.tsx b/packages/react/src/ThemeProvider/ThemeProvider.test.tsx index 490b2dbc17..a6565fa648 100644 --- a/packages/react/src/ThemeProvider/ThemeProvider.test.tsx +++ b/packages/react/src/ThemeProvider/ThemeProvider.test.tsx @@ -2,7 +2,7 @@ import React, {useContext} from 'react' import {render, renderHook, cleanup} from '@testing-library/react' import '@testing-library/jest-dom' -import {ColorMode, ThemeContext, ThemeProvider} from './ThemeProvider' +import {ColorMode, ThemeContext, ThemeProvider, getColorScheme} from './ThemeProvider' import {axe, toHaveNoViolations} from 'jest-axe' expect.extend(toHaveNoViolations) @@ -42,6 +42,7 @@ describe('ThemeProvider', () => { expect(rootEl).toBeInTheDocument() expect(rootEl).toHaveAttribute('data-color-mode') + expect(rootEl).toHaveAttribute('data-color-scheme', 'light') expect(childEl).toBeInTheDocument() }) @@ -53,6 +54,7 @@ describe('ThemeProvider', () => { const expectedMode = 'light' expect(rootEl).toHaveAttribute('data-color-mode', expectedMode) + expect(rootEl).toHaveAttribute('data-color-scheme', 'light') }) it('can optionally render dark mode', () => { @@ -63,6 +65,27 @@ describe('ThemeProvider', () => { const el = getByTestId(mockId) expect(el).toHaveAttribute('data-color-mode', mockMode) + expect(el).toHaveAttribute('data-color-scheme', 'dark') + }) + + it('derives dark color scheme for any mode containing "dark"', () => { + const mockId = 'theme-provider' + const {getByTestId} = render() + + const el = getByTestId(mockId) + + expect(el).toHaveAttribute('data-color-mode', 'dark_dimmed') + expect(el).toHaveAttribute('data-color-scheme', 'dark') + }) + + it('derives light color scheme for modes not containing "dark"', () => { + const mockId = 'theme-provider' + const {getByTestId} = render() + + const el = getByTestId(mockId) + + expect(el).toHaveAttribute('data-color-mode', 'light_high_contrast') + expect(el).toHaveAttribute('data-color-scheme', 'light') }) it('can optionally render dark color scheme in auto mode', () => { @@ -85,6 +108,7 @@ describe('ThemeProvider', () => { const expectedMode = 'dark' expect(el).toHaveAttribute('data-color-mode', expectedMode) + expect(el).toHaveAttribute('data-color-scheme', 'dark') matchMediaSpy.mockRestore() }) @@ -109,6 +133,7 @@ describe('ThemeProvider', () => { const expectedMode = 'light' expect(el).toHaveAttribute('data-color-mode', expectedMode) + expect(el).toHaveAttribute('data-color-scheme', 'light') }) it('supports nested ThemeProviders', () => { @@ -129,8 +154,11 @@ describe('ThemeProvider', () => { const levelTwoProviderEl = getByTestId(levelTwoProviderId) expect(rootProviderEl).toHaveAttribute('data-color-mode', 'light') + expect(rootProviderEl).toHaveAttribute('data-color-scheme', 'light') expect(levelOneProviderEl).toHaveAttribute('data-color-mode', 'dark') + expect(levelOneProviderEl).toHaveAttribute('data-color-scheme', 'dark') expect(levelTwoProviderEl).toHaveAttribute('data-color-mode', 'light') + expect(levelTwoProviderEl).toHaveAttribute('data-color-scheme', 'light') }) it('returns all available modes via context', () => { @@ -150,10 +178,12 @@ describe('ThemeProvider', () => { const {getByTestId, rerender} = render() expect(getByTestId(mockId)).toHaveAttribute('data-color-mode', 'light') + expect(getByTestId(mockId)).toHaveAttribute('data-color-scheme', 'light') rerender() expect(getByTestId(mockId)).toHaveAttribute('data-color-mode', 'dark') + expect(getByTestId(mockId)).toHaveAttribute('data-color-scheme', 'dark') }) test('no a11y violations', async () => { @@ -163,3 +193,17 @@ describe('ThemeProvider', () => { expect(results).toHaveNoViolations() }) }) + +describe('getColorScheme', () => { + it('returns "dark" for modes containing "dark"', () => { + expect(getColorScheme('dark')).toBe('dark') + expect(getColorScheme('dark_dimmed')).toBe('dark') + expect(getColorScheme('dark_high_contrast')).toBe('dark') + }) + + it('returns "light" for modes not containing "dark"', () => { + expect(getColorScheme('light')).toBe('light') + expect(getColorScheme('light_high_contrast')).toBe('light') + expect(getColorScheme('auto')).toBe('light') + }) +}) diff --git a/packages/react/src/ThemeProvider/ThemeProvider.tsx b/packages/react/src/ThemeProvider/ThemeProvider.tsx index e024f15754..efddbafa99 100644 --- a/packages/react/src/ThemeProvider/ThemeProvider.tsx +++ b/packages/react/src/ThemeProvider/ThemeProvider.tsx @@ -6,7 +6,15 @@ export enum ColorModesEnum { AUTO = 'auto', } -export type ColorMode = `${ColorModesEnum}` +export type ColorMode = `${ColorModesEnum}` | (string & {}) + +/** + * Returns the base color scheme ("light" or "dark") for a given color mode. + * Any mode containing "dark" maps to the dark scheme; everything else maps to light. + */ +export function getColorScheme(mode: string): 'light' | 'dark' { + return mode.includes('dark') ? 'dark' : 'light' +} export type ThemeContextProps = { /* @@ -31,6 +39,16 @@ export const ThemeContext = createContext({ availableColorModes: Object.values(ColorModesEnum), }) +/** + * SSR-safe matchMedia wrapper. Returns undefined when window.matchMedia is unavailable. + */ +const safeMatchMedia = (query: string): MediaQueryList | undefined => { + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + return window.matchMedia(query) + } + return undefined +} + /** * ThemeProvider is used to provide theme-related context to its child components. */ @@ -50,22 +68,23 @@ export function ThemeProvider({colorMode = defaultMode, children, ...rest}: Prop return ( -
+
{children}
) } -const queryBrowserPreference = () => window.matchMedia(`(prefers-color-scheme: ${ColorModesEnum.DARK})`) +const queryBrowserPreference = () => safeMatchMedia(`(prefers-color-scheme: ${ColorModesEnum.DARK})`) const getActiveAutoMode = () => { - const mediaQueryList: MediaQueryList = queryBrowserPreference() - return mediaQueryList.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT + const mediaQueryList = queryBrowserPreference() + return mediaQueryList?.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT } const handleSystemPreferenceChange = callback => { const mediaQueryList = queryBrowserPreference() + if (!mediaQueryList) return () => {} const changeHandler = event => callback(event.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT) mediaQueryList.addEventListener('change', changeHandler) return () => mediaQueryList.removeEventListener('change', changeHandler) diff --git a/packages/react/src/Token/Token.module.css b/packages/react/src/Token/Token.module.css index 6ea5fe214c..3d6a992191 100644 --- a/packages/react/src/Token/Token.module.css +++ b/packages/react/src/Token/Token.module.css @@ -22,7 +22,7 @@ text-decoration: none; } -[data-color-mode='dark'] .Token { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .Token { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css index a1b8d55965..1d2a581e33 100644 --- a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css +++ b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css @@ -7,7 +7,7 @@ --brand-Hero-regular-padding: var(--brand-Hero-narrow-padding); } -[data-color-mode='dark'] .FeaturePreview__trailingSection { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .FeaturePreview__trailingSection { --brand-color-canvas-subtle: var(--base-color-scale-gray-7); } @@ -89,7 +89,7 @@ z-index: 1; } - [data-color-mode='dark'] .FeaturePreview__formContent { + :is([data-color-mode='dark'], [data-color-scheme='dark']) .FeaturePreview__formContent { background-color: var(--brand-color-canvas-subtle); } diff --git a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css index 29446bb7df..f9999a6edd 100644 --- a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css +++ b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css @@ -45,7 +45,7 @@ --brand-color-canvas-subtle: var(--base-color-scale-gray-1); } -[data-color-mode='dark'] .FeaturePreview__trailingSection { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .FeaturePreview__trailingSection { --brand-color-canvas-subtle: var(--base-color-scale-gray-7); } diff --git a/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css b/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css index 824b014a7f..91889d885b 100644 --- a/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css +++ b/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css @@ -58,7 +58,9 @@ var(--brand-color-canvas-default); } -[data-color-mode='dark'] .cardGridColumnArrowHover .cardGridItem:is(:hover, :focus-within) { +:is([data-color-mode='dark'], [data-color-scheme='dark']) + .cardGridColumnArrowHover + .cardGridItem:is(:hover, :focus-within) { background: linear-gradient(180deg, rgba(242, 245, 243, 0.1) 0%, rgba(242, 245, 243, 0) 100%), var(--brand-color-canvas-default); } diff --git a/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css b/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css index e65c32cf74..bad9d673e2 100644 --- a/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css +++ b/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css @@ -52,19 +52,19 @@ left: 20%; } -[data-color-mode='light'] .customHero::before { +:is([data-color-mode='light'], [data-color-scheme='light']) .customHero::before { background: var(--base-color-scale-blue-2); } -[data-color-mode='light'] .customHero::after { +:is([data-color-mode='light'], [data-color-scheme='light']) .customHero::after { background: var(--base-color-scale-purple-2); } -[data-color-mode='dark'] .customHero::before { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .customHero::before { background: var(--base-color-scale-blue-5); } -[data-color-mode='dark'] .customHero::after { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .customHero::after { background: var(--base-color-scale-purple-5); } diff --git a/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css b/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css index e934accffd..22b3dd4450 100644 --- a/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css +++ b/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css @@ -102,18 +102,18 @@ top: calc(--topOffset - 100px); } -[data-color-mode='light'] .heroVideoGlow::before { +:is([data-color-mode='light'], [data-color-scheme='light']) .heroVideoGlow::before { background: var(--base-color-scale-blue-2); } -[data-color-mode='light'] .heroVideoGlow::after { +:is([data-color-mode='light'], [data-color-scheme='light']) .heroVideoGlow::after { background: var(--base-color-scale-purple-2); } -[data-color-mode='dark'] .heroVideoGlow::before { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .heroVideoGlow::before { background: var(--base-color-scale-blue-7); } -[data-color-mode='dark'] .heroVideoGlow::after { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .heroVideoGlow::after { background: var(--base-color-scale-purple-7); } diff --git a/packages/react/src/recipes/seo/Category/CategoryPage.module.css b/packages/react/src/recipes/seo/Category/CategoryPage.module.css index b25390e73a..11f159f476 100644 --- a/packages/react/src/recipes/seo/Category/CategoryPage.module.css +++ b/packages/react/src/recipes/seo/Category/CategoryPage.module.css @@ -33,8 +33,8 @@ background-color: var(--base-color-scale-gray-1); } -[data-color-mode='dark'] .navListItemSelected, -[data-color-mode='dark'] .navListItem:hover { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .navListItemSelected, +:is([data-color-mode='dark'], [data-color-scheme='dark']) .navListItem:hover { background-color: var(--base-color-scale-gray-6); } From 82aa674b6b2e7e34b3b08c8772198453b6e1fe1c Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Thu, 30 Apr 2026 18:12:33 +0200 Subject: [PATCH 02/18] Update token formatter to support data-color-scheme and auto mode --- .../src/formats/color-mode-attributes.js | 101 +++++++++++++----- 1 file changed, 72 insertions(+), 29 deletions(-) diff --git a/packages/design-tokens/src/formats/color-mode-attributes.js b/packages/design-tokens/src/formats/color-mode-attributes.js index e109a57667..318caf05af 100644 --- a/packages/design-tokens/src/formats/color-mode-attributes.js +++ b/packages/design-tokens/src/formats/color-mode-attributes.js @@ -30,44 +30,32 @@ function colorModeAttributes({dictionary, file, options}) { allTokens = [...allTokens].sort(sortByReference(dictionary)) // TODO - add a test for this } - const renderLightMode = () => - `:root, - [data-color-mode=${defaultMode}] { - ${allTokens - .map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false})) - .filter(function (strVal) { - return !!strVal - }) - .join('\n')} - }\n` + const getColorScheme = mode => (mode.includes('dark') ? 'dark' : 'light') /** - * Returns alternate color mode tokens by replacing the contents of value with said mode + * Renders token declarations for a given mode. + * Returns an empty string if no tokens exist for the mode. */ - const renderAlternateColorMode = mode => { - if (mode === defaultMode) return renderLightMode() + const renderTokenDeclarations = mode => { + if (mode === defaultMode) { + return allTokens + .map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false})) + .filter(Boolean) + .join('\n') + } const replaceWithAlternateModeValue = token => { if (token.hasOwnProperty(mode)) { - return Object.assign({}, token, { - value: token[mode], - }) + return Object.assign({}, token, {value: token[mode]}) } } const newDictionary = JSON.parse(JSON.stringify(dictionary)) - newDictionary.allProperties = newDictionary.allProperties.map(replaceWithAlternateModeValue).filter(Boolean) - // short-circuit if no tokens are found for the supported modes if (!newDictionary.allProperties.length) return '' - const {allTokens: newAllTokens} = newDictionary - - return ` - [data-color-mode="${mode}"] { - - ${newAllTokens + return newDictionary.allTokens .map(replaceWithAlternateModeValue) .filter(Boolean) .map( @@ -79,17 +67,72 @@ function colorModeAttributes({dictionary, file, options}) { themeable: false, }), ) - .filter(function (strVal) { - return !!strVal - }) - .join('\n')} - + .filter(Boolean) + .join('\n') + } + + const renderLightMode = () => { + const declarations = renderTokenDeclarations(defaultMode) + return `:root, + [data-color-mode="${defaultMode}"], + [data-color-scheme="${defaultMode}"] { + ${declarations} }\n` } + + /** + * Returns alternate color mode tokens by replacing the contents of value with said mode + */ + const renderAlternateColorMode = mode => { + if (mode === defaultMode) return renderLightMode() + + const declarations = renderTokenDeclarations(mode) + if (!declarations) return '' + + const colorScheme = getColorScheme(mode) + + return ` + :is([data-color-mode="${mode}"], [data-color-scheme="${colorScheme}"]) { + ${declarations} + }\n` + } + + /** + * Renders auto mode media query blocks so that tokens respond to + * prefers-color-scheme when data-color-mode="auto" is set (e.g. by Primer React). + */ + const renderAutoMode = () => { + const lightDeclarations = renderTokenDeclarations(defaultMode) + const darkDeclarations = renderTokenDeclarations('dark') + + let output = '' + + if (lightDeclarations) { + output += ` + @media (prefers-color-scheme: light) { + [data-color-mode="auto"] { + ${lightDeclarations} + } + }\n` + } + + if (darkDeclarations) { + output += ` + @media (prefers-color-scheme: dark) { + [data-color-mode="auto"] { + ${darkDeclarations} + } + }\n` + } + + return output + } + const template = ` ${fileHeader({file})} ${supportedModes.map(mode => renderAlternateColorMode(mode)).join('')} + ${renderAutoMode()} \n` From 33c34962536c0356aaf237ea31a382eb0480c9ae Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Thu, 30 Apr 2026 20:15:22 +0200 Subject: [PATCH 03/18] Add changeset --- .changeset/lazy-wolves-win.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/lazy-wolves-win.md diff --git a/.changeset/lazy-wolves-win.md b/.changeset/lazy-wolves-win.md new file mode 100644 index 0000000000..dd0f987ce0 --- /dev/null +++ b/.changeset/lazy-wolves-win.md @@ -0,0 +1,6 @@ +--- +'@primer/react-brand': minor +'@primer/brand-primitives': patch +--- + +`ThemeProvider` now supports derivative color modes. You can pass modes like `"dark_dimmed"` or `"light_high_contrast"` and the right light or dark color tokens will apply. No new themes are added; derivative modes inherit from the existing `light` and `dark` token sets. From 5fe073a7b937c6e103bcc730855b65d4dbb3bbdb Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Thu, 30 Apr 2026 22:18:54 +0200 Subject: [PATCH 04/18] Cleanup --- .../src/formats/color-mode-attributes.js | 109 ++++++------------ .../src/formats/color-mode-attributes.test.js | 14 ++- 2 files changed, 46 insertions(+), 77 deletions(-) diff --git a/packages/design-tokens/src/formats/color-mode-attributes.js b/packages/design-tokens/src/formats/color-mode-attributes.js index 318caf05af..6b015ba851 100644 --- a/packages/design-tokens/src/formats/color-mode-attributes.js +++ b/packages/design-tokens/src/formats/color-mode-attributes.js @@ -30,32 +30,51 @@ function colorModeAttributes({dictionary, file, options}) { allTokens = [...allTokens].sort(sortByReference(dictionary)) // TODO - add a test for this } - const getColorScheme = mode => (mode.includes('dark') ? 'dark' : 'light') + const renderLightMode = () => + `:root, + [data-color-mode=${defaultMode}], + [data-color-scheme="light"] { + ${allTokens + .map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false})) + .filter(function (strVal) { + return !!strVal + }) + .join('\n')} + }\n` /** - * Renders token declarations for a given mode. - * Returns an empty string if no tokens exist for the mode. + * Returns alternate color mode tokens by replacing the contents of value with said mode */ - const renderTokenDeclarations = mode => { - if (mode === defaultMode) { - return allTokens - .map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false})) - .filter(Boolean) - .join('\n') - } + const renderAlternateColorMode = mode => { + if (mode === defaultMode) return renderLightMode() const replaceWithAlternateModeValue = token => { if (token.hasOwnProperty(mode)) { - return Object.assign({}, token, {value: token[mode]}) + return Object.assign({}, token, { + value: token[mode], + }) } } const newDictionary = JSON.parse(JSON.stringify(dictionary)) + newDictionary.allProperties = newDictionary.allProperties.map(replaceWithAlternateModeValue).filter(Boolean) + // short-circuit if no tokens are found for the supported modes if (!newDictionary.allProperties.length) return '' - return newDictionary.allTokens + const {allTokens: newAllTokens} = newDictionary + + let selector = `[data-color-mode="${mode}"]` + // Only add data-color-scheme for base dark mode + if (mode === 'dark') { + selector = `[data-color-mode="${mode}"], [data-color-scheme="dark"]` + } + + return ` + ${selector} { + + ${newAllTokens .map(replaceWithAlternateModeValue) .filter(Boolean) .map( @@ -67,72 +86,18 @@ function colorModeAttributes({dictionary, file, options}) { themeable: false, }), ) - .filter(Boolean) - .join('\n') - } - - const renderLightMode = () => { - const declarations = renderTokenDeclarations(defaultMode) - return `:root, - [data-color-mode="${defaultMode}"], - [data-color-scheme="${defaultMode}"] { - ${declarations} - }\n` - } - - /** - * Returns alternate color mode tokens by replacing the contents of value with said mode - */ - const renderAlternateColorMode = mode => { - if (mode === defaultMode) return renderLightMode() - - const declarations = renderTokenDeclarations(mode) - if (!declarations) return '' - - const colorScheme = getColorScheme(mode) - - return ` - :is([data-color-mode="${mode}"], [data-color-scheme="${colorScheme}"]) { - ${declarations} + .filter(function (strVal) { + return !!strVal + }) + .join('\n')} + }\n` } - /** - * Renders auto mode media query blocks so that tokens respond to - * prefers-color-scheme when data-color-mode="auto" is set (e.g. by Primer React). - */ - const renderAutoMode = () => { - const lightDeclarations = renderTokenDeclarations(defaultMode) - const darkDeclarations = renderTokenDeclarations('dark') - - let output = '' - - if (lightDeclarations) { - output += ` - @media (prefers-color-scheme: light) { - [data-color-mode="auto"] { - ${lightDeclarations} - } - }\n` - } - - if (darkDeclarations) { - output += ` - @media (prefers-color-scheme: dark) { - [data-color-mode="auto"] { - ${darkDeclarations} - } - }\n` - } - - return output - } - const template = ` ${fileHeader({file})} - + ${supportedModes.map(mode => renderAlternateColorMode(mode)).join('')} - ${renderAutoMode()} \n` diff --git a/packages/design-tokens/src/formats/color-mode-attributes.test.js b/packages/design-tokens/src/formats/color-mode-attributes.test.js index 3b7eee9821..22e7b2c1fc 100644 --- a/packages/design-tokens/src/formats/color-mode-attributes.test.js +++ b/packages/design-tokens/src/formats/color-mode-attributes.test.js @@ -133,7 +133,8 @@ describe('Design tokens', () => { const expectedOutput = ` :root, -[data-color-mode="light"] { +[data-color-mode="light"], +[data-color-scheme="light"] { --brand-text-color-example: gray; } @@ -141,7 +142,8 @@ describe('Design tokens', () => { --brand-text-color-example: black; } -[data-color-mode="dark"] { +[data-color-mode="dark"], +[data-color-scheme="dark"] { --brand-text-color-example: white; } `.trim() @@ -460,13 +462,15 @@ describe('Design tokens', () => { const expectedOutput = ` :root, -[data-color-mode="light"] { +[data-color-mode="light"], +[data-color-scheme="light"] { --base-color-scale-gray-0: #f6f8fa; --base-color-scale-gray-1: #eaeef2; --base-color-scale-gray-2: #b1bac4; } -[data-color-mode="dark"] { +[data-color-mode="dark"], +[data-color-scheme="dark"] { --base-color-scale-gray-0: #f0f6fc; --base-color-scale-gray-1: #c9d1d9; --base-color-scale-gray-2: #b1bac4; @@ -476,7 +480,7 @@ describe('Design tokens', () => { --base-color-scale-gray-0: #f0f6fc; --base-color-scale-gray-1: #c9d1d9; --base-color-scale-gray-2: #b1bac4; -} +} `.trim() // remove timestamp and trim From bcdeeb2865ed9642b2c4c5879f85c19eeaca6741 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Thu, 30 Apr 2026 22:35:29 +0200 Subject: [PATCH 05/18] Fixes --- packages/react/src/Card/CardSkewEffect.tsx | 7 ++-- .../src/MinimalFooter/MinimalFooter.test.tsx | 12 +++++++ .../react/src/MinimalFooter/MinimalFooter.tsx | 5 +-- .../react/src/ThemeProvider/ThemeProvider.tsx | 17 ++------- .../FlexTemplate/FlexSection/FlexSection.tsx | 8 +++-- .../components/FlexSectionTestimonials.tsx | 7 ++-- .../Overview/FlexSuiteAIOverview.tsx | 35 +++++-------------- 7 files changed, 40 insertions(+), 51 deletions(-) diff --git a/packages/react/src/Card/CardSkewEffect.tsx b/packages/react/src/Card/CardSkewEffect.tsx index 43f77404e9..5d196c818a 100644 --- a/packages/react/src/Card/CardSkewEffect.tsx +++ b/packages/react/src/Card/CardSkewEffect.tsx @@ -1,7 +1,7 @@ import React, {useRef, useEffect, PropsWithChildren} from 'react' import {clsx} from 'clsx' -import {useTheme} from '../' +import {getColorScheme, useTheme} from '../' import {BaseProps} from '../component-helpers' import styles from './Card.module.css' @@ -21,11 +21,12 @@ export const CardSkewEffect = ({ ...rest }: PropsWithChildren) => { const {colorMode} = useTheme() + const colorScheme = getColorScheme(colorMode) const boundingRef = useRef(null) const torchRef = useRef(null) useEffect(() => { - if (colorMode === 'light' || disableSkew) return + if (colorScheme === 'light' || disableSkew) return const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)') @@ -70,7 +71,7 @@ export const CardSkewEffect = ({ torchElement?.removeEventListener('mousemove', handleMouseMove) motionQuery.removeEventListener('change', handleReducedMotionChange) } - }, [disableSkew, perspective, colorMode]) + }, [disableSkew, perspective, colorScheme]) return (
diff --git a/packages/react/src/MinimalFooter/MinimalFooter.test.tsx b/packages/react/src/MinimalFooter/MinimalFooter.test.tsx index effe343c6d..9c4e68b7e8 100644 --- a/packages/react/src/MinimalFooter/MinimalFooter.test.tsx +++ b/packages/react/src/MinimalFooter/MinimalFooter.test.tsx @@ -298,6 +298,18 @@ describe('MinimalFooter', () => { ) }) + it('uses dark logo fill for derived dark color modes', () => { + const {container} = render( + + + , + ) + + const logo = container.querySelector('a[aria-label="GitHub"] svg') + + expect(logo).toHaveAttribute('fill', 'white') + }) + it('renders social links with visually hidden text', () => { const {getByText} = render() const hiddenText = getByText('GitHub on X') diff --git a/packages/react/src/MinimalFooter/MinimalFooter.tsx b/packages/react/src/MinimalFooter/MinimalFooter.tsx index db8a7936d1..b49f409335 100644 --- a/packages/react/src/MinimalFooter/MinimalFooter.tsx +++ b/packages/react/src/MinimalFooter/MinimalFooter.tsx @@ -2,7 +2,7 @@ import React, {PropsWithChildren} from 'react' import {clsx} from 'clsx' import {LogoGithubIcon} from '@primer/octicons-react' -import {ColorModesEnum, Stack, Text, useTheme} from '../' +import {getColorScheme, Stack, Text, useTheme} from '../' import {BaseProps} from '../component-helpers' /** @@ -235,6 +235,7 @@ type SocialLogomarksProps = { function SocialLogomarks({socialLinks = socialLinkNames, logoHref}: SocialLogomarksProps) { const {colorMode} = useTheme() + const colorScheme = getColorScheme(colorMode) return (
@@ -252,7 +253,7 @@ function SocialLogomarks({socialLinks = socialLinkNames, logoHref}: SocialLogoma data-analytics-event='{"category":"Footer","action":"go to home","label":"text:home"}' aria-label="GitHub" > - +
{socialLinks ? ( diff --git a/packages/react/src/ThemeProvider/ThemeProvider.tsx b/packages/react/src/ThemeProvider/ThemeProvider.tsx index efddbafa99..8dd814e3ba 100644 --- a/packages/react/src/ThemeProvider/ThemeProvider.tsx +++ b/packages/react/src/ThemeProvider/ThemeProvider.tsx @@ -39,16 +39,6 @@ export const ThemeContext = createContext({ availableColorModes: Object.values(ColorModesEnum), }) -/** - * SSR-safe matchMedia wrapper. Returns undefined when window.matchMedia is unavailable. - */ -const safeMatchMedia = (query: string): MediaQueryList | undefined => { - if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { - return window.matchMedia(query) - } - return undefined -} - /** * ThemeProvider is used to provide theme-related context to its child components. */ @@ -75,16 +65,15 @@ export function ThemeProvider({colorMode = defaultMode, children, ...rest}: Prop ) } -const queryBrowserPreference = () => safeMatchMedia(`(prefers-color-scheme: ${ColorModesEnum.DARK})`) +const queryBrowserPreference = () => window.matchMedia(`(prefers-color-scheme: ${ColorModesEnum.DARK})`) const getActiveAutoMode = () => { - const mediaQueryList = queryBrowserPreference() - return mediaQueryList?.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT + const mediaQueryList: MediaQueryList = queryBrowserPreference() + return mediaQueryList.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT } const handleSystemPreferenceChange = callback => { const mediaQueryList = queryBrowserPreference() - if (!mediaQueryList) return () => {} const changeHandler = event => callback(event.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT) mediaQueryList.addEventListener('change', changeHandler) return () => mediaQueryList.removeEventListener('change', changeHandler) diff --git a/packages/react/src/recipes/FlexTemplate/FlexSection/FlexSection.tsx b/packages/react/src/recipes/FlexTemplate/FlexSection/FlexSection.tsx index 70d28a412c..0c831d8775 100644 --- a/packages/react/src/recipes/FlexTemplate/FlexSection/FlexSection.tsx +++ b/packages/react/src/recipes/FlexTemplate/FlexSection/FlexSection.tsx @@ -26,6 +26,7 @@ import { Stack, Statistic, Text, + getColorScheme, useTheme, } from '../../..' @@ -56,6 +57,7 @@ type FlexSectionProps = { export function FlexSection({component, className}: FlexSectionProps) { const {colorMode} = useTheme() + const colorScheme = getColorScheme(colorMode) const [selectedSegmentedControlItem, setSelectedSegmentedControlItem] = React.useState(0) const { breakoutBanner, @@ -435,9 +437,9 @@ export function FlexSection({component, className}: FlexSectionProps) { align={breakoutBanner.align} backgroundColor={backgroundColor} backgroundImageSrc={{ - narrow: colorMode === 'dark' ? darkNarrowBg : lightNarrowBg, - regular: colorMode === 'dark' ? darkWideBg : lightWideBg, - wide: colorMode === 'dark' ? darkWideBg : lightWideBg, + narrow: colorScheme === 'dark' ? darkNarrowBg : lightNarrowBg, + regular: colorScheme === 'dark' ? darkWideBg : lightWideBg, + wide: colorScheme === 'dark' ? darkWideBg : lightWideBg, }} backgroundImagePosition={{ narrow: 'bottom', diff --git a/packages/react/src/recipes/FlexTemplate/FlexSection/components/FlexSectionTestimonials.tsx b/packages/react/src/recipes/FlexTemplate/FlexSection/components/FlexSectionTestimonials.tsx index 9a8d29e94a..fd28837057 100644 --- a/packages/react/src/recipes/FlexTemplate/FlexSection/components/FlexSectionTestimonials.tsx +++ b/packages/react/src/recipes/FlexTemplate/FlexSection/components/FlexSectionTestimonials.tsx @@ -1,7 +1,7 @@ import React from 'react' import {clsx} from 'clsx' -import {Box, Grid, Image, useTheme} from '../../../..' +import {Box, Grid, Image, getColorScheme, useTheme} from '../../../..' import {ContentfulTestimonials} from './ContentfulTestimonials' import type {FlexTemplateTestimonialsConfig} from '../../FlexTemplate.types' @@ -77,6 +77,7 @@ type FlexSectionTestimonialsProps = { export function FlexSectionTestimonials({testimonials, className}: FlexSectionTestimonialsProps) { const {colorMode} = useTheme() + const colorScheme = getColorScheme(colorMode) const variant = testimonials.variant ?? 'minimal' const backgroundVariant = testimonials.backgroundImageVariant @@ -93,14 +94,14 @@ export function FlexSectionTestimonials({testimonials, className}: FlexSectionTe marginBlockEnd={{narrow: 'none', wide: set.marginBlockEnd}} > (null) const riverImageRefs = React.useRef>([]) const {colorMode} = useTheme() + const colorScheme = getColorScheme(colorMode) useEffect(() => { const cleanups: Array<() => void> = [] @@ -128,7 +129,7 @@ export function FlexSuiteAIOverviewTemplate({content}: FlexSuiteAIOverviewTempla @@ -184,14 +185,8 @@ export function FlexSuiteAIOverviewTemplate({content}: FlexSuiteAIOverviewTempla imageBackgroundColor="subtle" > - - {content.rivers[0].imageAlt} + + {content.rivers[0].imageAlt} @@ -209,14 +204,8 @@ export function FlexSuiteAIOverviewTemplate({content}: FlexSuiteAIOverviewTempla imageBackgroundColor="subtle" > - - {content.rivers[1].imageAlt} + + {content.rivers[1].imageAlt} @@ -234,14 +223,8 @@ export function FlexSuiteAIOverviewTemplate({content}: FlexSuiteAIOverviewTempla imageBackgroundColor="subtle" > - - {content.rivers[2].imageAlt} + + {content.rivers[2].imageAlt} From 7f64e350410504721d1fbb8e94dea52f9d22fae4 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Thu, 30 Apr 2026 22:44:17 +0200 Subject: [PATCH 06/18] Cleanup --- packages/design-tokens/src/formats/color-mode-attributes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/design-tokens/src/formats/color-mode-attributes.js b/packages/design-tokens/src/formats/color-mode-attributes.js index 6b015ba851..550cd9d202 100644 --- a/packages/design-tokens/src/formats/color-mode-attributes.js +++ b/packages/design-tokens/src/formats/color-mode-attributes.js @@ -66,7 +66,7 @@ function colorModeAttributes({dictionary, file, options}) { const {allTokens: newAllTokens} = newDictionary let selector = `[data-color-mode="${mode}"]` - // Only add data-color-scheme for base dark mode + if (mode === 'dark') { selector = `[data-color-mode="${mode}"], [data-color-scheme="dark"]` } @@ -96,7 +96,7 @@ function colorModeAttributes({dictionary, file, options}) { const template = ` ${fileHeader({file})} - + ${supportedModes.map(mode => renderAlternateColorMode(mode)).join('')} \n` From c026d32bc4ef6715abffb38f601d8443fa17c679 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Wed, 29 Apr 2026 22:00:06 +0200 Subject: [PATCH 07/18] Add data-color-scheme attribute for color mode scheme derivation --- .../react/src/Accordion/Accordion.module.css | 2 +- .../src/Card/Card.stories.shared.module.css | 2 +- packages/react/src/Heading/Heading.module.css | 2 +- .../react/src/Hero/Hero.stories.module.css | 4 +- .../src/InlineLink/InlineLink.module.css | 2 +- packages/react/src/Label/Label.module.css | 2 +- .../MinimalFooter/MinimalFooter.module.css | 2 +- packages/react/src/SubNav/SubNav.module.css | 2 +- packages/react/src/Text/Text.module.css | 2 +- .../src/ThemeProvider/ThemeProvider.test.tsx | 46 ++++++++++++++++++- .../react/src/ThemeProvider/ThemeProvider.tsx | 29 ++++++++++-- .../FeaturePreviewLevelOne.module.css | 4 +- .../FeaturePreviewLevelTwo.module.css | 2 +- .../Overview/FlexSuiteAIOverview.module.css | 4 +- .../CategoryPage/CategoryPage.module.css | 8 ++-- .../SolutionPage/SolutionPage.module.css | 8 ++-- .../seo/Category/CategoryPage.module.css | 4 +- 17 files changed, 95 insertions(+), 30 deletions(-) diff --git a/packages/react/src/Accordion/Accordion.module.css b/packages/react/src/Accordion/Accordion.module.css index 9ecb41c0ec..be54023e10 100644 --- a/packages/react/src/Accordion/Accordion.module.css +++ b/packages/react/src/Accordion/Accordion.module.css @@ -276,7 +276,7 @@ details[open] > .Accordion__summary.Accordion__summary--default .Accordion__summ line-height: var(--brand-text-lineHeight-200); } -[data-color-mode='dark'] .Accordion__content { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .Accordion__content { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/Card/Card.stories.shared.module.css b/packages/react/src/Card/Card.stories.shared.module.css index 01070f8006..434542aedc 100644 --- a/packages/react/src/Card/Card.stories.shared.module.css +++ b/packages/react/src/Card/Card.stories.shared.module.css @@ -34,7 +34,7 @@ var(--brand-color-canvas-default); } -[data-color-mode='dark'] .gridColumnArrowHover .gridItem:is(:hover, :focus-within) { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .gridColumnArrowHover .gridItem:is(:hover, :focus-within) { background: linear-gradient(180deg, rgba(242, 245, 243, 0.1) 0%, rgba(242, 245, 243, 0) 100%), var(--brand-color-canvas-default); } diff --git a/packages/react/src/Heading/Heading.module.css b/packages/react/src/Heading/Heading.module.css index 98beabb78d..532388a03a 100644 --- a/packages/react/src/Heading/Heading.module.css +++ b/packages/react/src/Heading/Heading.module.css @@ -15,7 +15,7 @@ --condensed-stretch: 90%; } -[data-color-mode='dark'] .Heading { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .Heading { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/packages/react/src/Hero/Hero.stories.module.css b/packages/react/src/Hero/Hero.stories.module.css index 02c4930f69..43864f2963 100644 --- a/packages/react/src/Hero/Hero.stories.module.css +++ b/packages/react/src/Hero/Hero.stories.module.css @@ -12,10 +12,10 @@ .heroWithCardsExample { --customCardsBackgroundColor: transparent; } -[data-color-mode='light'] .heroWithCardsExample { +:is([data-color-mode='light'], [data-color-scheme='light']) .heroWithCardsExample { --customCardsBackgroundColor: var(--base-color-scale-gray-1); } -[data-color-mode='dark'] .heroWithCardsExample { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .heroWithCardsExample { --customCardsBackgroundColor: var(--base-color-scale-gray-9); } diff --git a/packages/react/src/InlineLink/InlineLink.module.css b/packages/react/src/InlineLink/InlineLink.module.css index 42224c1adb..9f0dbd77c9 100644 --- a/packages/react/src/InlineLink/InlineLink.module.css +++ b/packages/react/src/InlineLink/InlineLink.module.css @@ -24,7 +24,7 @@ color: var(--brand-InlineLink-color-pressed); } -[data-color-mode='dark'] .InlineLink { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .InlineLink { -webkit-font-smoothing: subpixel-antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/Label/Label.module.css b/packages/react/src/Label/Label.module.css index 737941a86b..8d9436e1ec 100644 --- a/packages/react/src/Label/Label.module.css +++ b/packages/react/src/Label/Label.module.css @@ -32,7 +32,7 @@ text-align: center; } -[data-color-mode='dark'] .Label { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .Label { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/MinimalFooter/MinimalFooter.module.css b/packages/react/src/MinimalFooter/MinimalFooter.module.css index b3c7b1a0ad..49bf2243c1 100644 --- a/packages/react/src/MinimalFooter/MinimalFooter.module.css +++ b/packages/react/src/MinimalFooter/MinimalFooter.module.css @@ -81,7 +81,7 @@ height: auto; } -[data-color-mode='light'] .Footer__social-icon { +:is([data-color-mode='light'], [data-color-scheme='light']) .Footer__social-icon { filter: brightness(95%) saturate(100%); } diff --git a/packages/react/src/SubNav/SubNav.module.css b/packages/react/src/SubNav/SubNav.module.css index 08a23b4447..0903601a22 100644 --- a/packages/react/src/SubNav/SubNav.module.css +++ b/packages/react/src/SubNav/SubNav.module.css @@ -21,7 +21,7 @@ background-color: var(--brand-SubNav-bgColor); } -[data-color-mode='dark'] .SubNav { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .SubNav { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/packages/react/src/Text/Text.module.css b/packages/react/src/Text/Text.module.css index f810f2e96b..22b4bafa26 100644 --- a/packages/react/src/Text/Text.module.css +++ b/packages/react/src/Text/Text.module.css @@ -2,7 +2,7 @@ font-family: var(--brand-body-fontFamily); } -[data-color-mode='dark'] .Text--antialiased { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .Text--antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/ThemeProvider/ThemeProvider.test.tsx b/packages/react/src/ThemeProvider/ThemeProvider.test.tsx index 490b2dbc17..a6565fa648 100644 --- a/packages/react/src/ThemeProvider/ThemeProvider.test.tsx +++ b/packages/react/src/ThemeProvider/ThemeProvider.test.tsx @@ -2,7 +2,7 @@ import React, {useContext} from 'react' import {render, renderHook, cleanup} from '@testing-library/react' import '@testing-library/jest-dom' -import {ColorMode, ThemeContext, ThemeProvider} from './ThemeProvider' +import {ColorMode, ThemeContext, ThemeProvider, getColorScheme} from './ThemeProvider' import {axe, toHaveNoViolations} from 'jest-axe' expect.extend(toHaveNoViolations) @@ -42,6 +42,7 @@ describe('ThemeProvider', () => { expect(rootEl).toBeInTheDocument() expect(rootEl).toHaveAttribute('data-color-mode') + expect(rootEl).toHaveAttribute('data-color-scheme', 'light') expect(childEl).toBeInTheDocument() }) @@ -53,6 +54,7 @@ describe('ThemeProvider', () => { const expectedMode = 'light' expect(rootEl).toHaveAttribute('data-color-mode', expectedMode) + expect(rootEl).toHaveAttribute('data-color-scheme', 'light') }) it('can optionally render dark mode', () => { @@ -63,6 +65,27 @@ describe('ThemeProvider', () => { const el = getByTestId(mockId) expect(el).toHaveAttribute('data-color-mode', mockMode) + expect(el).toHaveAttribute('data-color-scheme', 'dark') + }) + + it('derives dark color scheme for any mode containing "dark"', () => { + const mockId = 'theme-provider' + const {getByTestId} = render() + + const el = getByTestId(mockId) + + expect(el).toHaveAttribute('data-color-mode', 'dark_dimmed') + expect(el).toHaveAttribute('data-color-scheme', 'dark') + }) + + it('derives light color scheme for modes not containing "dark"', () => { + const mockId = 'theme-provider' + const {getByTestId} = render() + + const el = getByTestId(mockId) + + expect(el).toHaveAttribute('data-color-mode', 'light_high_contrast') + expect(el).toHaveAttribute('data-color-scheme', 'light') }) it('can optionally render dark color scheme in auto mode', () => { @@ -85,6 +108,7 @@ describe('ThemeProvider', () => { const expectedMode = 'dark' expect(el).toHaveAttribute('data-color-mode', expectedMode) + expect(el).toHaveAttribute('data-color-scheme', 'dark') matchMediaSpy.mockRestore() }) @@ -109,6 +133,7 @@ describe('ThemeProvider', () => { const expectedMode = 'light' expect(el).toHaveAttribute('data-color-mode', expectedMode) + expect(el).toHaveAttribute('data-color-scheme', 'light') }) it('supports nested ThemeProviders', () => { @@ -129,8 +154,11 @@ describe('ThemeProvider', () => { const levelTwoProviderEl = getByTestId(levelTwoProviderId) expect(rootProviderEl).toHaveAttribute('data-color-mode', 'light') + expect(rootProviderEl).toHaveAttribute('data-color-scheme', 'light') expect(levelOneProviderEl).toHaveAttribute('data-color-mode', 'dark') + expect(levelOneProviderEl).toHaveAttribute('data-color-scheme', 'dark') expect(levelTwoProviderEl).toHaveAttribute('data-color-mode', 'light') + expect(levelTwoProviderEl).toHaveAttribute('data-color-scheme', 'light') }) it('returns all available modes via context', () => { @@ -150,10 +178,12 @@ describe('ThemeProvider', () => { const {getByTestId, rerender} = render() expect(getByTestId(mockId)).toHaveAttribute('data-color-mode', 'light') + expect(getByTestId(mockId)).toHaveAttribute('data-color-scheme', 'light') rerender() expect(getByTestId(mockId)).toHaveAttribute('data-color-mode', 'dark') + expect(getByTestId(mockId)).toHaveAttribute('data-color-scheme', 'dark') }) test('no a11y violations', async () => { @@ -163,3 +193,17 @@ describe('ThemeProvider', () => { expect(results).toHaveNoViolations() }) }) + +describe('getColorScheme', () => { + it('returns "dark" for modes containing "dark"', () => { + expect(getColorScheme('dark')).toBe('dark') + expect(getColorScheme('dark_dimmed')).toBe('dark') + expect(getColorScheme('dark_high_contrast')).toBe('dark') + }) + + it('returns "light" for modes not containing "dark"', () => { + expect(getColorScheme('light')).toBe('light') + expect(getColorScheme('light_high_contrast')).toBe('light') + expect(getColorScheme('auto')).toBe('light') + }) +}) diff --git a/packages/react/src/ThemeProvider/ThemeProvider.tsx b/packages/react/src/ThemeProvider/ThemeProvider.tsx index e024f15754..efddbafa99 100644 --- a/packages/react/src/ThemeProvider/ThemeProvider.tsx +++ b/packages/react/src/ThemeProvider/ThemeProvider.tsx @@ -6,7 +6,15 @@ export enum ColorModesEnum { AUTO = 'auto', } -export type ColorMode = `${ColorModesEnum}` +export type ColorMode = `${ColorModesEnum}` | (string & {}) + +/** + * Returns the base color scheme ("light" or "dark") for a given color mode. + * Any mode containing "dark" maps to the dark scheme; everything else maps to light. + */ +export function getColorScheme(mode: string): 'light' | 'dark' { + return mode.includes('dark') ? 'dark' : 'light' +} export type ThemeContextProps = { /* @@ -31,6 +39,16 @@ export const ThemeContext = createContext({ availableColorModes: Object.values(ColorModesEnum), }) +/** + * SSR-safe matchMedia wrapper. Returns undefined when window.matchMedia is unavailable. + */ +const safeMatchMedia = (query: string): MediaQueryList | undefined => { + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + return window.matchMedia(query) + } + return undefined +} + /** * ThemeProvider is used to provide theme-related context to its child components. */ @@ -50,22 +68,23 @@ export function ThemeProvider({colorMode = defaultMode, children, ...rest}: Prop return ( -
+
{children}
) } -const queryBrowserPreference = () => window.matchMedia(`(prefers-color-scheme: ${ColorModesEnum.DARK})`) +const queryBrowserPreference = () => safeMatchMedia(`(prefers-color-scheme: ${ColorModesEnum.DARK})`) const getActiveAutoMode = () => { - const mediaQueryList: MediaQueryList = queryBrowserPreference() - return mediaQueryList.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT + const mediaQueryList = queryBrowserPreference() + return mediaQueryList?.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT } const handleSystemPreferenceChange = callback => { const mediaQueryList = queryBrowserPreference() + if (!mediaQueryList) return () => {} const changeHandler = event => callback(event.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT) mediaQueryList.addEventListener('change', changeHandler) return () => mediaQueryList.removeEventListener('change', changeHandler) diff --git a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css index a1b8d55965..1d2a581e33 100644 --- a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css +++ b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css @@ -7,7 +7,7 @@ --brand-Hero-regular-padding: var(--brand-Hero-narrow-padding); } -[data-color-mode='dark'] .FeaturePreview__trailingSection { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .FeaturePreview__trailingSection { --brand-color-canvas-subtle: var(--base-color-scale-gray-7); } @@ -89,7 +89,7 @@ z-index: 1; } - [data-color-mode='dark'] .FeaturePreview__formContent { + :is([data-color-mode='dark'], [data-color-scheme='dark']) .FeaturePreview__formContent { background-color: var(--brand-color-canvas-subtle); } diff --git a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css index 29446bb7df..f9999a6edd 100644 --- a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css +++ b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css @@ -45,7 +45,7 @@ --brand-color-canvas-subtle: var(--base-color-scale-gray-1); } -[data-color-mode='dark'] .FeaturePreview__trailingSection { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .FeaturePreview__trailingSection { --brand-color-canvas-subtle: var(--base-color-scale-gray-7); } diff --git a/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css b/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css index 8b80b579a1..93dbf09c95 100644 --- a/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css +++ b/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css @@ -58,7 +58,9 @@ var(--brand-color-canvas-default); } -[data-color-mode='dark'] .cardGridColumnArrowHover .cardGridItem:is(:hover, :focus-within) { +:is([data-color-mode='dark'], [data-color-scheme='dark']) + .cardGridColumnArrowHover + .cardGridItem:is(:hover, :focus-within) { background: linear-gradient(180deg, rgba(242, 245, 243, 0.1) 0%, rgba(242, 245, 243, 0) 100%), var(--brand-color-canvas-default); } diff --git a/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css b/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css index e65c32cf74..bad9d673e2 100644 --- a/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css +++ b/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css @@ -52,19 +52,19 @@ left: 20%; } -[data-color-mode='light'] .customHero::before { +:is([data-color-mode='light'], [data-color-scheme='light']) .customHero::before { background: var(--base-color-scale-blue-2); } -[data-color-mode='light'] .customHero::after { +:is([data-color-mode='light'], [data-color-scheme='light']) .customHero::after { background: var(--base-color-scale-purple-2); } -[data-color-mode='dark'] .customHero::before { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .customHero::before { background: var(--base-color-scale-blue-5); } -[data-color-mode='dark'] .customHero::after { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .customHero::after { background: var(--base-color-scale-purple-5); } diff --git a/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css b/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css index e934accffd..22b3dd4450 100644 --- a/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css +++ b/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css @@ -102,18 +102,18 @@ top: calc(--topOffset - 100px); } -[data-color-mode='light'] .heroVideoGlow::before { +:is([data-color-mode='light'], [data-color-scheme='light']) .heroVideoGlow::before { background: var(--base-color-scale-blue-2); } -[data-color-mode='light'] .heroVideoGlow::after { +:is([data-color-mode='light'], [data-color-scheme='light']) .heroVideoGlow::after { background: var(--base-color-scale-purple-2); } -[data-color-mode='dark'] .heroVideoGlow::before { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .heroVideoGlow::before { background: var(--base-color-scale-blue-7); } -[data-color-mode='dark'] .heroVideoGlow::after { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .heroVideoGlow::after { background: var(--base-color-scale-purple-7); } diff --git a/packages/react/src/recipes/seo/Category/CategoryPage.module.css b/packages/react/src/recipes/seo/Category/CategoryPage.module.css index b25390e73a..11f159f476 100644 --- a/packages/react/src/recipes/seo/Category/CategoryPage.module.css +++ b/packages/react/src/recipes/seo/Category/CategoryPage.module.css @@ -33,8 +33,8 @@ background-color: var(--base-color-scale-gray-1); } -[data-color-mode='dark'] .navListItemSelected, -[data-color-mode='dark'] .navListItem:hover { +:is([data-color-mode='dark'], [data-color-scheme='dark']) .navListItemSelected, +:is([data-color-mode='dark'], [data-color-scheme='dark']) .navListItem:hover { background-color: var(--base-color-scale-gray-6); } From d8361a1ba052a63792708981d4bba420cc64ee29 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Thu, 30 Apr 2026 18:12:33 +0200 Subject: [PATCH 08/18] Update token formatter to support data-color-scheme and auto mode --- .../src/formats/color-mode-attributes.js | 101 +++++++++++++----- 1 file changed, 72 insertions(+), 29 deletions(-) diff --git a/packages/design-tokens/src/formats/color-mode-attributes.js b/packages/design-tokens/src/formats/color-mode-attributes.js index e109a57667..318caf05af 100644 --- a/packages/design-tokens/src/formats/color-mode-attributes.js +++ b/packages/design-tokens/src/formats/color-mode-attributes.js @@ -30,44 +30,32 @@ function colorModeAttributes({dictionary, file, options}) { allTokens = [...allTokens].sort(sortByReference(dictionary)) // TODO - add a test for this } - const renderLightMode = () => - `:root, - [data-color-mode=${defaultMode}] { - ${allTokens - .map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false})) - .filter(function (strVal) { - return !!strVal - }) - .join('\n')} - }\n` + const getColorScheme = mode => (mode.includes('dark') ? 'dark' : 'light') /** - * Returns alternate color mode tokens by replacing the contents of value with said mode + * Renders token declarations for a given mode. + * Returns an empty string if no tokens exist for the mode. */ - const renderAlternateColorMode = mode => { - if (mode === defaultMode) return renderLightMode() + const renderTokenDeclarations = mode => { + if (mode === defaultMode) { + return allTokens + .map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false})) + .filter(Boolean) + .join('\n') + } const replaceWithAlternateModeValue = token => { if (token.hasOwnProperty(mode)) { - return Object.assign({}, token, { - value: token[mode], - }) + return Object.assign({}, token, {value: token[mode]}) } } const newDictionary = JSON.parse(JSON.stringify(dictionary)) - newDictionary.allProperties = newDictionary.allProperties.map(replaceWithAlternateModeValue).filter(Boolean) - // short-circuit if no tokens are found for the supported modes if (!newDictionary.allProperties.length) return '' - const {allTokens: newAllTokens} = newDictionary - - return ` - [data-color-mode="${mode}"] { - - ${newAllTokens + return newDictionary.allTokens .map(replaceWithAlternateModeValue) .filter(Boolean) .map( @@ -79,17 +67,72 @@ function colorModeAttributes({dictionary, file, options}) { themeable: false, }), ) - .filter(function (strVal) { - return !!strVal - }) - .join('\n')} - + .filter(Boolean) + .join('\n') + } + + const renderLightMode = () => { + const declarations = renderTokenDeclarations(defaultMode) + return `:root, + [data-color-mode="${defaultMode}"], + [data-color-scheme="${defaultMode}"] { + ${declarations} }\n` } + + /** + * Returns alternate color mode tokens by replacing the contents of value with said mode + */ + const renderAlternateColorMode = mode => { + if (mode === defaultMode) return renderLightMode() + + const declarations = renderTokenDeclarations(mode) + if (!declarations) return '' + + const colorScheme = getColorScheme(mode) + + return ` + :is([data-color-mode="${mode}"], [data-color-scheme="${colorScheme}"]) { + ${declarations} + }\n` + } + + /** + * Renders auto mode media query blocks so that tokens respond to + * prefers-color-scheme when data-color-mode="auto" is set (e.g. by Primer React). + */ + const renderAutoMode = () => { + const lightDeclarations = renderTokenDeclarations(defaultMode) + const darkDeclarations = renderTokenDeclarations('dark') + + let output = '' + + if (lightDeclarations) { + output += ` + @media (prefers-color-scheme: light) { + [data-color-mode="auto"] { + ${lightDeclarations} + } + }\n` + } + + if (darkDeclarations) { + output += ` + @media (prefers-color-scheme: dark) { + [data-color-mode="auto"] { + ${darkDeclarations} + } + }\n` + } + + return output + } + const template = ` ${fileHeader({file})} ${supportedModes.map(mode => renderAlternateColorMode(mode)).join('')} + ${renderAutoMode()} \n` From 9d135bc6d9d1ce985cb7596823b2dd733ba2e1e1 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Thu, 30 Apr 2026 20:15:22 +0200 Subject: [PATCH 09/18] Add changeset --- .changeset/lazy-wolves-win.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/lazy-wolves-win.md diff --git a/.changeset/lazy-wolves-win.md b/.changeset/lazy-wolves-win.md new file mode 100644 index 0000000000..dd0f987ce0 --- /dev/null +++ b/.changeset/lazy-wolves-win.md @@ -0,0 +1,6 @@ +--- +'@primer/react-brand': minor +'@primer/brand-primitives': patch +--- + +`ThemeProvider` now supports derivative color modes. You can pass modes like `"dark_dimmed"` or `"light_high_contrast"` and the right light or dark color tokens will apply. No new themes are added; derivative modes inherit from the existing `light` and `dark` token sets. From 28bcbc078f72d22ad872c209a7f980944d9485a2 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Thu, 30 Apr 2026 22:18:54 +0200 Subject: [PATCH 10/18] Cleanup --- .../src/formats/color-mode-attributes.js | 109 ++++++------------ .../src/formats/color-mode-attributes.test.js | 14 ++- 2 files changed, 46 insertions(+), 77 deletions(-) diff --git a/packages/design-tokens/src/formats/color-mode-attributes.js b/packages/design-tokens/src/formats/color-mode-attributes.js index 318caf05af..6b015ba851 100644 --- a/packages/design-tokens/src/formats/color-mode-attributes.js +++ b/packages/design-tokens/src/formats/color-mode-attributes.js @@ -30,32 +30,51 @@ function colorModeAttributes({dictionary, file, options}) { allTokens = [...allTokens].sort(sortByReference(dictionary)) // TODO - add a test for this } - const getColorScheme = mode => (mode.includes('dark') ? 'dark' : 'light') + const renderLightMode = () => + `:root, + [data-color-mode=${defaultMode}], + [data-color-scheme="light"] { + ${allTokens + .map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false})) + .filter(function (strVal) { + return !!strVal + }) + .join('\n')} + }\n` /** - * Renders token declarations for a given mode. - * Returns an empty string if no tokens exist for the mode. + * Returns alternate color mode tokens by replacing the contents of value with said mode */ - const renderTokenDeclarations = mode => { - if (mode === defaultMode) { - return allTokens - .map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false})) - .filter(Boolean) - .join('\n') - } + const renderAlternateColorMode = mode => { + if (mode === defaultMode) return renderLightMode() const replaceWithAlternateModeValue = token => { if (token.hasOwnProperty(mode)) { - return Object.assign({}, token, {value: token[mode]}) + return Object.assign({}, token, { + value: token[mode], + }) } } const newDictionary = JSON.parse(JSON.stringify(dictionary)) + newDictionary.allProperties = newDictionary.allProperties.map(replaceWithAlternateModeValue).filter(Boolean) + // short-circuit if no tokens are found for the supported modes if (!newDictionary.allProperties.length) return '' - return newDictionary.allTokens + const {allTokens: newAllTokens} = newDictionary + + let selector = `[data-color-mode="${mode}"]` + // Only add data-color-scheme for base dark mode + if (mode === 'dark') { + selector = `[data-color-mode="${mode}"], [data-color-scheme="dark"]` + } + + return ` + ${selector} { + + ${newAllTokens .map(replaceWithAlternateModeValue) .filter(Boolean) .map( @@ -67,72 +86,18 @@ function colorModeAttributes({dictionary, file, options}) { themeable: false, }), ) - .filter(Boolean) - .join('\n') - } - - const renderLightMode = () => { - const declarations = renderTokenDeclarations(defaultMode) - return `:root, - [data-color-mode="${defaultMode}"], - [data-color-scheme="${defaultMode}"] { - ${declarations} - }\n` - } - - /** - * Returns alternate color mode tokens by replacing the contents of value with said mode - */ - const renderAlternateColorMode = mode => { - if (mode === defaultMode) return renderLightMode() - - const declarations = renderTokenDeclarations(mode) - if (!declarations) return '' - - const colorScheme = getColorScheme(mode) - - return ` - :is([data-color-mode="${mode}"], [data-color-scheme="${colorScheme}"]) { - ${declarations} + .filter(function (strVal) { + return !!strVal + }) + .join('\n')} + }\n` } - /** - * Renders auto mode media query blocks so that tokens respond to - * prefers-color-scheme when data-color-mode="auto" is set (e.g. by Primer React). - */ - const renderAutoMode = () => { - const lightDeclarations = renderTokenDeclarations(defaultMode) - const darkDeclarations = renderTokenDeclarations('dark') - - let output = '' - - if (lightDeclarations) { - output += ` - @media (prefers-color-scheme: light) { - [data-color-mode="auto"] { - ${lightDeclarations} - } - }\n` - } - - if (darkDeclarations) { - output += ` - @media (prefers-color-scheme: dark) { - [data-color-mode="auto"] { - ${darkDeclarations} - } - }\n` - } - - return output - } - const template = ` ${fileHeader({file})} - + ${supportedModes.map(mode => renderAlternateColorMode(mode)).join('')} - ${renderAutoMode()} \n` diff --git a/packages/design-tokens/src/formats/color-mode-attributes.test.js b/packages/design-tokens/src/formats/color-mode-attributes.test.js index 3b7eee9821..22e7b2c1fc 100644 --- a/packages/design-tokens/src/formats/color-mode-attributes.test.js +++ b/packages/design-tokens/src/formats/color-mode-attributes.test.js @@ -133,7 +133,8 @@ describe('Design tokens', () => { const expectedOutput = ` :root, -[data-color-mode="light"] { +[data-color-mode="light"], +[data-color-scheme="light"] { --brand-text-color-example: gray; } @@ -141,7 +142,8 @@ describe('Design tokens', () => { --brand-text-color-example: black; } -[data-color-mode="dark"] { +[data-color-mode="dark"], +[data-color-scheme="dark"] { --brand-text-color-example: white; } `.trim() @@ -460,13 +462,15 @@ describe('Design tokens', () => { const expectedOutput = ` :root, -[data-color-mode="light"] { +[data-color-mode="light"], +[data-color-scheme="light"] { --base-color-scale-gray-0: #f6f8fa; --base-color-scale-gray-1: #eaeef2; --base-color-scale-gray-2: #b1bac4; } -[data-color-mode="dark"] { +[data-color-mode="dark"], +[data-color-scheme="dark"] { --base-color-scale-gray-0: #f0f6fc; --base-color-scale-gray-1: #c9d1d9; --base-color-scale-gray-2: #b1bac4; @@ -476,7 +480,7 @@ describe('Design tokens', () => { --base-color-scale-gray-0: #f0f6fc; --base-color-scale-gray-1: #c9d1d9; --base-color-scale-gray-2: #b1bac4; -} +} `.trim() // remove timestamp and trim From 7b494eb7c92635fc304d969059611b239d9ac510 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Thu, 30 Apr 2026 22:35:29 +0200 Subject: [PATCH 11/18] Fixes --- packages/react/src/Card/CardSkewEffect.tsx | 7 ++-- .../src/MinimalFooter/MinimalFooter.test.tsx | 12 +++++++ .../react/src/MinimalFooter/MinimalFooter.tsx | 5 +-- .../react/src/ThemeProvider/ThemeProvider.tsx | 17 ++------- .../FlexTemplate/FlexSection/FlexSection.tsx | 8 +++-- .../components/FlexSectionTestimonials.tsx | 7 ++-- .../Overview/FlexSuiteAIOverview.tsx | 35 +++++-------------- 7 files changed, 40 insertions(+), 51 deletions(-) diff --git a/packages/react/src/Card/CardSkewEffect.tsx b/packages/react/src/Card/CardSkewEffect.tsx index 43f77404e9..5d196c818a 100644 --- a/packages/react/src/Card/CardSkewEffect.tsx +++ b/packages/react/src/Card/CardSkewEffect.tsx @@ -1,7 +1,7 @@ import React, {useRef, useEffect, PropsWithChildren} from 'react' import {clsx} from 'clsx' -import {useTheme} from '../' +import {getColorScheme, useTheme} from '../' import {BaseProps} from '../component-helpers' import styles from './Card.module.css' @@ -21,11 +21,12 @@ export const CardSkewEffect = ({ ...rest }: PropsWithChildren) => { const {colorMode} = useTheme() + const colorScheme = getColorScheme(colorMode) const boundingRef = useRef(null) const torchRef = useRef(null) useEffect(() => { - if (colorMode === 'light' || disableSkew) return + if (colorScheme === 'light' || disableSkew) return const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)') @@ -70,7 +71,7 @@ export const CardSkewEffect = ({ torchElement?.removeEventListener('mousemove', handleMouseMove) motionQuery.removeEventListener('change', handleReducedMotionChange) } - }, [disableSkew, perspective, colorMode]) + }, [disableSkew, perspective, colorScheme]) return (
diff --git a/packages/react/src/MinimalFooter/MinimalFooter.test.tsx b/packages/react/src/MinimalFooter/MinimalFooter.test.tsx index effe343c6d..9c4e68b7e8 100644 --- a/packages/react/src/MinimalFooter/MinimalFooter.test.tsx +++ b/packages/react/src/MinimalFooter/MinimalFooter.test.tsx @@ -298,6 +298,18 @@ describe('MinimalFooter', () => { ) }) + it('uses dark logo fill for derived dark color modes', () => { + const {container} = render( + + + , + ) + + const logo = container.querySelector('a[aria-label="GitHub"] svg') + + expect(logo).toHaveAttribute('fill', 'white') + }) + it('renders social links with visually hidden text', () => { const {getByText} = render() const hiddenText = getByText('GitHub on X') diff --git a/packages/react/src/MinimalFooter/MinimalFooter.tsx b/packages/react/src/MinimalFooter/MinimalFooter.tsx index db8a7936d1..b49f409335 100644 --- a/packages/react/src/MinimalFooter/MinimalFooter.tsx +++ b/packages/react/src/MinimalFooter/MinimalFooter.tsx @@ -2,7 +2,7 @@ import React, {PropsWithChildren} from 'react' import {clsx} from 'clsx' import {LogoGithubIcon} from '@primer/octicons-react' -import {ColorModesEnum, Stack, Text, useTheme} from '../' +import {getColorScheme, Stack, Text, useTheme} from '../' import {BaseProps} from '../component-helpers' /** @@ -235,6 +235,7 @@ type SocialLogomarksProps = { function SocialLogomarks({socialLinks = socialLinkNames, logoHref}: SocialLogomarksProps) { const {colorMode} = useTheme() + const colorScheme = getColorScheme(colorMode) return (
@@ -252,7 +253,7 @@ function SocialLogomarks({socialLinks = socialLinkNames, logoHref}: SocialLogoma data-analytics-event='{"category":"Footer","action":"go to home","label":"text:home"}' aria-label="GitHub" > - +
{socialLinks ? ( diff --git a/packages/react/src/ThemeProvider/ThemeProvider.tsx b/packages/react/src/ThemeProvider/ThemeProvider.tsx index efddbafa99..8dd814e3ba 100644 --- a/packages/react/src/ThemeProvider/ThemeProvider.tsx +++ b/packages/react/src/ThemeProvider/ThemeProvider.tsx @@ -39,16 +39,6 @@ export const ThemeContext = createContext({ availableColorModes: Object.values(ColorModesEnum), }) -/** - * SSR-safe matchMedia wrapper. Returns undefined when window.matchMedia is unavailable. - */ -const safeMatchMedia = (query: string): MediaQueryList | undefined => { - if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { - return window.matchMedia(query) - } - return undefined -} - /** * ThemeProvider is used to provide theme-related context to its child components. */ @@ -75,16 +65,15 @@ export function ThemeProvider({colorMode = defaultMode, children, ...rest}: Prop ) } -const queryBrowserPreference = () => safeMatchMedia(`(prefers-color-scheme: ${ColorModesEnum.DARK})`) +const queryBrowserPreference = () => window.matchMedia(`(prefers-color-scheme: ${ColorModesEnum.DARK})`) const getActiveAutoMode = () => { - const mediaQueryList = queryBrowserPreference() - return mediaQueryList?.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT + const mediaQueryList: MediaQueryList = queryBrowserPreference() + return mediaQueryList.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT } const handleSystemPreferenceChange = callback => { const mediaQueryList = queryBrowserPreference() - if (!mediaQueryList) return () => {} const changeHandler = event => callback(event.matches ? ColorModesEnum.DARK : ColorModesEnum.LIGHT) mediaQueryList.addEventListener('change', changeHandler) return () => mediaQueryList.removeEventListener('change', changeHandler) diff --git a/packages/react/src/recipes/FlexTemplate/FlexSection/FlexSection.tsx b/packages/react/src/recipes/FlexTemplate/FlexSection/FlexSection.tsx index 70d28a412c..0c831d8775 100644 --- a/packages/react/src/recipes/FlexTemplate/FlexSection/FlexSection.tsx +++ b/packages/react/src/recipes/FlexTemplate/FlexSection/FlexSection.tsx @@ -26,6 +26,7 @@ import { Stack, Statistic, Text, + getColorScheme, useTheme, } from '../../..' @@ -56,6 +57,7 @@ type FlexSectionProps = { export function FlexSection({component, className}: FlexSectionProps) { const {colorMode} = useTheme() + const colorScheme = getColorScheme(colorMode) const [selectedSegmentedControlItem, setSelectedSegmentedControlItem] = React.useState(0) const { breakoutBanner, @@ -435,9 +437,9 @@ export function FlexSection({component, className}: FlexSectionProps) { align={breakoutBanner.align} backgroundColor={backgroundColor} backgroundImageSrc={{ - narrow: colorMode === 'dark' ? darkNarrowBg : lightNarrowBg, - regular: colorMode === 'dark' ? darkWideBg : lightWideBg, - wide: colorMode === 'dark' ? darkWideBg : lightWideBg, + narrow: colorScheme === 'dark' ? darkNarrowBg : lightNarrowBg, + regular: colorScheme === 'dark' ? darkWideBg : lightWideBg, + wide: colorScheme === 'dark' ? darkWideBg : lightWideBg, }} backgroundImagePosition={{ narrow: 'bottom', diff --git a/packages/react/src/recipes/FlexTemplate/FlexSection/components/FlexSectionTestimonials.tsx b/packages/react/src/recipes/FlexTemplate/FlexSection/components/FlexSectionTestimonials.tsx index 9a8d29e94a..fd28837057 100644 --- a/packages/react/src/recipes/FlexTemplate/FlexSection/components/FlexSectionTestimonials.tsx +++ b/packages/react/src/recipes/FlexTemplate/FlexSection/components/FlexSectionTestimonials.tsx @@ -1,7 +1,7 @@ import React from 'react' import {clsx} from 'clsx' -import {Box, Grid, Image, useTheme} from '../../../..' +import {Box, Grid, Image, getColorScheme, useTheme} from '../../../..' import {ContentfulTestimonials} from './ContentfulTestimonials' import type {FlexTemplateTestimonialsConfig} from '../../FlexTemplate.types' @@ -77,6 +77,7 @@ type FlexSectionTestimonialsProps = { export function FlexSectionTestimonials({testimonials, className}: FlexSectionTestimonialsProps) { const {colorMode} = useTheme() + const colorScheme = getColorScheme(colorMode) const variant = testimonials.variant ?? 'minimal' const backgroundVariant = testimonials.backgroundImageVariant @@ -93,14 +94,14 @@ export function FlexSectionTestimonials({testimonials, className}: FlexSectionTe marginBlockEnd={{narrow: 'none', wide: set.marginBlockEnd}} > (null) const riverImageRefs = React.useRef>([]) const {colorMode} = useTheme() + const colorScheme = getColorScheme(colorMode) useEffect(() => { const cleanups: Array<() => void> = [] @@ -128,7 +129,7 @@ export function FlexSuiteAIOverviewTemplate({content}: FlexSuiteAIOverviewTempla @@ -184,14 +185,8 @@ export function FlexSuiteAIOverviewTemplate({content}: FlexSuiteAIOverviewTempla imageBackgroundColor="subtle" > - - {content.rivers[0].imageAlt} + + {content.rivers[0].imageAlt} @@ -209,14 +204,8 @@ export function FlexSuiteAIOverviewTemplate({content}: FlexSuiteAIOverviewTempla imageBackgroundColor="subtle" > - - {content.rivers[1].imageAlt} + + {content.rivers[1].imageAlt} @@ -234,14 +223,8 @@ export function FlexSuiteAIOverviewTemplate({content}: FlexSuiteAIOverviewTempla imageBackgroundColor="subtle" > - - {content.rivers[2].imageAlt} + + {content.rivers[2].imageAlt} From a3820b76b6743499b78b195e16753badeab0a321 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Thu, 30 Apr 2026 22:44:17 +0200 Subject: [PATCH 12/18] Cleanup --- packages/design-tokens/src/formats/color-mode-attributes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/design-tokens/src/formats/color-mode-attributes.js b/packages/design-tokens/src/formats/color-mode-attributes.js index 6b015ba851..550cd9d202 100644 --- a/packages/design-tokens/src/formats/color-mode-attributes.js +++ b/packages/design-tokens/src/formats/color-mode-attributes.js @@ -66,7 +66,7 @@ function colorModeAttributes({dictionary, file, options}) { const {allTokens: newAllTokens} = newDictionary let selector = `[data-color-mode="${mode}"]` - // Only add data-color-scheme for base dark mode + if (mode === 'dark') { selector = `[data-color-mode="${mode}"], [data-color-scheme="dark"]` } @@ -96,7 +96,7 @@ function colorModeAttributes({dictionary, file, options}) { const template = ` ${fileHeader({file})} - + ${supportedModes.map(mode => renderAlternateColorMode(mode)).join('')} \n` From 966eb0a36f3dd7fadce7473cd55a609cd58bdea8 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Mon, 4 May 2026 16:18:26 +0200 Subject: [PATCH 13/18] Remove color-scheme in component scope --- packages/react/src/Accordion/Accordion.module.css | 2 +- packages/react/src/Card/Card.stories.shared.module.css | 2 +- packages/react/src/Heading/Heading.module.css | 2 +- packages/react/src/Hero/Hero.stories.module.css | 4 ++-- packages/react/src/InlineLink/InlineLink.module.css | 2 +- packages/react/src/Label/Label.module.css | 2 +- packages/react/src/MinimalFooter/MinimalFooter.module.css | 2 +- packages/react/src/SubNav/SubNav.module.css | 2 +- packages/react/src/Text/Text.module.css | 2 +- packages/react/src/Token/Token.module.css | 1 - .../FeaturePreviewLevelOne.module.css | 4 ++-- .../FeaturePreviewLevelTwo.module.css | 2 +- .../Flexsuite/Overview/FlexSuiteAIOverview.module.css | 4 +--- .../CategoryPage/CategoryPage.module.css | 8 ++++---- .../SolutionPage/SolutionPage.module.css | 8 ++++---- .../src/recipes/seo/Category/CategoryPage.module.css | 4 ++-- 16 files changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/react/src/Accordion/Accordion.module.css b/packages/react/src/Accordion/Accordion.module.css index be54023e10..9ecb41c0ec 100644 --- a/packages/react/src/Accordion/Accordion.module.css +++ b/packages/react/src/Accordion/Accordion.module.css @@ -276,7 +276,7 @@ details[open] > .Accordion__summary.Accordion__summary--default .Accordion__summ line-height: var(--brand-text-lineHeight-200); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .Accordion__content { +[data-color-mode='dark'] .Accordion__content { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/Card/Card.stories.shared.module.css b/packages/react/src/Card/Card.stories.shared.module.css index 434542aedc..01070f8006 100644 --- a/packages/react/src/Card/Card.stories.shared.module.css +++ b/packages/react/src/Card/Card.stories.shared.module.css @@ -34,7 +34,7 @@ var(--brand-color-canvas-default); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .gridColumnArrowHover .gridItem:is(:hover, :focus-within) { +[data-color-mode='dark'] .gridColumnArrowHover .gridItem:is(:hover, :focus-within) { background: linear-gradient(180deg, rgba(242, 245, 243, 0.1) 0%, rgba(242, 245, 243, 0) 100%), var(--brand-color-canvas-default); } diff --git a/packages/react/src/Heading/Heading.module.css b/packages/react/src/Heading/Heading.module.css index 532388a03a..98beabb78d 100644 --- a/packages/react/src/Heading/Heading.module.css +++ b/packages/react/src/Heading/Heading.module.css @@ -15,7 +15,7 @@ --condensed-stretch: 90%; } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .Heading { +[data-color-mode='dark'] .Heading { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/packages/react/src/Hero/Hero.stories.module.css b/packages/react/src/Hero/Hero.stories.module.css index 43864f2963..02c4930f69 100644 --- a/packages/react/src/Hero/Hero.stories.module.css +++ b/packages/react/src/Hero/Hero.stories.module.css @@ -12,10 +12,10 @@ .heroWithCardsExample { --customCardsBackgroundColor: transparent; } -:is([data-color-mode='light'], [data-color-scheme='light']) .heroWithCardsExample { +[data-color-mode='light'] .heroWithCardsExample { --customCardsBackgroundColor: var(--base-color-scale-gray-1); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .heroWithCardsExample { +[data-color-mode='dark'] .heroWithCardsExample { --customCardsBackgroundColor: var(--base-color-scale-gray-9); } diff --git a/packages/react/src/InlineLink/InlineLink.module.css b/packages/react/src/InlineLink/InlineLink.module.css index 9f0dbd77c9..42224c1adb 100644 --- a/packages/react/src/InlineLink/InlineLink.module.css +++ b/packages/react/src/InlineLink/InlineLink.module.css @@ -24,7 +24,7 @@ color: var(--brand-InlineLink-color-pressed); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .InlineLink { +[data-color-mode='dark'] .InlineLink { -webkit-font-smoothing: subpixel-antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/Label/Label.module.css b/packages/react/src/Label/Label.module.css index 8d9436e1ec..737941a86b 100644 --- a/packages/react/src/Label/Label.module.css +++ b/packages/react/src/Label/Label.module.css @@ -32,7 +32,7 @@ text-align: center; } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .Label { +[data-color-mode='dark'] .Label { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/MinimalFooter/MinimalFooter.module.css b/packages/react/src/MinimalFooter/MinimalFooter.module.css index 49bf2243c1..b3c7b1a0ad 100644 --- a/packages/react/src/MinimalFooter/MinimalFooter.module.css +++ b/packages/react/src/MinimalFooter/MinimalFooter.module.css @@ -81,7 +81,7 @@ height: auto; } -:is([data-color-mode='light'], [data-color-scheme='light']) .Footer__social-icon { +[data-color-mode='light'] .Footer__social-icon { filter: brightness(95%) saturate(100%); } diff --git a/packages/react/src/SubNav/SubNav.module.css b/packages/react/src/SubNav/SubNav.module.css index 0903601a22..08a23b4447 100644 --- a/packages/react/src/SubNav/SubNav.module.css +++ b/packages/react/src/SubNav/SubNav.module.css @@ -21,7 +21,7 @@ background-color: var(--brand-SubNav-bgColor); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .SubNav { +[data-color-mode='dark'] .SubNav { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/packages/react/src/Text/Text.module.css b/packages/react/src/Text/Text.module.css index 22b4bafa26..f810f2e96b 100644 --- a/packages/react/src/Text/Text.module.css +++ b/packages/react/src/Text/Text.module.css @@ -2,7 +2,7 @@ font-family: var(--brand-body-fontFamily); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .Text--antialiased { +[data-color-mode='dark'] .Text--antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } diff --git a/packages/react/src/Token/Token.module.css b/packages/react/src/Token/Token.module.css index 9896108d04..25d14997f0 100644 --- a/packages/react/src/Token/Token.module.css +++ b/packages/react/src/Token/Token.module.css @@ -26,7 +26,6 @@ outline: var(--base-size-4) solid var(--brand-color-focus); outline-offset: var(--base-size-2); } - [data-color-mode='dark'] .Token { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; diff --git a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css index 1d2a581e33..a1b8d55965 100644 --- a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css +++ b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelOne/FeaturePreviewLevelOne.module.css @@ -7,7 +7,7 @@ --brand-Hero-regular-padding: var(--brand-Hero-narrow-padding); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .FeaturePreview__trailingSection { +[data-color-mode='dark'] .FeaturePreview__trailingSection { --brand-color-canvas-subtle: var(--base-color-scale-gray-7); } @@ -89,7 +89,7 @@ z-index: 1; } - :is([data-color-mode='dark'], [data-color-scheme='dark']) .FeaturePreview__formContent { + [data-color-mode='dark'] .FeaturePreview__formContent { background-color: var(--brand-color-canvas-subtle); } diff --git a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css index f9999a6edd..29446bb7df 100644 --- a/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css +++ b/packages/react/src/recipes/FeaturePreviewLPs/FeaturePreviewLevelTwo/FeaturePreviewLevelTwo.module.css @@ -45,7 +45,7 @@ --brand-color-canvas-subtle: var(--base-color-scale-gray-1); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .FeaturePreview__trailingSection { +[data-color-mode='dark'] .FeaturePreview__trailingSection { --brand-color-canvas-subtle: var(--base-color-scale-gray-7); } diff --git a/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css b/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css index 93dbf09c95..8b80b579a1 100644 --- a/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css +++ b/packages/react/src/recipes/Flexsuite/Overview/FlexSuiteAIOverview.module.css @@ -58,9 +58,7 @@ var(--brand-color-canvas-default); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) - .cardGridColumnArrowHover - .cardGridItem:is(:hover, :focus-within) { +[data-color-mode='dark'] .cardGridColumnArrowHover .cardGridItem:is(:hover, :focus-within) { background: linear-gradient(180deg, rgba(242, 245, 243, 0.1) 0%, rgba(242, 245, 243, 0) 100%), var(--brand-color-canvas-default); } diff --git a/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css b/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css index bad9d673e2..e65c32cf74 100644 --- a/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css +++ b/packages/react/src/recipes/SolutionTemplates/CategoryPage/CategoryPage.module.css @@ -52,19 +52,19 @@ left: 20%; } -:is([data-color-mode='light'], [data-color-scheme='light']) .customHero::before { +[data-color-mode='light'] .customHero::before { background: var(--base-color-scale-blue-2); } -:is([data-color-mode='light'], [data-color-scheme='light']) .customHero::after { +[data-color-mode='light'] .customHero::after { background: var(--base-color-scale-purple-2); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .customHero::before { +[data-color-mode='dark'] .customHero::before { background: var(--base-color-scale-blue-5); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .customHero::after { +[data-color-mode='dark'] .customHero::after { background: var(--base-color-scale-purple-5); } diff --git a/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css b/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css index 22b3dd4450..e934accffd 100644 --- a/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css +++ b/packages/react/src/recipes/SolutionTemplates/SolutionPage/SolutionPage.module.css @@ -102,18 +102,18 @@ top: calc(--topOffset - 100px); } -:is([data-color-mode='light'], [data-color-scheme='light']) .heroVideoGlow::before { +[data-color-mode='light'] .heroVideoGlow::before { background: var(--base-color-scale-blue-2); } -:is([data-color-mode='light'], [data-color-scheme='light']) .heroVideoGlow::after { +[data-color-mode='light'] .heroVideoGlow::after { background: var(--base-color-scale-purple-2); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .heroVideoGlow::before { +[data-color-mode='dark'] .heroVideoGlow::before { background: var(--base-color-scale-blue-7); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .heroVideoGlow::after { +[data-color-mode='dark'] .heroVideoGlow::after { background: var(--base-color-scale-purple-7); } diff --git a/packages/react/src/recipes/seo/Category/CategoryPage.module.css b/packages/react/src/recipes/seo/Category/CategoryPage.module.css index 11f159f476..b25390e73a 100644 --- a/packages/react/src/recipes/seo/Category/CategoryPage.module.css +++ b/packages/react/src/recipes/seo/Category/CategoryPage.module.css @@ -33,8 +33,8 @@ background-color: var(--base-color-scale-gray-1); } -:is([data-color-mode='dark'], [data-color-scheme='dark']) .navListItemSelected, -:is([data-color-mode='dark'], [data-color-scheme='dark']) .navListItem:hover { +[data-color-mode='dark'] .navListItemSelected, +[data-color-mode='dark'] .navListItem:hover { background-color: var(--base-color-scale-gray-6); } From b68c93c5aa8bf7ef83f8dd606070c255250b4849 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Tue, 5 May 2026 14:47:32 +0200 Subject: [PATCH 14/18] Add ThemeProvider story for derived themes --- .../ThemeProvider/ThemeProvider.stories.tsx | 73 +++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/react/src/ThemeProvider/ThemeProvider.stories.tsx b/packages/react/src/ThemeProvider/ThemeProvider.stories.tsx index 573460e086..ada711fad6 100644 --- a/packages/react/src/ThemeProvider/ThemeProvider.stories.tsx +++ b/packages/react/src/ThemeProvider/ThemeProvider.stories.tsx @@ -1,10 +1,8 @@ import React from 'react' import type {Meta, StoryObj} from '@storybook/react' import {SunIcon, MoonIcon} from '@primer/octicons-react' -import {ThemeProvider} from '.' -import {useTheme} from '..' -import {Text} from '../Text' - +import {ThemeProvider, type ThemeProviderProps} from '.' +import {Box, Text, Stack, useTheme} from '..' import styles from './ThemeProvider.stories.module.css' function ActiveColorMode() { @@ -12,21 +10,45 @@ function ActiveColorMode() { return Active global color mode: {colorMode} } +const providerBoxProps = { + backgroundColor: 'default', + borderColor: 'default', + borderRadius: 'xlarge', + borderStyle: 'solid', + borderWidth: 'thin', + padding: 24, +} as const + function Container({children}: {children: React.ReactNode}) { return
{children}
} function ControlsHint() { return ( - + Hint: Use Storybook Controls to alternate between color modes. ) } +const StorybookColorModes = [ + 'light', + 'dark', + 'dark_dimmed', + 'light_high_contrast', + 'dark_high_contrast', + 'auto', +] satisfies ThemeProviderProps['colorMode'][] + const meta = { title: 'Components/ThemeProvider', component: ThemeProvider, + argTypes: { + colorMode: { + control: 'select', + options: StorybookColorModes, + }, + }, } satisfies Meta export default meta @@ -73,3 +95,44 @@ export const Nested: Story = {
), } + +export const DerivedColorModes: Story = { + args: { + colorMode: 'auto', + }, + render: args => ( + + + + + {`Parent provider: ${args.colorMode}`} + + + + Nested provider: dark_dimmed + + + + Sibling provider: light_high_contrast + + + + + Sibling provider: dark_high_contrast + + + + + Sibling provider: auto + + + + + + + + + + + ), +} From 8db6c44fb7240353cdaccf148f31b2c1fa178503 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Tue, 5 May 2026 16:44:08 +0200 Subject: [PATCH 15/18] Update snapshots --- .../ThemeProvider/ThemeProvider.visual.spec.ts | 11 +++++++++++ ...emeProvider-Derived-Color-Modes-1-darwin.png | Bin 0 -> 25213 bytes 2 files changed, 11 insertions(+) create mode 100644 packages/react/src/ThemeProvider/ThemeProvider.visual.spec.ts-snapshots/Visual-Comparison-ThemeProvider-ThemeProvider-Derived-Color-Modes-1-darwin.png diff --git a/packages/react/src/ThemeProvider/ThemeProvider.visual.spec.ts b/packages/react/src/ThemeProvider/ThemeProvider.visual.spec.ts index 463eb867c3..25cd46e600 100644 --- a/packages/react/src/ThemeProvider/ThemeProvider.visual.spec.ts +++ b/packages/react/src/ThemeProvider/ThemeProvider.visual.spec.ts @@ -26,4 +26,15 @@ test.describe('Visual Comparison: ThemeProvider', () => { await page.waitForTimeout(500) await expect(page).toHaveScreenshot({fullPage: true}) }) + + test('ThemeProvider / Derived Color Modes', async ({page}) => { + await page.goto( + 'http://localhost:6006/iframe.html?args=&id=components-themeprovider--derived-color-modes&viewMode=story', + {waitUntil: 'networkidle'}, + ) + await page.locator('body.sb-show-main').waitFor({state: 'visible'}) + + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot({fullPage: true}) + }) }) diff --git a/packages/react/src/ThemeProvider/ThemeProvider.visual.spec.ts-snapshots/Visual-Comparison-ThemeProvider-ThemeProvider-Derived-Color-Modes-1-darwin.png b/packages/react/src/ThemeProvider/ThemeProvider.visual.spec.ts-snapshots/Visual-Comparison-ThemeProvider-ThemeProvider-Derived-Color-Modes-1-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..a0c285a1b0402439b0d0be56dccaad094f42be4c GIT binary patch literal 25213 zcmdSBbySuA*DVSbHXf(nw-%|^NrX^BmDZ8|n^ z*Yo|ozjNL*#*J~$xnrF7{-x}_pZdgFbIvu_6Zqt@?4|S6=LrZ1F3HPDJtZJG4gWpy z`|Jt$=N^LsApyY^0(q(X&phInMuRp}TzOJkbNdkiRrwFeR5WKi~ zwi-UVd7t160m1ciC+@-@@>gEKeagN1uXM&3N!6GKOKHe z)CVKaXO(g05;dP)Pl;!~8An{PO?R16xqvml^~2S6M1z}so!*hj`+Geq)eMd z1{r#B>bBa2HoX;=`M1$=b>G|IBxza6Hd?Hs?0ly7eUtFM+z={HxO`4Wau^n@v@f{W|su5`$KSA}80{ z2J_!urlEdE*ta@T?3q791wYTsyQDk1B31_wb{9N$rj@;jRf z)N$g7ml^!B$KALgY;QOCOPr0+XYX0N!G)h*Cmc}qtKSa=Er-f%VVdtzo3+K_#(#&c zXS~X%E~Y-=@D&SpZJUcTd+ZJ0%PGOdb|mS0QWSS$JFBhD_^pS(;#3YIP;lRW$PDi? z8lI{Rc&F8#-bEnhyqy^)L6>KFtn}3YmXnF}&5-BUaCaXVslT@3BOs;|aGBgXC|{w3 z)#XTXk%?YLuFdL6`{}W91GGh7L5#NqEX6B~@Nr05bnhqX^VJ(OoiqYYyZe;~$m2Y@ z{lG}7{%nXAI_(RAm{VIC4CC@2xDvs6#+x$FLaL*C17gosU!dUJwHlt&7xywn4!W6a zbgCf$t+kaANFZi6u}iO4CTKY<1ebcjNJ7qZSH`9--9YKoSfJ`{x8-4e@zXMw)dKe|5Y4K-l6NsgnJkE&B`p=(QN2}$3@Z5+Mw2g$Jymy*WK*pmjU8|62?DPdn zKFi^o$eo;*vdMgzbRv73puz(;0qfcXd8C&CXcGE-D|J%LNn~GVB>WfIosXsE<>jSc zOYG03L>Zuj;Q`Y67d#0hCPR4pb}>Z*M!}N!apDVnRQJoyT}(b=3}$#Y`$*v!(LYszIwi7{6nV;^92k#XhZNiem%srQAm0*?3aL zI2i3`KfLH)IEPF2Yg=dG9pR>g(EL3XW%KHStG?a0c zTEB=Bv`5vE$kx3cuYEV`M=Z8h-H{+F>$=&as!Ydc@kBz~($W%xI;8g)d)d_NyT9qV z-po8%Ki{324!LDPtPd~4-iSLE9gg*!O<2q>w@X0_1j$SPjpsFge9>#A zbk^v1Fjv*?FCvDz*9Q0r%IV12?IHWHerBau7dU03Nihs0_Hf1B|BK&lEkn_W+mL4Z z$;(2|50;BN4SY7cWQj%QK1VB7I88@raBBX%t{;NQeJ**t*Lh(zLDb!jz0EQ;AyS!n zhR1t*$>;aMD&}~S;aKSV&)Gz8iH$biX^Qzk=EPmUm#>?tX@Z+Eu-Wr)=vC8@ z{w6{{?6sK1-C=ON-|zEgz@?GSUDqbw1Gm|&%pho|3vuS>#M6p%>Zm}=)zM0(2wRv7 z`^qi#x^!eDl8*7S%Bb~^KJft#20plYe@cCiz3C{~m4&~B)>G91{>8⁢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 A^Z)<= literal 0 HcmV?d00001 From 632456d150e1fcfe3589f304d8902bdacbac181a Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Tue, 5 May 2026 18:39:02 +0200 Subject: [PATCH 16/18] Update changeset --- .changeset/lazy-wolves-win.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/lazy-wolves-win.md b/.changeset/lazy-wolves-win.md index dd0f987ce0..b5e008578c 100644 --- a/.changeset/lazy-wolves-win.md +++ b/.changeset/lazy-wolves-win.md @@ -3,4 +3,4 @@ '@primer/brand-primitives': patch --- -`ThemeProvider` now supports derivative color modes. You can pass modes like `"dark_dimmed"` or `"light_high_contrast"` and the right light or dark color tokens will apply. No new themes are added; derivative modes inherit from the existing `light` and `dark` token sets. +`ThemeProvider` now supports derived color modes. You can pass modes like `"dark_dimmed"` or `"light_high_contrast"` and the right light or dark color tokens will apply. No new themes are added; derived modes inherit from the existing `light` and `dark` token sets. From a13a336281cc33dc2fae5808b21ee0b5516643ef Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Fri, 8 May 2026 18:03:49 +0200 Subject: [PATCH 17/18] Address Copilot feedback --- packages/design-tokens/src/formats/color-mode-attributes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/design-tokens/src/formats/color-mode-attributes.js b/packages/design-tokens/src/formats/color-mode-attributes.js index 550cd9d202..4ffe41256e 100644 --- a/packages/design-tokens/src/formats/color-mode-attributes.js +++ b/packages/design-tokens/src/formats/color-mode-attributes.js @@ -32,7 +32,7 @@ function colorModeAttributes({dictionary, file, options}) { const renderLightMode = () => `:root, - [data-color-mode=${defaultMode}], + [data-color-mode="${defaultMode}]", [data-color-scheme="light"] { ${allTokens .map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false})) From 9a8794a5168f1befbc4da16ce7a0898f39dcbea7 Mon Sep 17 00:00:00 2001 From: Daniel Guillan Date: Mon, 11 May 2026 16:56:14 +0200 Subject: [PATCH 18/18] Fix --- packages/design-tokens/src/formats/color-mode-attributes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/design-tokens/src/formats/color-mode-attributes.js b/packages/design-tokens/src/formats/color-mode-attributes.js index 4ffe41256e..94c3b67ad7 100644 --- a/packages/design-tokens/src/formats/color-mode-attributes.js +++ b/packages/design-tokens/src/formats/color-mode-attributes.js @@ -32,7 +32,7 @@ function colorModeAttributes({dictionary, file, options}) { const renderLightMode = () => `:root, - [data-color-mode="${defaultMode}]", + [data-color-mode="${defaultMode}"], [data-color-scheme="light"] { ${allTokens .map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false}))