From e20862ae140e3c107368872ba39dbc40af09e44f Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Sun, 21 Dec 2025 18:50:51 -0500 Subject: [PATCH] feat: Add opt-in dark-mode-prefers.css for prefers-color-scheme support Generate a separate CSS file that enables automatic dark mode based on the user's system preference (prefers-color-scheme: dark). This file is opt-in to avoid bloating the default bundle. Consumers who need SSR-compatible automatic dark mode can import it: import '@cloudscape-design/design-tokens/dark-mode-prefers.css'; Then apply the class to their root element:
...
The CSS file extracts all .awsui-dark-mode rules from the generated styles and transforms them to use .awsui-dark-mode-if-preferred wrapped in @media (prefers-color-scheme: dark). --- build-tools/tasks/styles.js | 110 +++++++++++++++++++++++++++++++++++- build-tools/utils/themes.js | 10 +++- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/build-tools/tasks/styles.js b/build-tools/tasks/styles.js index 50aa943278..d4c75812f4 100644 --- a/build-tools/tasks/styles.js +++ b/build-tools/tasks/styles.js @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 const { parallel, series } = require('gulp'); -const { readFileSync } = require('fs'); +const { readFileSync, existsSync } = require('fs'); const { createHash } = require('crypto'); const { join } = require('path'); const { buildThemedComponentsInternal } = require('@cloudscape-design/theming-build/internal'); @@ -36,6 +36,112 @@ function compileStyleDictionary() { }); } +/** + * Extracts dark mode CSS rules from the generated styles and creates a separate + * CSS file that applies dark mode styles based on prefers-color-scheme media query. + * + * This allows consumers to opt-in to automatic dark mode without bundling the + * duplicate styles by default. + */ +function generateDarkModePrefersCss(theme) { + return task(`dark-mode-prefers:${theme.name}`, () => { + const baseStylesPath = join(theme.outputPath, 'internal/base-component/styles.scoped.css'); + + if (!existsSync(baseStylesPath)) { + console.log(` Base component styles not found at ${baseStylesPath}, skipping dark-mode-prefers CSS`); + return Promise.resolve(); + } + + const cssContent = readFileSync(baseStylesPath, 'utf-8'); + const darkModeRules = extractDarkModeRules(cssContent); + + if (darkModeRules.length === 0) { + console.log(' No dark mode rules found, skipping dark-mode-prefers CSS'); + return Promise.resolve(); + } + + // Transform .awsui-dark-mode to .awsui-dark-mode-if-preferred + const transformedRules = darkModeRules.map(rule => + rule.replace(/\.awsui-dark-mode/g, '.awsui-dark-mode-if-preferred') + ); + + const outputCss = `/* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +/* + * This file provides automatic dark mode support based on the user's system preference. + * Import this file and apply the .awsui-dark-mode-if-preferred class to enable automatic + * dark mode switching based on prefers-color-scheme. + * + * Usage: + * import darkModeStyles from '@cloudscape-design/design-tokens/dark-mode-prefers.css'; + *
...
+ * + * Or without CSS modules: + * import '@cloudscape-design/design-tokens/dark-mode-prefers.css'; + *
...
+ */ + +@media (prefers-color-scheme: dark) { +${transformedRules.join('\n\n')} +} +`; + + const designTokensOutputDir = join(workspace.targetPath, theme.designTokensDir); + writeFile(join(designTokensOutputDir, 'dark-mode-prefers.css'), outputCss); + console.log(` Generated dark-mode-prefers.css (${transformedRules.length} rules)`); + + return Promise.resolve(); + }); +} + +/** + * Extracts all .awsui-dark-mode CSS rule blocks from the stylesheet content. + */ +function extractDarkModeRules(cssContent) { + const lines = cssContent.split('\n'); + const darkModeRules = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + // Look for dark mode selectors (not inside @media blocks) + if (line.includes('.awsui-dark-mode') && line.includes('{') && !line.trim().startsWith('@media')) { + // Extract the full rule block + let braceCount = 0; + const ruleLines = []; + let j = i; + + while (j < lines.length) { + const currentLine = lines[j]; + ruleLines.push(currentLine); + + for (const char of currentLine) { + if (char === '{') { + braceCount++; + } + if (char === '}') { + braceCount--; + } + } + + if (braceCount === 0 && ruleLines.length > 0) { + break; + } + j++; + } + + darkModeRules.push(ruleLines.join('\n')); + i = j + 1; + } else { + i++; + } + } + + return darkModeRules; +} + function stylesTask(theme) { return task(`styles:${theme.name}`, async () => { const designTokensOutputDir = join(workspace.targetPath, theme.designTokensDir); @@ -88,5 +194,5 @@ function stylesTask(theme) { module.exports = series( generateEnvironment(), compileStyleDictionary(), - parallel(themes.map(theme => stylesTask(theme))) + parallel(themes.map(theme => series(stylesTask(theme), generateDarkModePrefersCss(theme)))) ); diff --git a/build-tools/utils/themes.js b/build-tools/utils/themes.js index 5066477bbf..b60e2c6157 100644 --- a/build-tools/utils/themes.js +++ b/build-tools/utils/themes.js @@ -10,7 +10,15 @@ const themes = [ packageJson: { name: '@cloudscape-design/components' }, designTokensOutput: 'index', designTokensDir: 'design-tokens', - designTokensPackageJson: { name: '@cloudscape-design/design-tokens' }, + designTokensPackageJson: { + name: '@cloudscape-design/design-tokens', + exports: { + '.': './index.js', + './index.js': './index.js', + './index.scss': './index.scss', + './dark-mode-prefers.css': './dark-mode-prefers.css', + }, + }, outputPath: path.join(workspace.targetPath, 'components'), primaryThemePath: './classic/index.js', secondaryThemePaths: ['./visual-refresh-secondary/index.js'],