@ethlete/core 5.0.0-next.4 → 5.0.0-next.41

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.
@@ -5,11 +5,15 @@ export default async function generate(tree, schema) {
5
5
  const themesPath = schema.themesPath || 'src/themes.ts';
6
6
  const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
7
7
  const prefix = schema.prefix || 'et';
8
+ // The runtime prefix drives the theme-swap CSS variables (--<runtimePrefix>-color-primary)
9
+ // and selectors (.<runtimePrefix>-color--<name>). It defaults to `prefix` so existing
10
+ // consumers who only ever passed one prefix see no change in output.
11
+ const runtimePrefix = schema.runtimePrefix || prefix;
8
12
  // Step 1: Check if themes file exists
9
13
  if (!tree.exists(themesPath)) {
10
14
  logger.error(`❌ Themes file not found at: ${themesPath}`);
11
15
  logger.log(`\nPlease specify the correct path using --themesPath option.`);
12
- logger.log(`Example: nx g @ethlete/core:tailwind-4-theme --themesPath=src/app/themes.ts\n`);
16
+ logger.log(`Example: nx g @ethlete/core:tailwind-4-color-theme --themesPath=src/app/themes.ts\n`);
13
17
  return;
14
18
  }
15
19
  logger.log(`📁 Reading themes from: ${themesPath}`);
@@ -32,7 +36,21 @@ export default async function generate(tree, schema) {
32
36
  logger.log(' export const THEMES = [...] satisfies Theme[];\n');
33
37
  return;
34
38
  }
35
- // Step 4: Validate theme configuration
39
+ // Step 4: Apply the generation-time default override, if any. The shared theme
40
+ // definitions may mark a default via isDefault, but in a monorepo each app picks its
41
+ // own default at its generation invocation - the option wins over the definitions.
42
+ if (schema.defaultTheme) {
43
+ try {
44
+ const defaultName = applyDefaultThemeOverride(themes, schema.defaultTheme);
45
+ logger.log(`🎯 Default theme set at generation time: ${defaultName}`);
46
+ }
47
+ catch (error) {
48
+ logger.error('❌ Invalid --defaultTheme option');
49
+ logger.error(` ${error instanceof Error ? error.message : String(error)}`);
50
+ return;
51
+ }
52
+ }
53
+ // Step 5: Validate theme configuration
36
54
  try {
37
55
  validateThemeConfiguration(themes);
38
56
  }
@@ -41,17 +59,24 @@ export default async function generate(tree, schema) {
41
59
  logger.error(` ${error instanceof Error ? error.message : String(error)}`);
42
60
  return;
43
61
  }
44
- // Step 5: Generate Tailwind CSS
62
+ // Step 6: Generate Tailwind CSS
45
63
  logger.log('\n🎨 Generating Tailwind theme CSS...');
46
- const css = generateTailwindThemeCss(themes, prefix, schema);
47
- // Step 6: Write the generated CSS file
64
+ const css = generateTailwindThemeCss(themes, prefix, runtimePrefix, schema);
65
+ // Step 7: Write the generated CSS file
48
66
  const outputDir = outputPath.substring(0, outputPath.lastIndexOf('/'));
49
67
  if (outputDir && !tree.exists(outputDir)) {
50
68
  logger.log(`📁 Creating directory: ${outputDir}`);
51
69
  }
52
70
  tree.write(outputPath, css);
53
71
  logger.log(`✅ Generated Tailwind themes at: ${outputPath}`);
54
- // Step 7: Try to find and update main styles file
72
+ // Step 8: Generate the `EthleteColorThemeNameRegistry` augmentation, so `etProvideColor`
73
+ // (and anything else that accepts a `RegisteredColorThemeName`) is checked/autocompleted
74
+ // against this app's actual theme names, instead of a plain `string`.
75
+ const typesOutputPath = schema.typesOutputPath || outputPath.replace(/\.css$/, '.d.ts');
76
+ const typesDts = generateColorThemeNameTypes(themes, schema);
77
+ tree.write(typesOutputPath, typesDts);
78
+ logger.log(`✅ Generated color theme name types at: ${typesOutputPath}`);
79
+ // Step 9: Try to find and update main styles file
55
80
  const mainStylesFiles = findMainStylesFile(tree);
56
81
  if (mainStylesFiles.length > 0) {
57
82
  logger.log('\n📝 Found potential main styles files:');
@@ -95,12 +120,9 @@ function extractThemesFromContent(content, filePath) {
95
120
  if (!initializer) {
96
121
  throw new Error('THEMES export has no initializer');
97
122
  }
98
- // Handle 'satisfies' expression: [array] satisfies Type[]
99
- if (initializer.isKind(SyntaxKind.SatisfiesExpression)) {
100
- initializer = initializer.getExpression();
101
- }
102
- // Handle 'as const': [array] as const
103
- if (initializer.isKind(SyntaxKind.AsExpression)) {
123
+ // Handle 'satisfies X'/'as const' wrappers, in either order: [array] satisfies Type[],
124
+ // [array] as const, or both combined.
125
+ while (initializer.isKind(SyntaxKind.SatisfiesExpression) || initializer.isKind(SyntaxKind.AsExpression)) {
104
126
  initializer = initializer.getExpression();
105
127
  }
106
128
  if (!initializer.isKind(SyntaxKind.ArrayLiteralExpression)) {
@@ -122,8 +144,9 @@ function extractThemesFromContent(content, filePath) {
122
144
  logger.warn(`⚠️ Theme ${name} has no initializer`);
123
145
  continue;
124
146
  }
125
- // Handle 'as const' on individual theme objects
126
- if (themeObj.isKind(SyntaxKind.AsExpression)) {
147
+ // Handle 'as const' and 'satisfies X' wrappers on individual theme objects, in
148
+ // either order (e.g. `{...} satisfies Theme`, `{...} as const`, or both combined).
149
+ while (themeObj.isKind(SyntaxKind.AsExpression) || themeObj.isKind(SyntaxKind.SatisfiesExpression)) {
127
150
  themeObj = themeObj.getExpression();
128
151
  }
129
152
  if (!themeObj.isKind(SyntaxKind.ObjectLiteralExpression)) {
@@ -167,9 +190,9 @@ function parseThemeObject(obj, sourceFile) {
167
190
  theme.isDefault = true;
168
191
  }
169
192
  break;
170
- case 'isDefaultAlt':
171
- if (initializer.isKind(SyntaxKind.TrueKeyword)) {
172
- theme.isDefaultAlt = true;
193
+ case 'type':
194
+ if (initializer.isKind(SyntaxKind.StringLiteral)) {
195
+ theme.type = initializer.getLiteralValue();
173
196
  }
174
197
  break;
175
198
  case 'primary':
@@ -186,10 +209,21 @@ function parseThemeObject(obj, sourceFile) {
186
209
  }
187
210
  return theme;
188
211
  }
212
+ // Makes the named theme the sole default, overriding any isDefault flags in the theme
213
+ // definitions. Matches the theme's name or its CSS-safe form. Returns the resolved name.
214
+ function applyDefaultThemeOverride(themes, defaultTheme) {
215
+ const wanted = createCssThemeName(defaultTheme);
216
+ const target = themes.find((t) => t.name === defaultTheme || createCssThemeName(t.name) === wanted);
217
+ if (!target) {
218
+ throw new Error(`No theme named "${defaultTheme}" found. Available themes: ${themes.map((t) => t.name).join(', ')}`);
219
+ }
220
+ for (const theme of themes) {
221
+ theme.isDefault = theme === target;
222
+ }
223
+ return target.name;
224
+ }
189
225
  function validateThemeConfiguration(themes) {
190
226
  const defaultThemes = themes.filter((t) => t.isDefault);
191
- const defaultAltThemes = themes.filter((t) => t.isDefaultAlt);
192
- const bothDefaultAndAlt = themes.filter((t) => t.isDefault && t.isDefaultAlt);
193
227
  // Error: No default theme
194
228
  if (defaultThemes.length === 0) {
195
229
  throw new Error('No default theme found. At least one theme must have isDefault: true');
@@ -198,13 +232,20 @@ function validateThemeConfiguration(themes) {
198
232
  if (defaultThemes.length > 1) {
199
233
  throw new Error(`Multiple default themes found: ${defaultThemes.map((t) => t.name).join(', ')}. Only one theme can have isDefault: true`);
200
234
  }
201
- // Error: Theme has both isDefault and isDefaultAlt
202
- if (bothDefaultAndAlt.length > 0) {
203
- throw new Error(`Theme "${bothDefaultAndAlt[0].name}" has both isDefault and isDefaultAlt set to true. A theme can only be one or the other`);
235
+ // Error: Duplicate theme types
236
+ const typeMap = new Map();
237
+ for (const theme of themes) {
238
+ if (!theme.type) {
239
+ continue;
240
+ }
241
+ const existing = typeMap.get(theme.type) || [];
242
+ existing.push(theme.name);
243
+ typeMap.set(theme.type, existing);
204
244
  }
205
- // Error: Multiple default alt themes
206
- if (defaultAltThemes.length > 1) {
207
- throw new Error(`Multiple default alt themes found: ${defaultAltThemes.map((t) => t.name).join(', ')}. Only one theme can have isDefaultAlt: true`);
245
+ for (const [type, names] of typeMap) {
246
+ if (names.length > 1) {
247
+ throw new Error(`Multiple themes with type "${type}" found: ${names.join(', ')}. Only one theme can have a given type`);
248
+ }
208
249
  }
209
250
  }
210
251
  function parseThemeSwatch(obj, sourceFile) {
@@ -219,11 +260,18 @@ function parseThemeSwatch(obj, sourceFile) {
219
260
  if (!initializer) {
220
261
  continue;
221
262
  }
222
- if (propName === 'color' || propName === 'onColor') {
223
- const colorMap = parseColorMap(initializer, sourceFile);
224
- if (colorMap) {
225
- swatch[propName] = colorMap;
226
- }
263
+ const colorMap = parseColorMap(initializer, sourceFile);
264
+ if (!colorMap) {
265
+ continue;
266
+ }
267
+ if (propName === 'color') {
268
+ swatch.color = colorMap;
269
+ }
270
+ else if (propName === 'onColor') {
271
+ swatch.onColor = colorMap;
272
+ }
273
+ else if (propName === 'inkColor') {
274
+ swatch.inkColor = colorMap;
227
275
  }
228
276
  }
229
277
  if (!swatch.color || !swatch.onColor) {
@@ -231,6 +279,8 @@ function parseThemeSwatch(obj, sourceFile) {
231
279
  }
232
280
  return swatch;
233
281
  }
282
+ const COLOR_MAP_KEYS = ['default', 'hover', 'focus', 'active', 'disabled'];
283
+ const isColorMapKey = (name) => COLOR_MAP_KEYS.some((key) => key === name);
234
284
  function parseColorMap(initializer, sourceFile) {
235
285
  // Handle spread expressions by resolving references
236
286
  if (initializer.isKind(SyntaxKind.ObjectLiteralExpression)) {
@@ -240,7 +290,7 @@ function parseColorMap(initializer, sourceFile) {
240
290
  if (prop.isKind(SyntaxKind.PropertyAssignment)) {
241
291
  const propName = prop.getName();
242
292
  const propValue = prop.getInitializer();
243
- if (propValue?.isKind(SyntaxKind.StringLiteral)) {
293
+ if (propValue?.isKind(SyntaxKind.StringLiteral) && isColorMapKey(propName)) {
244
294
  colorMap[propName] = propValue.getLiteralValue();
245
295
  }
246
296
  }
@@ -249,9 +299,7 @@ function parseColorMap(initializer, sourceFile) {
249
299
  const spreadExpr = prop.getExpression();
250
300
  if (spreadExpr.isKind(SyntaxKind.Identifier)) {
251
301
  const referencedName = spreadExpr.getText();
252
- const referencedDecl = sourceFile
253
- .getVariableDeclarations()
254
- .find((decl) => decl.getName() === referencedName);
302
+ const referencedDecl = sourceFile.getVariableDeclarations().find((decl) => decl.getName() === referencedName);
255
303
  if (referencedDecl) {
256
304
  const referencedObj = referencedDecl.getInitializer();
257
305
  if (referencedObj?.isKind(SyntaxKind.ObjectLiteralExpression)) {
@@ -265,19 +313,19 @@ function parseColorMap(initializer, sourceFile) {
265
313
  }
266
314
  }
267
315
  // Apply fallbacks for required fields
268
- if (colorMap.default) {
316
+ const defaultColor = colorMap.default;
317
+ if (defaultColor) {
269
318
  // Check if this is a ThemeColorMap (has hover, active, or disabled explicitly set)
270
319
  const isThemeColorMap = colorMap.hover !== undefined || colorMap.active !== undefined || colorMap.disabled !== undefined;
271
320
  if (isThemeColorMap) {
272
321
  // For ThemeColorMap - apply fallbacks for all required fields
273
- const defaultColor = colorMap.default;
274
- const hoverColor = (colorMap.hover || defaultColor);
322
+ const hoverColor = colorMap.hover || defaultColor;
275
323
  const result = {
276
324
  default: defaultColor,
277
325
  hover: hoverColor,
278
326
  focus: colorMap.focus,
279
- active: (colorMap.active || hoverColor),
280
- disabled: (colorMap.disabled || defaultColor),
327
+ active: colorMap.active || hoverColor,
328
+ disabled: colorMap.disabled || defaultColor,
281
329
  };
282
330
  return result;
283
331
  }
@@ -295,132 +343,182 @@ function createCssThemeName(name) {
295
343
  .replace(/\s+/g, '-')
296
344
  .replace(/[^a-z0-9-]/g, '');
297
345
  }
298
- function generateTailwindThemeCss(themes, prefix, schema) {
346
+ function generateColorThemeNameTypes(themes, schema) {
347
+ const themesPath = schema.themesPath || 'src/themes.ts';
348
+ const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
349
+ const typesOutputPath = schema.typesOutputPath || outputPath.replace(/\.css$/, '.d.ts');
350
+ const names = themes.map((theme) => `'${theme.name}'`).join(' | ');
351
+ return `/*
352
+ * Auto-generated by @ethlete/core:tailwind-4-color-theme
353
+ * DO NOT EDIT THIS FILE MANUALLY
354
+ *
355
+ * Regenerate by running:
356
+ * nx g @ethlete/core:tailwind-4-color-theme --themesPath=${themesPath}${schema.outputPath ? ` --outputPath=${outputPath}` : ''}${schema.typesOutputPath ? ` --typesOutputPath=${typesOutputPath}` : ''}${schema.prefix && schema.prefix !== 'et' ? ` --prefix=${schema.prefix}` : ''}${schema.runtimePrefix && schema.runtimePrefix !== schema.prefix ? ` --runtimePrefix=${schema.runtimePrefix}` : ''}${schema.defaultTheme ? ` --defaultTheme=${schema.defaultTheme}` : ''}
357
+ */
358
+
359
+ declare module '@ethlete/core' {
360
+ interface EthleteColorThemeNameRegistry {
361
+ name: ${names};
362
+ }
363
+ }
364
+
365
+ export {};
366
+ `;
367
+ }
368
+ function generateTailwindThemeCss(themes, utilityPrefix, runtimePrefix, schema) {
299
369
  const tailwindVars = [];
300
370
  const themeVars = [];
301
371
  const themesPath = schema.themesPath || 'src/themes.ts';
302
372
  const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
303
- const header = `/*
373
+ const header = `/*
304
374
  * Auto-generated Tailwind 4 theme colors from @ethlete/core
305
375
  * DO NOT EDIT THIS FILE MANUALLY
306
- *
376
+ *
307
377
  * Generated from your theme definitions
308
378
  * This file can be regenerated by running:
309
- * nx g @ethlete/core:tailwind-4-theme --themesPath=${themesPath}${schema.outputPath ? ` --outputPath=${outputPath}` : ''}${schema.prefix && schema.prefix !== 'et' ? ` --prefix=${schema.prefix}` : ''}
379
+ * nx g @ethlete/core:tailwind-4-color-theme --themesPath=${themesPath}${schema.outputPath ? ` --outputPath=${outputPath}` : ''}${schema.prefix && schema.prefix !== 'et' ? ` --prefix=${schema.prefix}` : ''}${schema.runtimePrefix && schema.runtimePrefix !== utilityPrefix ? ` --runtimePrefix=${schema.runtimePrefix}` : ''}${schema.defaultTheme ? ` --defaultTheme=${schema.defaultTheme}` : ''}
310
380
  */
311
381
 
312
382
  `;
313
383
  // Validation is now done separately before this function is called
314
384
  const defaultThemes = themes.filter((t) => t.isDefault);
315
- const defaultAltThemes = themes.filter((t) => t.isDefaultAlt);
316
- const regularThemes = themes.filter((t) => !t.isDefault && !t.isDefaultAlt);
385
+ const regularThemes = themes.filter((t) => !t.isDefault);
317
386
  // Generate static Tailwind @theme block for each theme
318
387
  for (const theme of themes) {
319
388
  const name = createCssThemeName(theme.name);
320
389
  // Add comment for theme section
321
390
  tailwindVars.push(` /* ${theme.name} theme */`);
322
391
  // Primary colors for Tailwind utilities
323
- addTailwindColorVariants(tailwindVars, `${prefix}-${name}`, theme.primary.color);
392
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}`, theme.primary.color);
324
393
  tailwindVars.push('');
325
394
  // On colors for Tailwind utilities
326
- addTailwindColorVariants(tailwindVars, `${prefix}-on-${name}`, theme.primary.onColor);
395
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}`, theme.primary.onColor);
396
+ tailwindVars.push('');
397
+ // Ink colors for standalone foreground usage
398
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-ink`, theme.primary.inkColor || theme.primary.color);
327
399
  tailwindVars.push('');
328
400
  // Secondary colors if present
329
401
  if (theme.secondary) {
330
- addTailwindColorVariants(tailwindVars, `${prefix}-${name}-secondary`, theme.secondary.color);
402
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-secondary`, theme.secondary.color);
331
403
  tailwindVars.push('');
332
- addTailwindColorVariants(tailwindVars, `${prefix}-on-${name}-secondary`, theme.secondary.onColor);
404
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}-secondary`, theme.secondary.onColor);
333
405
  tailwindVars.push('');
334
406
  }
335
407
  // Tertiary colors if present
336
408
  if (theme.tertiary) {
337
- addTailwindColorVariants(tailwindVars, `${prefix}-${name}-tertiary`, theme.tertiary.color);
409
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-tertiary`, theme.tertiary.color);
338
410
  tailwindVars.push('');
339
- addTailwindColorVariants(tailwindVars, `${prefix}-on-${name}-tertiary`, theme.tertiary.onColor);
411
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}-tertiary`, theme.tertiary.onColor);
340
412
  tailwindVars.push('');
341
413
  }
342
414
  }
343
415
  tailwindVars.push('');
344
- // Generate dynamic theme variables - check main themes and alt themes separately
416
+ // Main theme dynamic colors
345
417
  const mainThemes = [...defaultThemes, ...regularThemes];
346
418
  const hasSecondary = mainThemes.some((t) => t.secondary);
347
419
  const hasTertiary = mainThemes.some((t) => t.tertiary);
348
- const hasAltSecondary = defaultAltThemes.some((t) => t.secondary);
349
- const hasAltTertiary = defaultAltThemes.some((t) => t.tertiary);
350
- // Main theme dynamic colors
351
- tailwindVars.push(' /* Dynamic theme colors (references runtime CSS variables) */');
352
- addDynamicThemeColors(tailwindVars, prefix, 'theme', 'primary', true);
420
+ // Collect the dynamic theme colors once. They are emitted in `@theme` (so Tailwind generates
421
+ // the utilities) and ALSO re-declared on every color selector in the alias block below. A
422
+ // Tailwind `@theme` variable only lands on `:root`, so `rgb(var(--<runtime>-color-*))` resolves
423
+ // once against the root color scope and inherits that concrete color into descendants - which
424
+ // means `bg-<prefix>-theme-*` utilities would ignore nested `.<runtime>-color--*` scopes.
425
+ // Re-declaring them per color selector makes the utilities resolve against the nearest scope.
426
+ const dynamicColorVars = [];
427
+ addDynamicThemeColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme', 'primary', false);
428
+ addDynamicInkColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme-ink', 'primary-ink');
353
429
  if (hasSecondary) {
354
- addDynamicThemeColors(tailwindVars, prefix, 'theme-secondary', 'secondary', false);
430
+ addDynamicThemeColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme-secondary', 'secondary', false);
355
431
  }
356
432
  if (hasTertiary) {
357
- addDynamicThemeColors(tailwindVars, prefix, 'theme-tertiary', 'tertiary', false);
358
- }
359
- // Alt theme dynamic colors
360
- tailwindVars.push(' /* Alt theme dynamic colors */');
361
- addDynamicThemeColors(tailwindVars, prefix, 'alt-theme', 'alt-primary', true);
362
- if (hasAltSecondary) {
363
- addDynamicThemeColors(tailwindVars, prefix, 'alt-theme-secondary', 'alt-secondary', false);
364
- }
365
- if (hasAltTertiary) {
366
- addDynamicThemeColors(tailwindVars, prefix, 'alt-theme-tertiary', 'alt-tertiary', false);
433
+ addDynamicThemeColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme-tertiary', 'tertiary', false);
367
434
  }
368
- // Generate runtime CSS for ALL themes (both main and alt variants)
435
+ tailwindVars.push(' /* Dynamic theme colors (references runtime CSS variables) */');
436
+ tailwindVars.push(...dynamicColorVars);
437
+ // Generate runtime CSS for all themes
369
438
  themes.forEach((theme) => {
370
439
  const name = createCssThemeName(theme.name);
371
- // Determine if this is the default or default-alt theme
440
+ // Determine if this is the default theme
372
441
  const isDefault = theme.isDefault;
373
- const isDefaultAlt = theme.isDefaultAlt;
374
- // Generate main theme variant (.et-theme--{name})
442
+ // Generate color variant (.<runtimePrefix>-color--{name})
375
443
  if (isDefault) {
376
- // Default theme gets :root and .et-theme--default selectors
377
- const selectors = [':root', `.${prefix}-theme--default`, `.${prefix}-theme--${name}`];
378
- themeVars.push(`${selectors.join(', ')} {`);
379
- }
380
- else if (!isDefaultAlt) {
381
- // Regular themes just get their own class
382
- themeVars.push(`.${prefix}-theme--${name} {`);
383
- }
384
- if (isDefault || !isDefaultAlt) {
385
- addThemeColorVariants(themeVars, prefix, '', theme);
386
- themeVars.push('}\n');
387
- }
388
- // Generate alt theme variant (.et-theme-alt--{name})
389
- if (isDefaultAlt) {
390
- // Default alt theme gets :root and .et-theme-alt--default selectors
391
- const selectors = [':root', `.${prefix}-theme-alt--default`, `.${prefix}-theme-alt--${name}`];
444
+ // Default color gets :root and .<runtimePrefix>-color--default selectors
445
+ const selectors = [':root', `.${runtimePrefix}-color--default`, `.${runtimePrefix}-color--${name}`];
392
446
  themeVars.push(`${selectors.join(', ')} {`);
393
447
  }
394
448
  else {
395
- // All themes (including default and regular) get an alt variant
396
- themeVars.push(`.${prefix}-theme-alt--${name} {`);
449
+ // Regular colors just get their own class
450
+ themeVars.push(`.${runtimePrefix}-color--${name} {`);
397
451
  }
398
- addThemeColorVariants(themeVars, prefix, 'alt-', theme);
452
+ addThemeColorVariants(themeVars, runtimePrefix, '', theme);
399
453
  themeVars.push('}\n');
400
454
  });
455
+ // Re-indent the dynamic theme colors (from 2-space `@theme` indent to the 4-space alias-block
456
+ // indent) and drop trailing blank lines so they slot cleanly into the alias selector below.
457
+ const dynamicColorAliasLines = dynamicColorVars.map((line) => (line === '' ? '' : ` ${line}`));
458
+ while (dynamicColorAliasLines.length && dynamicColorAliasLines[dynamicColorAliasLines.length - 1] === '') {
459
+ dynamicColorAliasLines.pop();
460
+ }
461
+ // Convenience var aliases - available on any element with a color class (or root for default)
462
+ const aliasBlock = `/* Convenience aliases (rgb + solid + opacity variants) */
463
+ @layer base {
464
+ :root, :where([class*="${runtimePrefix}-color--"]) {
465
+ --${runtimePrefix}-theme-color-primary-rgb: var(--${runtimePrefix}-color-primary);
466
+ --${runtimePrefix}-theme-color-primary-opacity: 1;
467
+ --${runtimePrefix}-theme-color-primary-solid: rgb(var(--${runtimePrefix}-theme-color-primary-rgb));
468
+ --${runtimePrefix}-theme-color-primary: rgb(var(--${runtimePrefix}-theme-color-primary-rgb) / var(--${runtimePrefix}-theme-color-primary-opacity));
469
+
470
+ --${runtimePrefix}-theme-color-on-primary-rgb: var(--${runtimePrefix}-color-on-primary);
471
+ --${runtimePrefix}-theme-color-on-primary-opacity: 1;
472
+ --${runtimePrefix}-theme-color-on-primary-solid: rgb(var(--${runtimePrefix}-theme-color-on-primary-rgb) / var(--${runtimePrefix}-theme-color-on-primary-opacity));
473
+ --${runtimePrefix}-theme-color-on-primary: rgb(var(--${runtimePrefix}-theme-color-on-primary-rgb) / var(--${runtimePrefix}-theme-color-on-primary-opacity));
474
+
475
+ --${runtimePrefix}-theme-color-ink-rgb: var(--${runtimePrefix}-color-primary-ink, var(--${runtimePrefix}-color-primary));
476
+ --${runtimePrefix}-theme-color-ink-opacity: 1;
477
+ --${runtimePrefix}-theme-color-ink-solid: rgb(var(--${runtimePrefix}-theme-color-ink-rgb) / var(--${runtimePrefix}-theme-color-ink-opacity));
478
+ --${runtimePrefix}-theme-color-ink: rgb(var(--${runtimePrefix}-theme-color-ink-rgb) / var(--${runtimePrefix}-theme-color-ink-opacity));
479
+
480
+ /* Dynamic Tailwind theme colors - re-declared per color scope so that
481
+ bg-${utilityPrefix}-theme-* utilities resolve against the nearest .${runtimePrefix}-color--*
482
+ scope instead of the value computed once at :root. */
483
+ ${dynamicColorAliasLines.join('\n')}
484
+ }
485
+ }
486
+ `;
401
487
  return `${header}@theme {
402
488
  ${tailwindVars.join('\n')}
403
489
  }
404
490
 
405
- ${themeVars.join('\n')}`;
491
+ @layer base {
492
+ ${themeVars.join('\n')}
493
+ }
494
+
495
+ ${aliasBlock}`;
406
496
  }
407
- function addDynamicThemeColors(vars, prefix, tailwindName, cssVarName, addSpacingBefore) {
497
+ function addDynamicThemeColors(vars, utilityPrefix, runtimePrefix, tailwindName, cssVarName, addSpacingBefore) {
408
498
  if (addSpacingBefore && vars.length > 0 && vars[vars.length - 1] !== '') {
409
499
  vars.push('');
410
500
  }
411
501
  // Color variants
412
- vars.push(` --color-${prefix}-${tailwindName}: rgb(var(--${prefix}-color-${cssVarName}));`);
413
- vars.push(` --color-${prefix}-${tailwindName}-hover: rgb(var(--${prefix}-color-${cssVarName}-hover));`);
414
- vars.push(` --color-${prefix}-${tailwindName}-focus: rgb(var(--${prefix}-color-${cssVarName}-focus));`);
415
- vars.push(` --color-${prefix}-${tailwindName}-active: rgb(var(--${prefix}-color-${cssVarName}-active));`);
416
- vars.push(` --color-${prefix}-${tailwindName}-disabled: rgb(var(--${prefix}-color-${cssVarName}-disabled));`);
502
+ vars.push(` --color-${utilityPrefix}-${tailwindName}: rgb(var(--${runtimePrefix}-color-${cssVarName}));`);
503
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-${cssVarName}-hover));`);
504
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-${cssVarName}-focus));`);
505
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-${cssVarName}-active));`);
506
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-${cssVarName}-disabled));`);
417
507
  vars.push('');
418
508
  // On-color variants
419
- vars.push(` --color-${prefix}-on-${tailwindName}: rgb(var(--${prefix}-color-on-${cssVarName}));`);
420
- vars.push(` --color-${prefix}-on-${tailwindName}-hover: rgb(var(--${prefix}-color-on-${cssVarName}-hover));`);
421
- vars.push(` --color-${prefix}-on-${tailwindName}-focus: rgb(var(--${prefix}-color-on-${cssVarName}-focus));`);
422
- vars.push(` --color-${prefix}-on-${tailwindName}-active: rgb(var(--${prefix}-color-on-${cssVarName}-active));`);
423
- vars.push(` --color-${prefix}-on-${tailwindName}-disabled: rgb(var(--${prefix}-color-on-${cssVarName}-disabled));`);
509
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}: rgb(var(--${runtimePrefix}-color-on-${cssVarName}));`);
510
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-hover));`);
511
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-focus));`);
512
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-active));`);
513
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-disabled));`);
514
+ vars.push('');
515
+ }
516
+ function addDynamicInkColors(vars, utilityPrefix, runtimePrefix, tailwindName, cssVarName) {
517
+ vars.push(` --color-${utilityPrefix}-${tailwindName}: rgb(var(--${runtimePrefix}-color-${cssVarName}));`);
518
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-${cssVarName}-hover));`);
519
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-${cssVarName}-focus));`);
520
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-${cssVarName}-active));`);
521
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-${cssVarName}-disabled));`);
424
522
  vars.push('');
425
523
  }
426
524
  function addTailwindColorVariants(vars, colorName, colorSet) {
@@ -465,6 +563,17 @@ function addThemeColorVariants(vars, prefix, altPrefix, theme) {
465
563
  vars.push(` --${prefix}-color-${altPrefix}on-${level}-focus: ${onFocusColor};`);
466
564
  vars.push(` --${prefix}-color-${altPrefix}on-${level}-active: ${onActiveColor};`);
467
565
  vars.push(` --${prefix}-color-${altPrefix}on-${level}-disabled: ${onDisabledColor};`);
566
+ const inkDefaultColor = swatch.inkColor?.default || defaultColor;
567
+ const inkHoverColor = swatch.inkColor?.hover || inkDefaultColor;
568
+ const inkFocusColor = swatch.inkColor?.focus || inkHoverColor;
569
+ const inkActiveColor = swatch.inkColor?.active || inkDefaultColor;
570
+ const inkDisabledColor = swatch.inkColor?.disabled || inkDefaultColor;
571
+ vars.push('');
572
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink: ${inkDefaultColor};`);
573
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-hover: ${inkHoverColor};`);
574
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-focus: ${inkFocusColor};`);
575
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-active: ${inkActiveColor};`);
576
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-disabled: ${inkDisabledColor};`);
468
577
  if (theme.secondary || theme.tertiary) {
469
578
  vars.push('');
470
579
  }
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "tailwind-4-color-theme",
4
+ "title": "Tailwind 4 Theming Generator",
5
+ "description": "Generates Tailwind 4 compatible CSS from your Ethlete theme definitions",
6
+ "type": "object",
7
+ "properties": {
8
+ "themesPath": {
9
+ "type": "string",
10
+ "description": "Path to your themes file (relative to workspace root)",
11
+ "default": "src/themes.ts",
12
+ "x-prompt": "Where is your themes file located?"
13
+ },
14
+ "outputPath": {
15
+ "type": "string",
16
+ "description": "Where to output the generated Tailwind themes CSS",
17
+ "default": "src/styles/generated-tailwind-themes.css",
18
+ "x-prompt": "Where should the generated CSS be saved?"
19
+ },
20
+ "typesOutputPath": {
21
+ "type": "string",
22
+ "description": "Where to output the generated `EthleteColorThemeNameRegistry` augmentation (a .d.ts that narrows `etProvideColor` and other theme-name APIs to your actual theme names). Defaults to `outputPath` with its extension swapped to `.d.ts`."
23
+ },
24
+ "prefix": {
25
+ "type": "string",
26
+ "description": "Prefix for Tailwind utility color variable names (e.g., 'et' creates --color-et-primary, usable as bg-et-primary)",
27
+ "default": "et"
28
+ },
29
+ "runtimePrefix": {
30
+ "type": "string",
31
+ "description": "Prefix for the runtime theme-swap mechanism: the CSS custom properties (--<runtimePrefix>-color-primary) and selectors (.<runtimePrefix>-color--<name>) that ProvideColorDirective (etProvideColor) toggles between. Defaults to the same value as 'prefix'. Set this separately when your Tailwind utility prefix needs to differ from your runtime theme-switching prefix (e.g. because '--color-primary' is already a documented public API elsewhere in your app)."
32
+ },
33
+ "defaultTheme": {
34
+ "type": "string",
35
+ "description": "Name of the theme to use as the default, overriding any isDefault flags in the theme definitions. Use this when multiple apps share one theme definition set (e.g. in a monorepo) but each app needs a different default."
36
+ },
37
+ "skipFormat": {
38
+ "type": "boolean",
39
+ "description": "Skip formatting files after generation",
40
+ "default": false
41
+ }
42
+ },
43
+ "required": ["themesPath"]
44
+ }