@ui5/webcomponents-tools 2.21.0-rc.1 → 2.21.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [2.21.0-rc.2](https://github.com/UI5/webcomponents/compare/v2.21.0-rc.1...v2.21.0-rc.2) (2026-03-19)
7
+
8
+
9
+ ### Features
10
+
11
+ * **theming:** add OS-adaptive auto themes (sap_horizon_auto, sap_horizon_hc_auto) ([#13300](https://github.com/UI5/webcomponents/issues/13300)) ([f6ae2b5](https://github.com/UI5/webcomponents/commit/f6ae2b53ff86d0b4e1c420863535d8e43bae4ed4))
12
+
13
+
14
+
15
+
16
+
6
17
  # [2.21.0-rc.1](https://github.com/UI5/webcomponents/compare/v2.21.0-rc.0...v2.21.0-rc.1) (2026-03-19)
7
18
 
8
19
  **Note:** Version bump only for package @ui5/webcomponents-tools
package/assets-meta.js CHANGED
@@ -6,10 +6,12 @@ const assetsMeta = {
6
6
  "sap_fiori_3_dark",
7
7
  "sap_fiori_3_hcb",
8
8
  "sap_fiori_3_hcw",
9
- "sap_horizon",
10
- "sap_horizon_dark",
11
- "sap_horizon_hcb",
12
- "sap_horizon_hcw",
9
+ "sap_horizon",
10
+ "sap_horizon_auto", // os-based auto theme, not generated by merging light + dark
11
+ "sap_horizon_dark",
12
+ "sap_horizon_hc_auto", // os-based auto theme, merging hcb + hcw
13
+ "sap_horizon_hcb",
14
+ "sap_horizon_hcw",
13
15
  ],
14
16
  },
15
17
  "languages": {
@@ -8,8 +8,18 @@ import combineDuplicatedSelectors from "../postcss-combine-duplicated-selectors/
8
8
  import postcssPlugin from "./postcss-plugin.mjs";
9
9
  import { writeFileIfChanged, getFileContent } from "./shared.mjs";
10
10
  import scopeVariables from "./scope-variables.mjs";
11
+ import { mergeLightDark } from "./merge-light-dark.mjs";
11
12
  import { pathToFileURL } from "url";
12
13
 
14
+ /**
15
+ * Auto theme pairs: each entry defines a light theme, its dark counterpart,
16
+ * and the auto theme name that will be generated by merging them.
17
+ */
18
+ const AUTO_THEME_PAIRS = [
19
+ { light: "sap_horizon", dark: "sap_horizon_dark", auto: "sap_horizon_auto" },
20
+ { light: "sap_horizon_hcw", dark: "sap_horizon_hcb", auto: "sap_horizon_hc_auto" },
21
+ ];
22
+
13
23
  const generate = async (argv) => {
14
24
  const CSS_VARIABLES_TARGET = process.env.CSS_VARIABLES_TARGET === "host";
15
25
  const tsMode = process.env.UI5_TS === "true";
@@ -18,9 +28,14 @@ const generate = async (argv) => {
18
28
  const packageJSON = JSON.parse(fs.readFileSync("./package.json"));
19
29
  const basePackageJSON = (await import("@ui5/webcomponents-base/package.json", { with: { type: "json" } })).default;
20
30
 
21
- const inputFiles = await globby([
31
+ const allInputFiles = await globby([
22
32
  "src/**/parameters-bundle.css",
23
33
  ]);
34
+
35
+ // Filter out auto theme placeholders - they will be generated from merging light + dark
36
+ const autoThemeNames = AUTO_THEME_PAIRS.map(p => p.auto);
37
+ const inputFiles = allInputFiles.filter(f => !autoThemeNames.some(name => f.includes(`/${name}/`)));
38
+
24
39
  const restArgs = argv.slice(2);
25
40
 
26
41
  const saveFiles = async (distPath, css, suffix = "") => {
@@ -38,6 +53,8 @@ const generate = async (argv) => {
38
53
  writeFileIfChanged(jsPath, jsContent);
39
54
  }
40
55
 
56
+ const isThemingPackage = (filePath) => filePath.includes("packages/theming") || filePath.includes("packages\\theming");
57
+
41
58
  const processThemingPackageFile = async (f) => {
42
59
  const selector = ':root';
43
60
  const result = await postcss().process(f.text, { from: undefined });
@@ -73,17 +90,54 @@ const generate = async (argv) => {
73
90
  return { css: scopeVariables(combined.css, basePackageJSON, f.path) };
74
91
  }
75
92
 
93
+ /**
94
+ * Generate auto theme CSS by merging light and dark theme outputs.
95
+ * For the theming package (:root), adds color-scheme and toggle variables.
96
+ * For component packages (:host), just merges the variables.
97
+ */
98
+ const generateAutoThemes = async (processedCSS) => {
99
+ for (const { light, dark, auto } of AUTO_THEME_PAIRS) {
100
+ for (const [lightPath, lightCSS] of processedCSS) {
101
+ // Match paths containing the light theme folder
102
+ const lightPattern = new RegExp(`[/\\\\]${light}[/\\\\]`);
103
+ if (!lightPattern.test(lightPath)) continue;
104
+
105
+ const darkPath = lightPath.replace(lightPattern, (match) => match.replace(light, dark));
106
+ const darkCSS = processedCSS.get(darkPath);
107
+ if (!darkCSS) continue;
108
+
109
+ const selector = isThemingPackage(lightPath) ? ":root" : ":host";
110
+ let autoCSS = mergeLightDark(lightCSS, darkCSS, selector);
111
+
112
+ // For the theming package, add color-scheme and toggle setup
113
+ if (selector === ":root") {
114
+ autoCSS = addAutoThemeSetup(autoCSS);
115
+ }
116
+
117
+ const autoPath = lightPath.replace(lightPattern, (match) => match.replace(light, auto));
118
+ await saveFiles(autoPath, autoCSS);
119
+ }
120
+ }
121
+ };
122
+
76
123
  let scopingPlugin = {
77
124
  name: 'scoping',
78
125
  setup(build) {
79
126
  build.initialOptions.write = false;
80
127
 
81
- build.onEnd(result => {
82
- result.outputFiles.forEach(async f => {
83
- let { css } = f.path.includes("packages/theming") ? await processThemingPackageFile(f) : await processComponentPackageFile(f);
128
+ build.onEnd(async (result) => {
129
+ const processedCSS = new Map();
130
+
131
+ // Process all real theme files
132
+ await Promise.all(result.outputFiles.map(async f => {
133
+ let { css } = isThemingPackage(f.path) ? await processThemingPackageFile(f) : await processComponentPackageFile(f);
134
+
135
+ await saveFiles(f.path, css);
136
+ processedCSS.set(f.path, css);
137
+ }));
84
138
 
85
- saveFiles(f.path, css);
86
- });
139
+ // Generate auto themes by merging light + dark
140
+ await generateAutoThemes(processedCSS);
87
141
  })
88
142
  },
89
143
  }
@@ -110,13 +164,63 @@ const generate = async (argv) => {
110
164
  }
111
165
  }
112
166
 
167
+ /**
168
+ * Adds auto theme setup CSS: color-scheme declaration and
169
+ * light/dark toggle variables with @media switching.
170
+ *
171
+ * In light mode (default):
172
+ * --_ui5-light-scheme is guaranteed-invalid → var() fallbacks resolve to light values
173
+ * --_ui5-dark-scheme is valid empty → var() fallbacks are NOT used
174
+ *
175
+ * In dark mode (@media prefers-color-scheme: dark):
176
+ * --_ui5-light-scheme is valid empty → var() fallbacks are NOT used
177
+ * --_ui5-dark-scheme is guaranteed-invalid → var() fallbacks resolve to dark values
178
+ *
179
+ * @param {string} mergedCSS - The merged :root CSS from mergeLightDark()
180
+ * @returns {string} CSS with toggle setup and @media rule appended
181
+ */
182
+ const addAutoThemeSetup = (mergedCSS) => {
183
+ const root = postcss.parse(mergedCSS);
184
+
185
+ // Add color-scheme and toggle defaults to the :root rule
186
+ root.walkRules(":root", rule => {
187
+ rule.prepend(
188
+ postcss.decl({ prop: "--_ui5-dark-scheme", value: " " }),
189
+ );
190
+ rule.prepend(
191
+ postcss.decl({ prop: "--_ui5-light-scheme", value: "var(--_ui5-f2d95f8)" }),
192
+ );
193
+ rule.prepend(
194
+ postcss.decl({ prop: "color-scheme", value: "light dark" }),
195
+ );
196
+ });
197
+
198
+ // Add @media rule for dark mode - flips the toggles
199
+ const mediaRule = postcss.atRule({
200
+ name: "media",
201
+ params: "(prefers-color-scheme: dark)",
202
+ });
203
+ const darkRoot = postcss.rule({ selector: ":root" });
204
+ darkRoot.append(
205
+ postcss.decl({ prop: "--_ui5-light-scheme", value: " " }),
206
+ postcss.decl({ prop: "--_ui5-dark-scheme", value: "var(--_ui5-f2d95f8)" }),
207
+ postcss.decl({ prop: "color-scheme", value: "dark" }),
208
+ );
209
+ mediaRule.append(darkRoot);
210
+ root.append(mediaRule);
211
+
212
+ return root.toString();
213
+ };
214
+
113
215
  const filePath = process.argv[1];
114
- const fileUrl = pathToFileURL(filePath).href;
115
216
 
116
- if (import.meta.url === fileUrl) {
117
- generate(process.argv)
217
+ if (filePath) {
218
+ const fileUrl = pathToFileURL(filePath).href;
219
+ if (import.meta.url === fileUrl) {
220
+ generate(process.argv)
221
+ }
118
222
  }
119
223
 
120
224
  export default {
121
225
  _ui5mainFn: generate
122
- }
226
+ }
@@ -0,0 +1,131 @@
1
+ import postcss from "postcss";
2
+
3
+ /**
4
+ * Merges two CSS theme files (light and dark) into a single file using
5
+ * CSS light-dark() for color values and space toggles for non-color values.
6
+ *
7
+ * The space toggle pattern uses --_ui5-dark-scheme / --_ui5-light-scheme,
8
+ * following the same approach as the compact/cozy density toggles.
9
+ *
10
+ * @param {string} lightCSS - The light theme CSS string
11
+ * @param {string} darkCSS - The dark theme CSS string
12
+ * @param {string} selector - The selector to match (":root" for theming package, ":host" for component packages)
13
+ * @returns {string} Merged CSS string with light-dark() and space toggle values
14
+ */
15
+ const mergeLightDark = (lightCSS, darkCSS, selector = ":root") => {
16
+ const lightRoot = postcss.parse(lightCSS);
17
+ const darkRoot = postcss.parse(darkCSS);
18
+
19
+ // Extract all custom property declarations from each theme
20
+ const lightVars = extractVars(lightRoot, selector);
21
+ const darkVars = extractVars(darkRoot, selector);
22
+
23
+ // Build merged declarations
24
+ const allProps = new Set([...lightVars.keys(), ...darkVars.keys()]);
25
+ const mergedRule = postcss.rule({ selector });
26
+
27
+ for (const prop of allProps) {
28
+ const lightVal = lightVars.get(prop);
29
+ const darkVal = darkVars.get(prop);
30
+
31
+ if (prop.startsWith("--sapThemeMetaData")) {
32
+ // Keep light version only for metadata
33
+ if (lightVal !== undefined) {
34
+ mergedRule.append(postcss.decl({ prop, value: lightVal }));
35
+ }
36
+ continue;
37
+ }
38
+
39
+ if (lightVal !== undefined && darkVal !== undefined) {
40
+ if (lightVal === darkVal) {
41
+ // Identical values - output once
42
+ mergedRule.append(postcss.decl({ prop, value: lightVal }));
43
+ } else if (isColorValue(lightVal) && isColorValue(darkVal)) {
44
+ // Different color values - use light-dark()
45
+ mergedRule.append(postcss.decl({ prop, value: `light-dark(${lightVal}, ${darkVal})` }));
46
+ } else {
47
+ // Different non-color values - use space toggle
48
+ mergedRule.append(postcss.decl({
49
+ prop,
50
+ value: `var(--_ui5-light-scheme, ${lightVal}) var(--_ui5-dark-scheme, ${darkVal})`,
51
+ }));
52
+ }
53
+ } else if (lightVal !== undefined) {
54
+ // Only in light theme - use space toggle with guaranteed-invalid for dark
55
+ mergedRule.append(postcss.decl({
56
+ prop,
57
+ value: `var(--_ui5-light-scheme, ${lightVal}) var(--_ui5-dark-scheme, var(--_ui5-f2d95f8))`,
58
+ }));
59
+ } else {
60
+ // Only in dark theme - use space toggle with guaranteed-invalid for light
61
+ mergedRule.append(postcss.decl({
62
+ prop,
63
+ value: `var(--_ui5-dark-scheme, ${darkVal}) var(--_ui5-light-scheme, var(--_ui5-f2d95f8))`,
64
+ }));
65
+ }
66
+ }
67
+
68
+ return mergedRule.toString();
69
+ };
70
+
71
+ /**
72
+ * Extract custom property declarations from a parsed CSS root for a given selector.
73
+ * @param {postcss.Root} root
74
+ * @param {string} selector
75
+ * @returns {Map<string, string>}
76
+ */
77
+ const extractVars = (root, selector) => {
78
+ const vars = new Map();
79
+ root.walkRules(rule => {
80
+ if (rule.selector === selector) {
81
+ rule.walkDecls(decl => {
82
+ if (decl.prop.startsWith("--")) {
83
+ vars.set(decl.prop, decl.value);
84
+ }
85
+ });
86
+ }
87
+ });
88
+ return vars;
89
+ };
90
+
91
+ /**
92
+ * Heuristic to determine if a CSS value is a color value.
93
+ * light-dark() only works with <color> values.
94
+ */
95
+ const isColorValue = (value) => {
96
+ const v = value.trim().toLowerCase();
97
+
98
+ // hex colors
99
+ if (/^#[0-9a-f]{3,8}$/.test(v)) return true;
100
+
101
+ // rgb/rgba/hsl/hsla functions
102
+ if (/^(rgba?|hsla?|oklch|oklab|lab|lch|color)\s*\(/.test(v)) return true;
103
+
104
+ // transparent and common CSS color keywords
105
+ if (v === "transparent" || v === "currentcolor") return true;
106
+
107
+ // Named CSS colors - check for common ones used in SAP themes
108
+ const namedColors = new Set([
109
+ "black", "white", "red", "green", "blue", "yellow", "orange", "purple",
110
+ "pink", "gray", "grey", "brown", "cyan", "magenta", "lime", "navy",
111
+ "teal", "aqua", "silver", "maroon", "olive", "fuchsia",
112
+ ]);
113
+ if (namedColors.has(v)) return true;
114
+
115
+ // var() references to SAP color variables
116
+ // Match patterns like var(--sapXxxColor), var(--sapXxx_Background), etc.
117
+ if (/^var\(--sap\w*(Color|Background|BorderColor|TextColor|IconColor|ForegroundColor|HoverColor|ActiveColor|SelectedColor|Shadow)\w*\)$/.test(value.trim())) return true;
118
+
119
+ // Compound values containing color-related var() references
120
+ // e.g., "0.0625rem solid var(--sapContent_FocusColor)"
121
+ if (/var\(--sap\w*(Color|BorderColor|TextColor|IconColor|ForegroundColor|SelectedColor)\w*\)/.test(value)) {
122
+ // If the value also contains dimensional tokens, it's likely a shorthand (border, box-shadow)
123
+ // These are NOT pure color values - light-dark() won't work
124
+ if (/\d+(\.\d+)?(rem|px|em|%)/.test(value)) return false;
125
+ return true;
126
+ }
127
+
128
+ return false;
129
+ };
130
+
131
+ export { mergeLightDark, isColorValue };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ui5/webcomponents-tools",
3
- "version": "2.21.0-rc.1",
3
+ "version": "2.21.0-rc.2",
4
4
  "description": "UI5 Web Components: webcomponents.tools",
5
5
  "author": "SAP SE (https://www.sap.com)",
6
6
  "license": "Apache-2.0",
@@ -82,5 +82,5 @@
82
82
  "esbuild": "^0.25.0",
83
83
  "yargs": "^17.5.1"
84
84
  },
85
- "gitHead": "ca81ad3bb0594ea51472c46d517d34d7e0a719b8"
85
+ "gitHead": "41f7af366ca45a15b31f7f74d349420dcd87fe1f"
86
86
  }