@carbon/themes 11.81.0 → 11.82.0-rc.0

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.
@@ -14,8 +14,10 @@
14
14
  * token files (button.json, tag.json, …) use into flat per-theme tokens that
15
15
  * Style Dictionary can process natively.
16
16
  *
17
- * Input — one token node with no $value but a per-theme map in extensions:
17
+ * Input — one token node with no $value but a per-theme map in extensions.
18
+ * Two equivalent forms are accepted:
18
19
  *
20
+ * Component-token form (legacy, bare strings + separate alphaModifiers):
19
21
  * "button": {
20
22
  * "danger-active": {
21
23
  * "$type": "color",
@@ -26,6 +28,21 @@
26
28
  * }
27
29
  * }
28
30
  *
31
+ * Unified theme-token form (value + alpha co-located per theme):
32
+ * "background": {
33
+ * "hover": {
34
+ * "$type": "color",
35
+ * "$extensions": {
36
+ * "carbon.themes": {
37
+ * "white": { "value": "{gray.50}", "alpha": 0.12 },
38
+ * "g10": { "value": "{gray.50}", "alpha": 0.12 },
39
+ * "g90": "{gray.50}",
40
+ * "g100": "{gray.50}"
41
+ * }
42
+ * }
43
+ * }
44
+ * }
45
+ *
29
46
  * Output — one flat sibling token per theme, keyed as
30
47
  * `<tokenPath>-_by_theme-<themeName>` so the format functions can reconstruct
31
48
  * the original token name and theme:
@@ -80,9 +97,33 @@ function carbonComponentTokensPreprocessor(dictionary) {
80
97
  if (carbonThemes && value.$value === undefined) {
81
98
  const byTheme = {};
82
99
 
83
- // Preserve original JSON key order (important for 'fallback' position)
84
- for (const [theme, themeValue] of Object.entries(carbonThemes)) {
85
- const alpha = alphaModifiers[theme];
100
+ // Preserve original JSON key order (important for 'fallback' position).
101
+ // Each entry in carbon.themes is either:
102
+ // - a bare string/alias → { "white": "{red.80}" } (legacy component form)
103
+ // - an object { value, alpha? } → unified theme form where value and
104
+ // alpha are co-located under the same theme key.
105
+ // The legacy separate "org.carbon.alphaModifiers" map is still supported
106
+ // for backwards-compat with existing component token files.
107
+ for (const [theme, themeEntry] of Object.entries(carbonThemes)) {
108
+ // An entry can be:
109
+ // 1. A bare string alias/hex → treat as the value directly
110
+ // 2. A composite color object → { colorSpace, components, hex }
111
+ // Used for hardcoded non-palette colors. Treat as the value directly.
112
+ // 3. A { value, alpha? } wrapper object → unified theme format
113
+ const isComposite =
114
+ themeEntry !== null &&
115
+ typeof themeEntry === 'object' &&
116
+ !Array.isArray(themeEntry) &&
117
+ 'hex' in themeEntry;
118
+ const isWrapper =
119
+ themeEntry !== null &&
120
+ typeof themeEntry === 'object' &&
121
+ !Array.isArray(themeEntry) &&
122
+ !isComposite;
123
+ const themeValue = isWrapper ? themeEntry.value : themeEntry;
124
+ // Per-theme alpha: only { value, alpha } wrappers carry alpha.
125
+ // Legacy org.carbon.alphaModifiers map is the fallback for component files.
126
+ const alpha = isWrapper ? themeEntry.alpha : alphaModifiers[theme];
86
127
  const syntheticNode = {
87
128
  $type: value.$type ?? 'color',
88
129
  $value: themeValue,
@@ -95,12 +136,24 @@ function carbonComponentTokensPreprocessor(dictionary) {
95
136
  byTheme[theme] = syntheticNode;
96
137
  }
97
138
 
98
- out[key] = {
139
+ // Build the expanded node: preserve DTCG metadata, add _by_theme, and
140
+ // recurse into any non-$ children (dual-role nodes in themes.json have
141
+ // both a carbon.themes value AND group children like `active`, `hover`).
142
+ const expandedNode = {
99
143
  ...(value.$type ? { $type: value.$type } : {}),
100
144
  ...(value.$description ? { $description: value.$description } : {}),
101
145
  // _by_theme is a DTCG group — each child is a leaf token with $value
102
146
  _by_theme: byTheme,
103
147
  };
148
+ for (const [childKey, childValue] of Object.entries(value)) {
149
+ if (childKey.startsWith('$') || childKey === '_by_theme') continue;
150
+ if (!childValue || typeof childValue !== 'object') continue;
151
+ // Wrap in a single-key object so walk() sees the key and can apply
152
+ // the carbon.themes expansion logic to it (walk iterates the entries
153
+ // of the object passed in, so childValue alone would skip expansion).
154
+ expandedNode[childKey] = walk({ [childKey]: childValue })[childKey];
155
+ }
156
+ out[key] = expandedNode;
104
157
  } else {
105
158
  // Recurse into nested groups
106
159
  out[key] = walk(value);
@@ -9,13 +9,13 @@
9
9
 
10
10
  /**
11
11
  *
12
- * DTCG source files (g10.json, g90.json, g100.json) contain nodes that are
13
- * simultaneously token leaves (they have a `$value`) AND token groups (they
14
- * have non-`$`-prefixed children). Style Dictionary v5 does not support this
15
- * pattern — when a node has `$value`, SD treats it as a leaf token and silently
16
- * drops any non-`$` children.
12
+ * The unified themes.json (and previously the individual per-theme files) can
13
+ * contain nodes that are simultaneously token leaves (they have a `$value`) AND
14
+ * token groups (they have non-`$`-prefixed children). Style Dictionary v5 does
15
+ * not support this pattern — when a node has `$value`, SD treats it as a leaf
16
+ * token and silently drops any non-`$` children.
17
17
  *
18
- * Example (g10.json):
18
+ * Example:
19
19
  *
20
20
  * background: {
21
21
  * $type: "color",
@@ -10,7 +10,7 @@
10
10
  /**
11
11
  *
12
12
  * Extracts the `$extensions["org.carbon"]["color-scheme"]` value that lives
13
- * at the top level of each theme JSON file (white.json, g10.json, etc.) and
13
+ * at the top level of the unified themes.json and
14
14
  * injects it as a synthetic `color-scheme` token, so the SCSS format can
15
15
  * include it in the theme Sass map alongside the regular color tokens.
16
16
  *
@@ -21,7 +21,9 @@ const { default: StyleDictionary } = require('style-dictionary');
21
21
  // ── Custom plugins ────────────────────────────────────────────────────────────
22
22
  const carbonColorFlatten = require('./transforms/color-flatten');
23
23
  const carbonAlphaModifier = require('./transforms/alpha-modifier');
24
- const carbonComponentTokensPreprocessor = require('./preprocessors/component-tokens');
24
+ const carbonComponentTokensPreprocessorModule = require('./preprocessors/component-tokens');
25
+ const { preprocessor: carbonComponentTokensPreprocessor } =
26
+ carbonComponentTokensPreprocessorModule;
25
27
  const carbonThemeMetadataPreprocessor = require('./preprocessors/theme-metadata');
26
28
  const { preprocessor: flattenDualRole } = require('./preprocessors/dual-role');
27
29
  const carbonScssThemes = require('./formats/scss-themes');
@@ -43,6 +45,15 @@ const JS_GENERATED_COMPONENTS = path.join(
43
45
  );
44
46
 
45
47
  const THEME_NAMES = ['white', 'g10', 'g90', 'g100'];
48
+
49
+ // Light/dark scheme per theme — used to re-inject color-scheme metadata after
50
+ // extractThemeSlice strips the root $extensions from the unified file.
51
+ const THEME_COLOR_SCHEME = {
52
+ white: 'light',
53
+ g10: 'light',
54
+ g90: 'dark',
55
+ g100: 'dark',
56
+ };
46
57
  const COMPONENT_NAMES = [
47
58
  'button',
48
59
  'tag',
@@ -51,6 +62,72 @@ const COMPONENT_NAMES = [
51
62
  'content-switcher',
52
63
  ];
53
64
 
65
+ // ── extractThemeSlice ─────────────────────────────────────────────────────────
66
+ //
67
+ // After the component-tokens preprocessor runs on themes.json, every leaf token
68
+ // node becomes a group with a `_by_theme` child containing one synthetic leaf
69
+ // per theme. This function walks the expanded tree and replaces each
70
+ // `_by_theme` group with the single synthetic leaf for `themeName`, collapsing
71
+ // the tree back into a normal per-theme token structure that the rest of the
72
+ // pipeline (flattenDualRole, SD transforms, formatters) understands.
73
+ //
74
+ // Tokens that do NOT have a _by_theme group (palette reference tokens injected
75
+ // via `source:`) are passed through unchanged.
76
+ function extractThemeSlice(node, themeName) {
77
+ if (!node || typeof node !== 'object') return node;
78
+
79
+ const out = {};
80
+ for (const [key, value] of Object.entries(node)) {
81
+ if (key.startsWith('$')) {
82
+ out[key] = value;
83
+ continue;
84
+ }
85
+ if (!value || typeof value !== 'object') {
86
+ out[key] = value;
87
+ continue;
88
+ }
89
+ if (key === '_by_theme') {
90
+ // Skip — handled by the parent via the `value._by_theme` branch below.
91
+ continue;
92
+ }
93
+
94
+ // Check if this child node has a _by_theme group (i.e. it was a leaf or
95
+ // dual-role node in themes.json that the preprocessor expanded).
96
+ if (value._by_theme) {
97
+ const leaf = value._by_theme[themeName];
98
+ if (leaf) {
99
+ // Start with the leaf's DTCG fields ($type, $value, $extensions).
100
+ const merged = { ...leaf };
101
+ // Preserve $description from the group wrapper (leaf synthetic nodes
102
+ // don't carry it, but the original themes.json node does).
103
+ if (!merged.$description && value.$description)
104
+ merged.$description = value.$description;
105
+ if (!merged.$type && value.$type) merged.$type = value.$type;
106
+
107
+ // Recurse into any group children of this node (dual-role case):
108
+ // e.g. `background` is both a token and the parent of `background-active`.
109
+ for (const [childKey, childValue] of Object.entries(value)) {
110
+ if (childKey.startsWith('$') || childKey === '_by_theme') continue;
111
+ if (!childValue || typeof childValue !== 'object') continue;
112
+ const sliced = extractThemeSlice(
113
+ { [childKey]: childValue },
114
+ themeName
115
+ );
116
+ if (sliced[childKey] !== undefined)
117
+ merged[childKey] = sliced[childKey];
118
+ }
119
+
120
+ out[key] = merged;
121
+ }
122
+ // If the theme had no entry, skip the token.
123
+ } else {
124
+ const sliced = extractThemeSlice(value, themeName);
125
+ if (sliced !== null) out[key] = sliced;
126
+ }
127
+ }
128
+ return out;
129
+ }
130
+
54
131
  // ── Custom name transform ─────────────────────────────────────────────────────
55
132
  //
56
133
  // Builds a kebab-case name from the SD token path, with one special rule:
@@ -89,16 +166,46 @@ const CARBON_TRANSFORMS = [
89
166
  ];
90
167
 
91
168
  // ── Helper: build config for one theme ───────────────────────────────────────
169
+ //
170
+ // themes.json is the unified source — all four themes in one file, with each
171
+ // token carrying per-theme values under $extensions["carbon.themes"].
172
+ // The component-tokens preprocessor expands those into per-theme leaf tokens
173
+ // (identical to what it does for component files), and dual-role splitting
174
+ // then runs on the resulting tree so SD sees clean leaf+group pairs.
92
175
  function themeConfig(themeName) {
93
- // Pre-process the theme JSON to handle "dual-role" nodes (nodes that have
94
- // both a $value AND non-$ children). SD v5 treats a node with $value as a
95
- // leaf and silently drops children; flattenDualRole collects ALL leaf tokens
96
- // (including children of dual-role parents) into a flat camelCase structure.
97
- // The palette is kept in `source:` (nested) so SD alias resolution works.
98
- const rawTheme = JSON.parse(
99
- fs.readFileSync(path.join(DTCG_DIR, `${themeName}.json`), 'utf8')
176
+ // Read the unified themes.json. The component-tokens preprocessor first
177
+ // expands carbon.themes entries into _by_theme.<theme> leaf nodes, then
178
+ // flattenDualRole splits any dual-role nodes (value + children) so SD can
179
+ // process them without losing children.
180
+ const rawThemes = JSON.parse(
181
+ fs.readFileSync(path.join(DTCG_DIR, 'themes.json'), 'utf8')
100
182
  );
101
- const flatTokens = flattenDualRole(rawTheme);
183
+ // The component-tokens preprocessor understands the { value, alpha } entry
184
+ // shape from the unified file as well as the legacy bare-string shape from
185
+ // component files. Run it first so carbon.themes entries become $value leaves.
186
+ const expanded = carbonComponentTokensPreprocessor(rawThemes);
187
+ // Now extract only this theme's _by_theme.<themeName> leaf nodes, rewriting
188
+ // them back to plain $value tokens so the rest of the pipeline sees a normal
189
+ // single-theme token tree.
190
+ const rawSlice = extractThemeSlice(expanded, themeName);
191
+
192
+ // Re-attach the per-theme color-scheme to the root $extensions so that
193
+ // carbonThemeMetadataPreprocessor (which reads
194
+ // dictionary.$extensions["org.carbon"]["color-scheme"]) can inject the
195
+ // color-scheme synthetic token. extractThemeSlice does not carry the root
196
+ // $extensions through because they are global, not per-token.
197
+ const themeSlice = {
198
+ ...rawSlice,
199
+ $extensions: {
200
+ ...(rawSlice.$extensions ?? {}),
201
+ 'org.carbon': {
202
+ ...(rawSlice.$extensions?.['org.carbon'] ?? {}),
203
+ 'color-scheme': THEME_COLOR_SCHEME[themeName],
204
+ },
205
+ },
206
+ };
207
+
208
+ const flatTokens = flattenDualRole(themeSlice);
102
209
 
103
210
  return {
104
211
  source: [path.join(DTCG_DIR, 'color-palette.json')],
@@ -246,7 +353,7 @@ function createBase() {
246
353
  name: 'carbon',
247
354
  transforms: CARBON_TRANSFORMS,
248
355
  });
249
- base.registerPreprocessor(carbonComponentTokensPreprocessor);
356
+ base.registerPreprocessor(carbonComponentTokensPreprocessorModule);
250
357
  base.registerPreprocessor(carbonThemeMetadataPreprocessor);
251
358
  base.registerFormat(carbonScssThemes);
252
359
  base.registerFormat(carbonScssTokens);
@@ -19,7 +19,7 @@
19
19
  * If no alphaModifier extension is present the token is passed through
20
20
  * unchanged (carbon/color-flatten will handle it next).
21
21
  *
22
- * Example in white.json:
22
+ * Example in themes.json:
23
23
  * "background-active": {
24
24
  * "$type": "color",
25
25
  * "$value": "{gray.50}", ← resolved by SD to '#8d8d8d'