@massimo-cassandro/minimo 1.37.0 → 1.38.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.
@@ -4,18 +4,25 @@
4
4
  // light/dark custom-properties split.
5
5
  //
6
6
  // For each mode, a separate StyleDictionary instance is built from that
7
- // mode's own `source` array. The generated `:root { ... }` CSS blocks are
8
- // then composed into a single destFile:
9
- // - the base mode's block stays as a plain `:root { ... }` rule, with a
10
- // `color-scheme: <all modes joined>;` declaration prepended
11
- // (e.g. "light dark")
7
+ // mode's own `source` array. The generated `<selector> { ... }` CSS blocks
8
+ // (selector: customPropsSelector, default ':root' — see config.mjs) are then
9
+ // composed into a single destFile:
10
+ // - the base mode's block stays as a plain `<selector> { ... }` rule, with
11
+ // a `color-scheme: <all modes joined>;` declaration prepended (e.g.
12
+ // "light dark")
12
13
  // - every other mode's block is wrapped in
13
- // `@media (prefers-color-scheme: <mode>) { :root { ... } }`, with a
14
+ // `@media (prefers-color-scheme: <mode>) { <selector> { ... } }`, with a
14
15
  // `color-scheme: <mode>;` declaration prepended
15
16
  // customPropsGroups, mergeCustomProps and pxToRem apply per mode, exactly as
16
17
  // in the single-source build. addLayer (if set) wraps the whole composed
17
18
  // result in a single `@layer`, rather than one per mode.
18
19
  //
20
+ // When sourceModes defines both a `light` and a `dark` key and
21
+ // useLightDarkFunc is true (default), custom properties shared by both are
22
+ // reconciled into a single `light-dark()` declaration instead of being split
23
+ // across the base block and a `@media (prefers-color-scheme: dark)` rule —
24
+ // see light-dark.mjs.
25
+ //
19
26
  // JSON tokens (if jsonBuildPath is set) are produced per mode: one set of
20
27
  // files per mode, with `-<mode>` appended to each filename (see
21
28
  // buildJsonFiles() in formats/json.mjs), e.g. "tokens-light.jsonc" /
@@ -26,15 +33,16 @@ import StyleDictionary from 'style-dictionary';
26
33
  import * as path from 'node:path';
27
34
  import { mkdir, writeFile } from 'node:fs/promises';
28
35
  import { CSS_TRANSFORMS, JSON_TRANSFORMS } from './platforms.mjs';
29
- import { customPropsCount } from './formats/css.mjs';
36
+ import { customPropsCount, lastFinalProps } from './formats/css.mjs';
30
37
  import { buildJsonFiles, collectConcreteFilePaths } from './formats/json.mjs';
31
38
  import { loadExistingCustomPropsScoped } from './merge-css.mjs';
32
39
  import { LEGACY_TOKENS_PARSER_NAME } from './legacy-tokens-parser.mjs';
40
+ import { applyLightDarkFunc } from './light-dark.mjs';
33
41
 
34
42
  /**
35
43
  * @param {object} opts
36
44
  * @param {Record<string,string[]>} opts.sourceModes Resolved source paths per mode (see config.mjs)
37
- * @param {string} opts.baseMode Mode whose block stays a top-level `:root { ... }` rule
45
+ * @param {string} opts.baseMode Mode whose block stays a top-level `<selector> { ... }` rule
38
46
  * @param {string} opts.buildPath Absolute path for CSS output directory
39
47
  * @param {string} opts.destFile CSS output filename
40
48
  * @param {string|null} opts.jsonBuildPath Absolute path for JSON output (null = disabled)
@@ -45,6 +53,8 @@ import { LEGACY_TOKENS_PARSER_NAME } from './legacy-tokens-parser.mjs';
45
53
  * @param {boolean} opts.pxToRem
46
54
  * @param {string|null} opts.addLayer
47
55
  * @param {boolean|(string|RegExp)[]} opts.mergeCustomProps true = merge all sources, array = only the matching token source files
56
+ * @param {boolean} opts.useLightDarkFunc reconcile shared `light`/`dark` properties into `light-dark()` calls (default: true — see config.mjs and light-dark.mjs). Ignored unless sourceModes defines both a `light` and a `dark` key.
57
+ * @param {string} opts.customPropsSelector CSS selector wrapping the generated block(s) (default: ':root' — see config.mjs)
48
58
  * @returns {Promise<{cssDestPath: string, totalCustomProps: number, jsonFilesByMode: Record<string, object[]>}>}
49
59
  */
50
60
  export const buildSourceModes = async ({
@@ -60,13 +70,15 @@ export const buildSourceModes = async ({
60
70
  pxToRem,
61
71
  addLayer,
62
72
  mergeCustomProps,
73
+ useLightDarkFunc,
74
+ customPropsSelector,
63
75
  }) => {
64
76
  const modeNames = Object.keys(sourceModes);
65
77
 
66
78
  // Read the pre-existing destFile (if any) BEFORE any Style Dictionary
67
79
  // instance runs, since the first one to write would overwrite it.
68
80
  if (mergeCustomProps) {
69
- loadExistingCustomPropsScoped(path.join(buildPath, destFile), baseMode, mergeCustomProps);
81
+ loadExistingCustomPropsScoped(path.join(buildPath, destFile), baseMode, mergeCustomProps, customPropsSelector);
70
82
  }
71
83
 
72
84
  const cssTransforms = pxToRem
@@ -75,13 +87,22 @@ export const buildSourceModes = async ({
75
87
 
76
88
  /** @type {Record<string,string>} */
77
89
  const modeBlocks = {};
78
- let totalCustomProps = 0;
90
+ // Per-mode custom-prop count and structured (name -> tail) props map,
91
+ // captured right after each per-mode format call (customPropsCount and
92
+ // lastFinalProps are live bindings, updated synchronously by the format
93
+ // function — see formats/css.mjs). Kept per mode (rather than summed on
94
+ // the fly) so applyLightDarkFunc() can later overwrite just the entries it
95
+ // actually touches (light, dark, and the base mode).
96
+ /** @type {Record<string,number>} */
97
+ const modeCounts = {};
98
+ /** @type {Record<string, Record<string,string>>} */
99
+ const modeFinalProps = {};
79
100
  /** @type {Record<string, object[]>} */
80
101
  const jsonFilesByMode = {};
81
102
 
82
103
  for (const mode of modeNames) {
83
104
  // Non-base modes may reference base-mode tokens (always defined, since
84
- // the base block is the unconditional top-level `:root`). The base
105
+ // the base block is the unconditional top-level `<selector>`). The base
85
106
  // sources are passed as `include`: available for reference resolution
86
107
  // but flagged isSource: false, and skipped by the css/json formats.
87
108
  // Base-mode-only on purpose: a reference to a token that exists only in
@@ -102,6 +123,7 @@ export const buildSourceModes = async ({
102
123
  format: 'css/variables-sorted',
103
124
  options: {
104
125
  outputReferences: true,
126
+ selector: customPropsSelector,
105
127
  customPropsGroups,
106
128
  mode,
107
129
  colorScheme: mode === baseMode ? modeNames.join(' ') : mode,
@@ -116,9 +138,10 @@ export const buildSourceModes = async ({
116
138
  // the composed multi-mode file is written once, at the end, below.
117
139
  const [formatted] = await sd.formatPlatform('css');
118
140
  modeBlocks[mode] = /** @type {string} */ (formatted.output);
119
- // customPropsCount is a live binding, updated synchronously by the
120
- // format function called above (see formats/css.mjs).
121
- totalCustomProps += customPropsCount;
141
+ // customPropsCount / lastFinalProps are live bindings, updated
142
+ // synchronously by the format function called above (see formats/css.mjs).
143
+ modeCounts[mode] = customPropsCount;
144
+ modeFinalProps[mode] = lastFinalProps;
122
145
 
123
146
  if (jsonBuildPath) {
124
147
  const concreteFilePaths = jsonDestFile ? [] : await collectConcreteFilePaths(sd);
@@ -142,6 +165,26 @@ export const buildSourceModes = async ({
142
165
  }
143
166
  }
144
167
 
168
+ // ── light-dark() reconciliation (light/dark modes only) ──────────────────
169
+ // Rebuilds just the `light`, `dark` and base-mode blocks from the adjusted
170
+ // props maps — see light-dark.mjs. No-op (returns null) unless sourceModes
171
+ // defines both a `light` and a `dark` key.
172
+ if (useLightDarkFunc) {
173
+ const reconciled = applyLightDarkFunc({
174
+ modeFinalProps,
175
+ modeNames,
176
+ baseMode,
177
+ selector: customPropsSelector,
178
+ customPropsGroups,
179
+ });
180
+ if (reconciled) {
181
+ Object.assign(modeBlocks, reconciled.blocks);
182
+ Object.assign(modeCounts, reconciled.counts);
183
+ }
184
+ }
185
+
186
+ const totalCustomProps = Object.values(modeCounts).reduce((sum, n) => sum + n, 0);
187
+
145
188
  // ── Compose the final CSS ────────────────────────────────────────────────
146
189
  const otherModes = modeNames.filter((mode) => mode !== baseMode);
147
190
 
@@ -103,6 +103,26 @@ export const mergeCustomProps = (() => {
103
103
  // Set to null, undefined or false (default) to emit no layer.
104
104
  export const addLayer = buildConfig.addLayer ? String(buildConfig.addLayer).trim() : null;
105
105
 
106
+ // customPropsSelector: CSS selector wrapping the generated custom properties
107
+ // block(s) (see formats/css.mjs buildCssBlock()), instead of the default
108
+ // `:root`. Applies uniformly to a single-source build and to a sourceModes
109
+ // build (the base mode's top-level block and every other mode's block
110
+ // nested under its own `@media (prefers-color-scheme: <mode>)` rule all use
111
+ // the same selector). Falsy or blank values fall back to ':root'.
112
+ export const customPropsSelector = typeof buildConfig.customPropsSelector === 'string'
113
+ && buildConfig.customPropsSelector.trim()
114
+ ? buildConfig.customPropsSelector.trim()
115
+ : ':root';
116
+
117
+ // useLightDarkFunc: with sourceModes set (see below) and defining both a
118
+ // `light` and a `dark` key, custom properties present in both are written
119
+ // once as `--name: light-dark(<light>, <dark>);` instead of being split
120
+ // across the base mode's block and a
121
+ // `@media (prefers-color-scheme: dark) { ... }` rule (see light-dark.mjs).
122
+ // Ignored when sourceModes is not set, or does not define both a light and a
123
+ // dark mode. Default: true.
124
+ export const useLightDarkFunc = buildConfig.useLightDarkFunc !== false;
125
+
106
126
  // customPropsGroups: custom properties whose first hyphen-separated name
107
127
  // segment matches one of a group's prefixes are pulled out, labelled with
108
128
  // that group's name and moved to the beginning of the generated CSS file, in
@@ -155,217 +155,278 @@ const buildGroupTitle = (name) => {
155
155
  // Exported counter — read by build-tokens.mjs for the final log line
156
156
  export let customPropsCount = 0;
157
157
 
158
- StyleDictionary.registerFormat({
159
- name: 'css/variables-sorted',
160
- format: ({ dictionary, options }) => {
161
- const selector = options.selector ?? ':root';
158
+ // Exported live binding holding the name → declaration-tail map last computed
159
+ // by computeFinalProps() (see below), i.e. the structured result of the most
160
+ // recent 'css/variables-sorted' format call. Read by
161
+ // build-tokens-src/build-source-modes.mjs right after each per-mode
162
+ // sd.formatPlatform('css') call (synchronous, same pattern as
163
+ // customPropsCount) — needed by build-tokens-src/light-dark.mjs to reconcile
164
+ // shared light/dark properties into light-dark() calls, working on the
165
+ // structured props instead of the rendered CSS text.
166
+ export let lastFinalProps = {};
167
+
168
+ /**
169
+ * Computes the final `name -> declaration-tail` map for a token dictionary:
170
+ * resolves each token's CSS value (including the complex-type builders above),
171
+ * then merges in any pre-existing custom properties via mergeCustomProps()
172
+ * (see ../merge-css.mjs and the `mergeCustomProps` config option).
173
+ * @param {object} dictionary Style Dictionary's resolved token dictionary (format() `dictionary` arg)
174
+ * @param {object} options format() `options` arg — only `outputReferences` and `mode` are read here
175
+ * @returns {Record<string,string>} name → declaration tail (value + terminating `;` + optional trailing comment)
176
+ */
177
+ export const computeFinalProps = (dictionary, options) => {
178
+ const tokenByPath = {};
179
+ for (const token of dictionary.allTokens) {
180
+ tokenByPath[token.path.join('.')] = token;
181
+ }
182
+
183
+ const resolveRefs = makeResolveRefs(tokenByPath);
184
+
185
+ const sequenceList = [
186
+ 'xxs', 'xs', 'sm', 'base', 'md', 'lg', 'xl', 'xxl', 'xxxl', '2xl', '3xl',
187
+ 'xlight', 'light', 'regular', 'medium', 'semibold', 'bold', 'xbold'
188
+ ];
189
+
190
+ const sortFunction = (a, b) => {
191
+ const idxA = sequenceList.indexOf(a.name);
192
+ const idxB = sequenceList.indexOf(b.name);
193
+
194
+ // Entrambi nella lista → ordine arbitrario
195
+ if (idxA !== -1 && idxB !== -1) return idxA - idxB;
196
+
197
+ // Solo A nella lista → A viene prima
198
+ if (idxA !== -1) return -1;
199
+
200
+ // Solo B nella lista → B viene prima
201
+ if (idxB !== -1) return 1;
162
202
 
163
- const tokenByPath = {};
164
- for (const token of dictionary.allTokens) {
165
- tokenByPath[token.path.join('.')] = token;
203
+ // Nessuno nella lista → ordine alfabetico
204
+ return a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' });
205
+ };
206
+
207
+ // Tokens loaded via `include` (sourceModes: base-mode tokens made
208
+ // available to other modes) stay in tokenByPath, so references to them
209
+ // resolve to var(--name), but are not declared in this mode's block.
210
+ // property name → token source file, used by mergeCustomProps when the
211
+ // merge is limited to some source files (a token may generate several
212
+ // properties, e.g. typography)
213
+ /** @type {Record<string,string>} */
214
+ const sourceFileMap = {};
215
+ const trackSource = (token, tokenLines) => {
216
+ for (const name of Object.keys(parseCustomProps(tokenLines.join('\n')))) {
217
+ sourceFileMap[name] = token.filePath;
166
218
  }
219
+ return tokenLines;
220
+ };
167
221
 
168
- const resolveRefs = makeResolveRefs(tokenByPath);
169
-
170
- const sequenceList = [
171
- 'xxs', 'xs', 'sm', 'base', 'md', 'lg', 'xl', 'xxl', 'xxxl', '2xl', '3xl',
172
- 'xlight', 'light', 'regular', 'medium', 'semibold', 'bold', 'xbold'
173
- ];
174
-
175
- const sortFunction = (a, b) => {
176
- const idxA = sequenceList.indexOf(a.name);
177
- const idxB = sequenceList.indexOf(b.name);
178
-
179
- // Entrambi nella lista → ordine arbitrario
180
- if (idxA !== -1 && idxB !== -1) return idxA - idxB;
181
-
182
- // Solo A nella lista → A viene prima
183
- if (idxA !== -1) return -1;
184
-
185
- // Solo B nella lista → B viene prima
186
- if (idxB !== -1) return 1;
187
-
188
- // Nessuno nella lista → ordine alfabetico
189
- return a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' });
190
- };
191
-
192
- // Tokens loaded via `include` (sourceModes: base-mode tokens made
193
- // available to other modes) stay in tokenByPath, so references to them
194
- // resolve to var(--name), but are not declared in this mode's block.
195
- // property name → token source file, used by mergeCustomProps when the
196
- // merge is limited to some source files (a token may generate several
197
- // properties, e.g. typography)
198
- /** @type {Record<string,string>} */
199
- const sourceFileMap = {};
200
- const trackSource = (token, tokenLines) => {
201
- for (const name of Object.keys(parseCustomProps(tokenLines.join('\n')))) {
202
- sourceFileMap[name] = token.filePath;
222
+ const lines = dictionary.allTokens
223
+ .filter((token) => token.isSource !== false)
224
+ // .sort((a, b) => a.name.localeCompare(b.name, 'en', {numeric: true, sensitivity: 'base'}))
225
+ .sort((a, b) => sortFunction(a,b))
226
+ .flatMap((token) => {
227
+ const type = token.$type ?? token.type;
228
+ const orig = token.original?.$value ?? token.original?.value;
229
+ const description = token.$description ?? token.description ?? token.comment;
230
+ const commentSuffix = buildDescriptionComment(description);
231
+
232
+ if (type === 'typography') {
233
+ const typographyLines = buildTypography(token.name, orig ?? {}, resolveRefs)
234
+ .split('\n')
235
+ .map((line) => `${line}${commentSuffix}`)
236
+ .join('\n');
237
+ return trackSource(token, [typographyLines]);
203
238
  }
204
- return tokenLines;
205
- };
206
-
207
- const lines = dictionary.allTokens
208
- .filter((token) => token.isSource !== false)
209
- // .sort((a, b) => a.name.localeCompare(b.name, 'en', {numeric: true, sensitivity: 'base'}))
210
- .sort((a, b) => sortFunction(a,b))
211
- .flatMap((token) => {
212
- const type = token.$type ?? token.type;
213
- const orig = token.original?.$value ?? token.original?.value;
214
- const description = token.$description ?? token.description ?? token.comment;
215
- const commentSuffix = buildDescriptionComment(description);
216
-
217
- if (type === 'typography') {
218
- const typographyLines = buildTypography(token.name, orig ?? {}, resolveRefs)
219
- .split('\n')
220
- .map((line) => `${line}${commentSuffix}`)
221
- .join('\n');
222
- return trackSource(token, [typographyLines]);
223
- }
224
-
225
- let value;
226
-
227
- if (type === 'shadow' && options.outputReferences) {
228
- value = (typeof orig === 'string' && orig.startsWith('{'))
229
- ? resolveRefs(orig) : buildShadow(orig, resolveRefs);
230
-
231
- } else if (type === 'gradient' && options.outputReferences) {
232
- value = (typeof orig === 'string' && orig.startsWith('{'))
233
- ? resolveRefs(orig) : buildGradient(orig, resolveRefs);
234
-
235
- } else if ((type === 'border' || type === 'outline') && options.outputReferences) {
236
- value = (typeof orig === 'string' && orig.startsWith('{'))
237
- ? resolveRefs(orig) : buildBorderLike(orig, resolveRefs);
238
-
239
- } else if (type === 'transition' && options.outputReferences) {
240
- value = (typeof orig === 'string' && orig.startsWith('{'))
241
- ? resolveRefs(orig) : buildTransition(orig, resolveRefs);
242
-
243
- } else if (type === 'animation' && options.outputReferences) {
244
- value = (typeof orig === 'string' && orig.startsWith('{'))
245
- ? resolveRefs(orig) : buildAnimation(orig, resolveRefs);
246
-
247
- } else if (options.outputReferences && typeof orig === 'string' && orig.includes('{')) {
248
- // Covers both plain aliases and CSS functions containing {references}
249
- // e.g. color-mix(in srgb, {btn.secondary.background.color} 60%, #000)
250
- // {references} are replaced with var(--name); arithmetic gets calc().
251
- const resolved = resolveRefs(orig);
252
- // Strip var(...) and CSS function calls (word chars + hyphens followed by '(')
253
- // before checking for arithmetic operators, so that hyphens in names like
254
- // "color-mix" or "linear-gradient" don't trigger a spurious calc() wrap.
255
- const stripped = resolved
256
- .replace(/var\([^)]+\)/g, '0')
257
- .replace(/[\w-]+\(/g, '(');
258
- const isCalcNeeded = /[+\-*/]/.test(stripped);
259
- value = isCalcNeeded ? `calc(${resolved})` : resolved;
260
-
261
- } else {
262
- value = String(token.$value ?? token.value);
263
- }
264
-
265
- // Multi-line values (e.g. Open Props' linear() easings) are collapsed
266
- // to a single line: declarations are parsed one line at a time (see
267
- // parseCustomProps in ../merge-css.mjs), so a multi-line value would
268
- // be truncated to its first line.
269
- value = String(value)
270
- .replace(/\s*\n\s*/g, ' ')
271
- .replace(/\(\s+/g, '(')
272
- .replace(/\s+\)/g, ')');
273
-
274
- return trackSource(token, [` --${token.name}: ${value};${commentSuffix}`]);
275
- });
276
-
277
- // Build a name → declaration-tail map from the declarations generated
278
- // above, then merge with any pre-existing custom properties loaded via
279
- // loadExistingCustomProps() / loadExistingCustomPropsScoped() (see
280
- // ../merge-css.mjs). Pre-existing values (including trailing comments)
281
- // take priority when both exist; pre-existing-only properties are kept.
282
- // options.mode (sourceModes build only, see ../build-source-modes.mjs)
283
- // scopes the merge to that mode, so same-named props with different
284
- // values across modes are never mixed up.
285
- const generatedProps = parseCustomProps(lines.join('\n'));
286
- const finalProps = mergeCustomProps(generatedProps, options.mode ?? null, sourceFileMap);
287
-
288
- // Final output is always sorted alphabetically ascending by property
289
- // name, regardless of merge — this also keeps pre-existing-only
290
- // properties from merge-css.mjs in order instead of trailing at the end.
291
- const sortedProps = Object.entries(finalProps)
292
- .sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true, sensitivity: 'base' }));
293
-
294
- // customPropsGroups: properties whose first hyphen-separated name segment
295
- // matches one of a group's prefixes are pulled out, labelled with that
296
- // group's name and placed, in group-list order, at the beginning of the
297
- // output file. A property matches the first group (in list order) whose
298
- // prefixes include it.
299
- const customPropsGroups = options.customPropsGroups ?? [];
300
- const groupedProps = customPropsGroups.map(() => []);
301
- const restProps = [];
302
- for (const entry of sortedProps) {
303
- const prefix = entry[0].split('-')[0];
304
- const groupIndex = customPropsGroups.findIndex((g) => g.prefixes?.includes(prefix));
305
- if (groupIndex !== -1) {
306
- groupedProps[groupIndex].push(entry);
239
+
240
+ let value;
241
+
242
+ if (type === 'shadow' && options.outputReferences) {
243
+ value = (typeof orig === 'string' && orig.startsWith('{'))
244
+ ? resolveRefs(orig) : buildShadow(orig, resolveRefs);
245
+
246
+ } else if (type === 'gradient' && options.outputReferences) {
247
+ value = (typeof orig === 'string' && orig.startsWith('{'))
248
+ ? resolveRefs(orig) : buildGradient(orig, resolveRefs);
249
+
250
+ } else if ((type === 'border' || type === 'outline') && options.outputReferences) {
251
+ value = (typeof orig === 'string' && orig.startsWith('{'))
252
+ ? resolveRefs(orig) : buildBorderLike(orig, resolveRefs);
253
+
254
+ } else if (type === 'transition' && options.outputReferences) {
255
+ value = (typeof orig === 'string' && orig.startsWith('{'))
256
+ ? resolveRefs(orig) : buildTransition(orig, resolveRefs);
257
+
258
+ } else if (type === 'animation' && options.outputReferences) {
259
+ value = (typeof orig === 'string' && orig.startsWith('{'))
260
+ ? resolveRefs(orig) : buildAnimation(orig, resolveRefs);
261
+
262
+ } else if (options.outputReferences && typeof orig === 'string' && orig.includes('{')) {
263
+ // Covers both plain aliases and CSS functions containing {references}
264
+ // e.g. color-mix(in srgb, {btn.secondary.background.color} 60%, #000)
265
+ // {references} are replaced with var(--name); arithmetic gets calc().
266
+ const resolved = resolveRefs(orig);
267
+ // Strip var(...) and CSS function calls (word chars + hyphens followed by '(')
268
+ // before checking for arithmetic operators, so that hyphens in names like
269
+ // "color-mix" or "linear-gradient" don't trigger a spurious calc() wrap.
270
+ const stripped = resolved
271
+ .replace(/var\([^)]+\)/g, '0')
272
+ .replace(/[\w-]+\(/g, '(');
273
+ const isCalcNeeded = /[+\-*/]/.test(stripped);
274
+ value = isCalcNeeded ? `calc(${resolved})` : resolved;
275
+
307
276
  } else {
308
- restProps.push(entry);
277
+ value = String(token.$value ?? token.value);
309
278
  }
310
- }
311
- groupedProps.forEach((props, i) => {
312
- const prefixes = customPropsGroups[i].prefixes;
313
- props.sort(([a], [b]) => {
314
- const idxA = prefixes.indexOf(a.split('-')[0]);
315
- const idxB = prefixes.indexOf(b.split('-')[0]);
316
- if (idxA !== idxB) return idxA - idxB;
317
- return a.localeCompare(b, 'en', { numeric: true, sensitivity: 'base' });
318
- });
279
+
280
+ // Multi-line values (e.g. Open Props' linear() easings) are collapsed
281
+ // to a single line: declarations are parsed one line at a time (see
282
+ // parseCustomProps in ../merge-css.mjs), so a multi-line value would
283
+ // be truncated to its first line.
284
+ value = String(value)
285
+ .replace(/\s*\n\s*/g, ' ')
286
+ .replace(/\(\s+/g, '(')
287
+ .replace(/\s+\)/g, ')');
288
+
289
+ return trackSource(token, [` --${token.name}: ${value};${commentSuffix}`]);
319
290
  });
320
291
 
321
- // A blank line is inserted between blocks of properties sharing the same
322
- // first hyphen-separated segment (e.g. all `btn-*` together), to visually
323
- // group related custom properties in the generated file.
324
- const outLines = [];
325
-
326
- // options.colorScheme (sourceModes build only, see ../build-source-modes.mjs):
327
- // prepends a `color-scheme: <value>;` declaration, e.g. "light dark" for
328
- // the base mode's block, or the mode's own name (e.g. "dark") for a
329
- // block nested inside its @media (prefers-color-scheme: <mode>) rule.
330
- if (options.colorScheme) {
331
- outLines.push(` color-scheme: ${options.colorScheme};`, '');
292
+ // Build a name → declaration-tail map from the declarations generated
293
+ // above, then merge with any pre-existing custom properties loaded via
294
+ // loadExistingCustomProps() / loadExistingCustomPropsScoped() (see
295
+ // ../merge-css.mjs). Pre-existing values (including trailing comments)
296
+ // take priority when both exist; pre-existing-only properties are kept.
297
+ // options.mode (sourceModes build only, see ../build-source-modes.mjs)
298
+ // scopes the merge to that mode, so same-named props with different
299
+ // values across modes are never mixed up.
300
+ const generatedProps = parseCustomProps(lines.join('\n'));
301
+ return mergeCustomProps(generatedProps, options.mode ?? null, sourceFileMap);
302
+ };
303
+
304
+ /**
305
+ * Builds the final CSS block text (`<selector> { ... }`) from an already
306
+ * computed name → declaration-tail map (see computeFinalProps() above):
307
+ * sorts properties alphabetically, pulls out customPropsGroups, prepends an
308
+ * optional `color-scheme` declaration, and optionally wraps the result in
309
+ * `@layer <addLayer> { ... }`.
310
+ *
311
+ * Extracted as its own function (rather than inline in the format() call
312
+ * below) so build-tokens-src/light-dark.mjs can re-run it on an adjusted
313
+ * props map — e.g. after moving properties shared by `light` and `dark` into
314
+ * a single `light-dark()` declaration — and get back a properly sorted and
315
+ * grouped block, instead of appending raw text after the fact.
316
+ * @param {object} opts
317
+ * @param {Record<string,string>} opts.finalProps name → declaration tail, as returned by computeFinalProps()
318
+ * @param {string} [opts.selector] CSS selector wrapping the block (default: (default: ':root'))
319
+ * @param {string} [opts.colorScheme] optional value for a `color-scheme: <value>;` declaration prepended to the block
320
+ * @param {{name:string,prefixes:string[]}[]} [opts.customPropsGroups] named groups of name prefixes, moved to the top of the block in list order (default: [])
321
+ * @param {string|null} [opts.addLayer] wraps the block in `@layer <addLayer> { ... }` (default: null — no layer)
322
+ * @returns {string}
323
+ * @example
324
+ * buildCssBlock({
325
+ * finalProps: { 'accent-color': '#123;' },
326
+ * selector: ':root', // default: ':root'
327
+ * colorScheme: 'light dark', // default: undefined — no color-scheme line
328
+ * customPropsGroups: [], // default: []
329
+ * addLayer: null, // default: null
330
+ * });
331
+ */
332
+ export const buildCssBlock = ({
333
+ finalProps,
334
+ selector = ':root',
335
+ colorScheme,
336
+ customPropsGroups = [],
337
+ addLayer = null,
338
+ }) => {
339
+ // Final output is always sorted alphabetically ascending by property
340
+ // name, regardless of merge — this also keeps pre-existing-only
341
+ // properties from merge-css.mjs in order instead of trailing at the end.
342
+ const sortedProps = Object.entries(finalProps)
343
+ .sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true, sensitivity: 'base' }));
344
+
345
+ // customPropsGroups: properties whose first hyphen-separated name segment
346
+ // matches one of a group's prefixes are pulled out, labelled with that
347
+ // group's name and placed, in group-list order, at the beginning of the
348
+ // output file. A property matches the first group (in list order) whose
349
+ // prefixes include it.
350
+ const groupedProps = customPropsGroups.map(() => []);
351
+ const restProps = [];
352
+ for (const entry of sortedProps) {
353
+ const prefix = entry[0].split('-')[0];
354
+ const groupIndex = customPropsGroups.findIndex((g) => g.prefixes?.includes(prefix));
355
+ if (groupIndex !== -1) {
356
+ groupedProps[groupIndex].push(entry);
357
+ } else {
358
+ restProps.push(entry);
332
359
  }
360
+ }
361
+ groupedProps.forEach((props, i) => {
362
+ const prefixes = customPropsGroups[i].prefixes;
363
+ props.sort(([a], [b]) => {
364
+ const idxA = prefixes.indexOf(a.split('-')[0]);
365
+ const idxB = prefixes.indexOf(b.split('-')[0]);
366
+ if (idxA !== idxB) return idxA - idxB;
367
+ return a.localeCompare(b, 'en', { numeric: true, sensitivity: 'base' });
368
+ });
369
+ });
370
+
371
+ // A blank line is inserted between blocks of properties sharing the same
372
+ // first hyphen-separated segment (e.g. all `btn-*` together), to visually
373
+ // group related custom properties in the generated file.
374
+ const outLines = [];
333
375
 
334
- let prevPrefix = null;
335
- const appendBlock = (props) => {
336
- for (const [name, tail] of props) {
337
- const prefix = name.split('-')[0];
338
- if (prevPrefix !== null && prefix !== prevPrefix) {
339
- outLines.push('');
340
- }
341
- outLines.push(` --${name}: ${tail}`);
342
- prevPrefix = prefix;
376
+ if (colorScheme) {
377
+ outLines.push(` color-scheme: ${colorScheme};`, '');
378
+ }
379
+
380
+ let prevPrefix = null;
381
+ const appendBlock = (props) => {
382
+ for (const [name, tail] of props) {
383
+ const prefix = name.split('-')[0];
384
+ if (prevPrefix !== null && prefix !== prevPrefix) {
385
+ outLines.push('');
343
386
  }
344
- };
345
-
346
- const hasGroups = groupedProps.some((props) => props.length);
347
- let isFirstBlock = true;
348
- groupedProps.forEach((props, i) => {
349
- if (!props.length) return;
350
- if (!isFirstBlock) outLines.push('');
351
- isFirstBlock = false;
352
- outLines.push(buildGroupTitle(customPropsGroups[i].name));
353
- appendBlock(props);
354
- prevPrefix = null;
355
- });
356
- if (hasGroups && restProps.length) {
357
- outLines.push('', ' /* ---------------------- */', '');
358
- prevPrefix = null;
387
+ outLines.push(` --${name}: ${tail}`);
388
+ prevPrefix = prefix;
359
389
  }
360
- appendBlock(restProps);
390
+ };
361
391
 
362
- customPropsCount = sortedProps.length;
363
- const block = `${selector} {\n${outLines.join('\n')}\n}\n`;
392
+ const hasGroups = groupedProps.some((props) => props.length);
393
+ let isFirstBlock = true;
394
+ groupedProps.forEach((props, i) => {
395
+ if (!props.length) return;
396
+ if (!isFirstBlock) outLines.push('');
397
+ isFirstBlock = false;
398
+ outLines.push(buildGroupTitle(customPropsGroups[i].name));
399
+ appendBlock(props);
400
+ prevPrefix = null;
401
+ });
402
+ if (hasGroups && restProps.length) {
403
+ outLines.push('', ' /* ---------------------- */', '');
404
+ prevPrefix = null;
405
+ }
406
+ appendBlock(restProps);
407
+
408
+ const block = `${selector} {\n${outLines.join('\n')}\n}\n`;
409
+
410
+ // addLayer: wraps the whole block inside `@layer <name> { ... }`.
411
+ // Indentation is left to stylelint's fix step (run right after the build).
412
+ return addLayer
413
+ ? `@layer ${addLayer} {\n\n${block}}\n`
414
+ : block;
415
+ };
364
416
 
365
- // addLayer: wraps the whole block inside `@layer <name> { ... }`.
366
- // Indentation is left to stylelint's fix step (run right after the build).
367
- return options.addLayer
368
- ? `@layer ${options.addLayer} {\n\n${block}}\n`
369
- : block;
417
+ StyleDictionary.registerFormat({
418
+ name: 'css/variables-sorted',
419
+ format: ({ dictionary, options }) => {
420
+ const finalProps = computeFinalProps(dictionary, options);
421
+ lastFinalProps = finalProps;
422
+ customPropsCount = Object.keys(finalProps).length;
423
+
424
+ return buildCssBlock({
425
+ finalProps,
426
+ selector: options.selector,
427
+ colorScheme: options.colorScheme,
428
+ customPropsGroups: options.customPropsGroups,
429
+ addLayer: options.addLayer,
430
+ });
370
431
  },
371
432
  });
@@ -0,0 +1,126 @@
1
+ // build-tokens-src/light-dark.mjs
2
+ // Supports the `useLightDarkFunc` config option (see config.mjs): with a
3
+ // sourceModes build (see build-source-modes.mjs) that defines both a `light`
4
+ // and a `dark` mode, custom properties present in BOTH — matched by name —
5
+ // are written once, as `--name: light-dark(<light-value>, <dark-value>);`,
6
+ // instead of being split across the base mode's top-level `:root { ... }`
7
+ // block and a `@media (prefers-color-scheme: dark) { ... }` block.
8
+ //
9
+ // Only `light` and `dark` are affected:
10
+ // - a third mode (e.g. a custom `high-contrast` key in sourceModes) is left
11
+ // completely untouched, still nested under its own
12
+ // `@media (prefers-color-scheme: <mode>) { ... }` rule
13
+ // - a property present in only one of `light`/`dark` is left in its own
14
+ // mode's block — still behind that mode's `@media` rule, unless that mode
15
+ // happens to be the configured base mode
16
+ //
17
+ // The shared `light-dark()` declarations always land in the configured base
18
+ // mode's block (build-source-modes.mjs' `sourceModesBase`, whichever of
19
+ // `light`, `dark`, or a third mode that is) — never specifically in `light`'s
20
+ // or `dark`'s own block — since the light-dark() function resolves against
21
+ // the effective `color-scheme`, regardless of whether the declaration itself
22
+ // sits inside a `@media (prefers-color-scheme: ...)` rule or not.
23
+ //
24
+ // Interaction with `mergeCustomProps`: nothing special is needed here — the
25
+ // per-mode merge with pre-existing custom properties (see ../merge-css.mjs)
26
+ // already runs earlier, inside computeFinalProps() (formats/css.mjs), before
27
+ // this module ever sees the props. So a pre-existing file written with the
28
+ // classic `@media (prefers-color-scheme: dark)` split (i.e. built before
29
+ // useLightDarkFunc was turned on) is reconciled into light-dark() calls just
30
+ // like freshly generated values, matched by property name. As the values
31
+ // merged in this way were never meant to be combined into light-dark() calls,
32
+ // unexpected pre-existing content (e.g. a manually-added property whose name
33
+ // happens to collide across modes) can produce an invalid combined value —
34
+ // left for the user to fix, same as any other mergeCustomProps edge case.
35
+
36
+ import { buildCssBlock } from './formats/css.mjs';
37
+
38
+ // Splits a declaration tail (as produced by computeFinalProps(): value +
39
+ // terminating `;` + optional trailing comment) into its bare CSS value and
40
+ // its trailing comment, so the value can be embedded as a light-dark()
41
+ // argument without dragging the `;`/comment along.
42
+ /** @param {string} tail @returns {{value: string, comment: string}} */
43
+ const splitTail = (tail) => {
44
+ const match = /^(.*?);\s*(\/\*.*\*\/)?\s*$/.exec(tail.trim());
45
+ if (!match) return { value: tail.trim().replace(/;$/, ''), comment: '' };
46
+ return { value: match[1].trim(), comment: match[2] ?? '' };
47
+ };
48
+
49
+ /**
50
+ * @param {object} opts
51
+ * @param {Record<string, Record<string,string>>} opts.modeFinalProps mode → (name → declaration tail) map, one entry per sourceModes key — as captured from computeFinalProps()/lastFinalProps (see formats/css.mjs) right after each per-mode sd.formatPlatform('css') call in build-source-modes.mjs
52
+ * @param {string[]} opts.modeNames all sourceModes keys, in config order
53
+ * @param {string} opts.baseMode mode whose block is printed as a top-level, unconditioned `:root` (or custom selector) — see sourceModesBase in config.mjs
54
+ * @param {string} [opts.selector] customPropsSelector — passed through unchanged to buildCssBlock() (default: ':root')
55
+ * @param {{name:string,prefixes:string[]}[]} [opts.customPropsGroups]
56
+ * @returns {{blocks: Record<string,string>, counts: Record<string,number>}|null}
57
+ * Rebuilt CSS block text + custom-prop count, keyed by mode, for every mode
58
+ * touched by the reconciliation (`light`, `dark`, and `baseMode`, which may
59
+ * coincide) — or null when sourceModes does not define both `light` and
60
+ * `dark` (useLightDarkFunc then has no effect, see config.mjs)
61
+ */
62
+ export const applyLightDarkFunc = ({ modeFinalProps, modeNames, baseMode, selector, customPropsGroups }) => {
63
+ if (!modeNames.includes('light') || !modeNames.includes('dark')) return null;
64
+
65
+ const lightProps = modeFinalProps.light;
66
+ const darkProps = modeFinalProps.dark;
67
+ const commonNames = Object.keys(lightProps).filter((name) => name in darkProps);
68
+
69
+ /** @type {Record<string,string>} */
70
+ const sharedProps = {};
71
+ for (const name of commonNames) {
72
+ const light = splitTail(lightProps[name]);
73
+ const dark = splitTail(darkProps[name]);
74
+ // Prefers the light-side trailing comment (e.g. a token $description);
75
+ // falls back to the dark-side one if light has none.
76
+ const comment = light.comment || dark.comment;
77
+ sharedProps[name] = `light-dark(${light.value}, ${dark.value});${comment ? ` ${comment}` : ''}`;
78
+ }
79
+
80
+ const stripCommon = (props) => {
81
+ const copy = { ...props };
82
+ for (const name of commonNames) delete copy[name];
83
+ return copy;
84
+ };
85
+
86
+ /** @type {Record<string, Record<string,string>>} */
87
+ const updatedFinalProps = { ...modeFinalProps };
88
+ updatedFinalProps.light = stripCommon(lightProps);
89
+ updatedFinalProps.dark = stripCommon(darkProps);
90
+ // The shared declarations always land in the base mode's own map — adding
91
+ // them back in if the base mode happens to be `light` or `dark` itself
92
+ // (whose map was just stripped above), or into a third mode's map.
93
+ updatedFinalProps[baseMode] = { ...updatedFinalProps[baseMode], ...sharedProps };
94
+
95
+ // The base mode's `color-scheme` value is always normalised to read
96
+ // "light dark [...other modes]", with `light` before `dark`, regardless of
97
+ // the order sourceModes keys were declared in config or of which of the
98
+ // two is the configured base — light-dark() itself is positional
99
+ // (light-value first, dark-value second), so the accompanying
100
+ // `color-scheme` declaration follows the same, predictable reading. Modes
101
+ // other than light/dark, if any, are appended after, unaffected.
102
+ const restModes = modeNames.filter((mode) => mode !== 'light' && mode !== 'dark');
103
+ const colorScheme = ['light', 'dark', ...restModes].join(' ');
104
+
105
+ // Only `light`, `dark`, and the base mode (which may be one of the two, or
106
+ // a third mode) need re-rendering: every other sourceModes key is
107
+ // untouched and keeps its already-built block (see build-source-modes.mjs).
108
+ const modesToRebuild = new Set(['light', 'dark', baseMode]);
109
+
110
+ /** @type {Record<string,string>} */
111
+ const blocks = {};
112
+ /** @type {Record<string,number>} */
113
+ const counts = {};
114
+ for (const mode of modesToRebuild) {
115
+ const finalProps = updatedFinalProps[mode];
116
+ blocks[mode] = buildCssBlock({
117
+ finalProps,
118
+ selector,
119
+ colorScheme: mode === baseMode ? colorScheme : mode,
120
+ customPropsGroups,
121
+ });
122
+ counts[mode] = Object.keys(finalProps).length;
123
+ }
124
+
125
+ return { blocks, counts };
126
+ };
@@ -67,6 +67,12 @@ const matchBraces = (text, openIndex) => {
67
67
  return -1;
68
68
  };
69
69
 
70
+ // Escapes a string for literal use inside a RegExp — needed because
71
+ // customPropsSelector (see config.mjs) can contain regex-special characters,
72
+ // e.g. ':where(html)'.
73
+ /** @param {string} str */
74
+ const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
75
+
70
76
  // Finds every @media (prefers-color-scheme: <mode>) { ... } block in a
71
77
  // previously generated sourceModes CSS destFile, with absolute character
72
78
  // offsets into cssText. Used to split the file into per-mode scopes (see
@@ -93,22 +99,25 @@ export const findMediaModeBlocks = (cssText) => {
93
99
 
94
100
  // Splits a previously generated sourceModes CSS destFile into per-mode
95
101
  // scopes (see build-source-modes.mjs for the shape of the generated file):
96
- // - the base scope is the top-level `:root { ... }` block, i.e. the one
97
- // NOT nested inside a `@media (prefers-color-scheme: ...)` rule
98
- // - every other scope is the `:root { ... }` block nested inside its own
99
- // `@media (prefers-color-scheme: <mode>) { ... }` rule
102
+ // - the base scope is the top-level `<selector> { ... }` block, i.e. the
103
+ // one NOT nested inside a `@media (prefers-color-scheme: ...)` rule
104
+ // - every other scope is the `<selector> { ... }` block nested inside its
105
+ // own `@media (prefers-color-scheme: <mode>) { ... }` rule
100
106
  // @param {string} cssText
107
+ // @param {string} [selector] the customPropsSelector the file was generated
108
+ // with (default: ':root' — see config.mjs)
101
109
  // @returns {Record<string,string>} mode → CSS body text (base mode keyed as '__base__')
102
- const splitModeScopes = (cssText) => {
110
+ const splitModeScopes = (cssText, selector = ':root') => {
103
111
  /** @type {Record<string,string>} */
104
112
  const scopes = {};
113
+ const selectorRe = new RegExp(`${escapeRegExp(selector)}\\s*\\{`);
105
114
 
106
115
  // 1. Every @media (prefers-color-scheme: <mode>) { ... } block, and the
107
- // :root { ... } body nested inside each one.
116
+ // <selector> { ... } body nested inside each one.
108
117
  const mediaBlocks = findMediaModeBlocks(cssText);
109
118
  for (const { mode, start, end } of mediaBlocks) {
110
119
  const body = cssText.slice(start + 1, end);
111
- const rootMatch = /:root\s*\{/.exec(body);
120
+ const rootMatch = selectorRe.exec(body);
112
121
  if (rootMatch) {
113
122
  const rootOpen = rootMatch.index + rootMatch[0].length - 1;
114
123
  const rootClose = matchBraces(body, rootOpen);
@@ -118,9 +127,9 @@ const splitModeScopes = (cssText) => {
118
127
  }
119
128
  }
120
129
 
121
- // 2. The base :root { ... } block: the first one not nested inside any of
122
- // the media blocks found above.
123
- const ROOT_RE = /:root\s*\{/g;
130
+ // 2. The base <selector> { ... } block: the first one not nested inside
131
+ // any of the media blocks found above.
132
+ const ROOT_RE = new RegExp(`${escapeRegExp(selector)}\\s*\\{`, 'g');
124
133
  for (const match of cssText.matchAll(ROOT_RE)) {
125
134
  const openIdx = match.index + match[0].length - 1;
126
135
  const insideMedia = mediaBlocks.some((b) => openIdx > b.start && openIdx < b.end);
@@ -188,22 +197,24 @@ export const loadExistingCustomProps = (absDestPath, sources = true) => {
188
197
  /**
189
198
  * sourceModes equivalent of loadExistingCustomProps(): reads the CSS
190
199
  * destination file (if it exists) and stores a per-mode name → value map,
191
- * scoped to each mode's own `:root { ... }` block (see splitModeScopes()).
200
+ * scoped to each mode's own `<selector> { ... }` block (see splitModeScopes()).
192
201
  * @param {string} absDestPath absolute path of the CSS file to read
193
202
  * @param {string} baseMode mode whose declarations live in the top-level,
194
- * non-nested `:root { ... }` block (no default — always passed explicitly
203
+ * non-nested `<selector> { ... }` block (no default — always passed explicitly
195
204
  * by build-source-modes.mjs, from the resolved sourceModesBase)
196
205
  * @param {true|(string|RegExp)[]} [sources] `true` merges every source, an
197
206
  * array only the token source files matching one of its entries (default: true)
207
+ * @param {string} [selector] the customPropsSelector the pre-existing file was
208
+ * generated with (default: ':root' — see config.mjs)
198
209
  * @returns {Record<string, Record<string,string>>|null}
199
210
  */
200
- export const loadExistingCustomPropsScoped = (absDestPath, baseMode, sources = true) => {
211
+ export const loadExistingCustomPropsScoped = (absDestPath, baseMode, sources = true, selector = ':root') => {
201
212
  mergeSources = sources;
202
213
  if (!existsSync(absDestPath)) {
203
214
  scopedExistingCustomProps = null;
204
215
  return scopedExistingCustomProps;
205
216
  }
206
- const scopes = splitModeScopes(readFileSync(absDestPath, 'utf8'));
217
+ const scopes = splitModeScopes(readFileSync(absDestPath, 'utf8'), selector);
207
218
  scopedExistingCustomProps = {};
208
219
  if (scopes.__base__ !== undefined) {
209
220
  scopedExistingCustomProps[baseMode] = parseCustomProps(scopes.__base__);
@@ -52,6 +52,7 @@ export const JSON_TRANSFORMS = [
52
52
  * @param {{name: string, prefixes: string[]}[]} opts.customPropsGroups Named groups of name prefixes, moved to the top of the CSS output in list order.
53
53
  * @param {boolean} opts.pxToRem If false, skips the px→rem transform on the CSS platform. Default: true.
54
54
  * @param {string|null} opts.addLayer Wraps the generated custom properties inside `@layer <addLayer> { ... }`. null = no layer.
55
+ * @param {string} opts.selector CSS selector wrapping the generated block (default: ':root').
55
56
  * @returns {Record<string, PlatformConfig>} platforms object ready for Style Dictionary config
56
57
  */
57
58
  export const buildPlatforms = ({
@@ -65,6 +66,7 @@ export const buildPlatforms = ({
65
66
  customPropsGroups = [],
66
67
  pxToRem = true,
67
68
  addLayer = null,
69
+ selector = ':root',
68
70
  }) => {
69
71
  const cssTransforms = pxToRem
70
72
  ? CSS_TRANSFORMS
@@ -82,6 +84,7 @@ export const buildPlatforms = ({
82
84
  options: {
83
85
  outputReferences: true,
84
86
  showFileHeader: true,
87
+ selector,
85
88
  customPropsGroups,
86
89
  addLayer,
87
90
  },
@@ -41,6 +41,8 @@ import {
41
41
  customPropsGroups,
42
42
  pxToRem,
43
43
  addLayer,
44
+ useLightDarkFunc,
45
+ customPropsSelector,
44
46
  } from './build-tokens-src/config.mjs';
45
47
 
46
48
  // ── 2. Custom transforms ─────────────────────────────────────────────────────
@@ -114,6 +116,8 @@ if (sourceModes) {
114
116
  pxToRem,
115
117
  addLayer,
116
118
  mergeCustomProps,
119
+ useLightDarkFunc,
120
+ customPropsSelector,
117
121
  });
118
122
 
119
123
  } else {
@@ -166,6 +170,7 @@ if (sourceModes) {
166
170
  customPropsGroups,
167
171
  pxToRem,
168
172
  addLayer,
173
+ selector: customPropsSelector,
169
174
  }),
170
175
  });
171
176
 
@@ -68,7 +68,7 @@ const config = {
68
68
  // { src: `${node_modules_path}/open-props/open-props.style-dictionary-tokens.json`, prefix: 'op' },
69
69
 
70
70
  // your project tokens
71
- './project-tokens/*.{js,mjs,jsonc,json}',
71
+ {src: './project-tokens/*.{js,mjs,jsonc,json}', prefix: 'my-project'}
72
72
  ],
73
73
 
74
74
  // Optional light/dark (or other) custom properties split — alternative to
@@ -110,12 +110,54 @@ const config = {
110
110
  // },
111
111
  sourceModes: null,
112
112
 
113
+ // With sourceModes set, and only if it defines both a `light` and a `dark`
114
+ // key: custom properties present in BOTH — matched by name — are written
115
+ // once as `--name: light-dark(<light-value>, <dark-value>);`, instead of
116
+ // being split across the base mode's top-level block and a
117
+ // `@media (prefers-color-scheme: dark) { ... }` rule.
118
+ //
119
+ // Unaffected:
120
+ // - any other key in sourceModes besides light/dark (e.g. a third,
121
+ // custom mode) — always stays on its own
122
+ // `@media (prefers-color-scheme: <mode>) { ... }` rule
123
+ // - a property present in only one of `light`/`dark` — stays in its own
124
+ // mode's block (still behind `@media`, unless that mode happens to be
125
+ // sourceModesBase)
126
+ //
127
+ // Whenever this option is active (sourceModes has both a light and a dark
128
+ // key), the base mode's `color-scheme` declaration is always normalised to
129
+ // read "light dark [...any other mode]", with light before dark — matching
130
+ // light-dark()'s own (light-value, dark-value) argument order — regardless
131
+ // of the order sourceModes keys were declared in, or of which of the two
132
+ // is configured as sourceModesBase below.
133
+ //
134
+ // Interaction with mergeCustomProps: no special handling needed — the
135
+ // per-mode merge with pre-existing custom properties always runs first;
136
+ // this option then reconciles whatever value ends up in each mode
137
+ // (freshly generated or preserved from a pre-existing file) into
138
+ // light-dark(), matched by name. This also means that re-running a build
139
+ // with this option newly turned on, over a destFile previously generated
140
+ // with it off (classic `@media (prefers-color-scheme: dark)` split) and
141
+ // mergeCustomProps: true, converts the preserved values into light-dark()
142
+ // calls too — as with any mergeCustomProps case, a resulting mismatch is
143
+ // left for you to fix.
144
+ //
145
+ // Default: true. Ignored when sourceModes is not set (null/undefined).
146
+ useLightDarkFunc: true,
147
+
113
148
  // With sourceModes set: the mode whose declarations go in the top-level
114
149
  // `:root { ... }` block, instead of nested inside its own
115
150
  // `@media (prefers-color-scheme: <mode>) { ... }` rule (default: the first
116
151
  // key of sourceModes, e.g. 'light'). Ignored when sourceModes is not set.
117
152
  sourceModesBase: 'light',
118
153
 
154
+ // CSS selector wrapping the generated custom properties block(s), instead
155
+ // of the default `:root`. Applies uniformly whether using a single
156
+ // `source` or `sourceModes` (the base mode's top-level block and every
157
+ // other mode's block nested under its own
158
+ // `@media (prefers-color-scheme: <mode>)` rule all use this same
159
+ // selector). Falsy or blank values fall back to ':root'.
160
+ customPropsSelector: ':where(html)',
119
161
 
120
162
  // If true (default), dimension token values expressed in px are converted
121
163
  // to rem in the generated CSS. Values in other units (em, %, vh, dvw, ...)
@@ -141,7 +183,7 @@ const config = {
141
183
  // Comments at the end of a custom property line are preserved; full-line
142
184
  // comments are lost, and a multi-line comment is cut at the end of its first
143
185
  // line (and closed, to keep the CSS valid).
144
- mergeCustomProps: false,
186
+ mergeCustomProps: true,
145
187
 
146
188
  // Wraps the generated custom properties inside a CSS `@layer` at-rule,
147
189
  // e.g. addLayer: 'project' -> `@layer project { :root { ... } }`.
@@ -155,7 +197,7 @@ const config = {
155
197
  customPropsGroups: [
156
198
  {
157
199
  name: 'COLORS',
158
- prefixes: ['accent', 'primary', 'secondary', 'neutral'],
200
+ prefixes: ['accent', 'primary', 'secondary', 'neutral', 'color'],
159
201
  },
160
202
  {
161
203
  name: 'LAYOUT',
@@ -415,7 +415,7 @@ done
415
415
  # installato in node_modules (stessa versione che userà il progetto)
416
416
  MINIMO_PKG_DIR=./node_modules/@massimo-cassandro/minimo
417
417
 
418
- force_cat "${MINIMO_PKG_DIR}/src/custom-properties.css" "${FRONTEND_INSTALL_PATH}/css/custom-properties.css"
418
+ force_cat "${MINIMO_PKG_DIR}/src/custom-properties.css" "${FRONTEND_INSTALL_PATH}/css/minimo-custom-properties.css"
419
419
  force_cat "${MINIMO_PKG_DIR}/src/custom-media.css" "${FRONTEND_INSTALL_PATH}/css/custom-media.css"
420
420
 
421
421
  # entry css principale del progetto: copia diretta di minimo.css, rinominato
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massimo-cassandro/minimo",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "description": "Light css framework & utilities",
5
5
  "type": "module",
6
6
  "main": "./index.js",