@ethlete/core 5.0.0-next.3 → 5.0.0-next.31

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,18 @@ 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`);
204
- }
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`);
235
+ // Error: Duplicate theme types
236
+ const typedThemes = themes.filter((t) => t.type);
237
+ const typeMap = new Map();
238
+ for (const theme of typedThemes) {
239
+ const existing = typeMap.get(theme.type) || [];
240
+ existing.push(theme.name);
241
+ typeMap.set(theme.type, existing);
242
+ }
243
+ for (const [type, names] of typeMap) {
244
+ if (names.length > 1) {
245
+ throw new Error(`Multiple themes with type "${type}" found: ${names.join(', ')}. Only one theme can have a given type`);
246
+ }
208
247
  }
209
248
  }
210
249
  function parseThemeSwatch(obj, sourceFile) {
@@ -219,7 +258,7 @@ function parseThemeSwatch(obj, sourceFile) {
219
258
  if (!initializer) {
220
259
  continue;
221
260
  }
222
- if (propName === 'color' || propName === 'onColor') {
261
+ if (propName === 'color' || propName === 'onColor' || propName === 'inkColor') {
223
262
  const colorMap = parseColorMap(initializer, sourceFile);
224
263
  if (colorMap) {
225
264
  swatch[propName] = colorMap;
@@ -295,132 +334,182 @@ function createCssThemeName(name) {
295
334
  .replace(/\s+/g, '-')
296
335
  .replace(/[^a-z0-9-]/g, '');
297
336
  }
298
- function generateTailwindThemeCss(themes, prefix, schema) {
337
+ function generateColorThemeNameTypes(themes, schema) {
338
+ const themesPath = schema.themesPath || 'src/themes.ts';
339
+ const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
340
+ const typesOutputPath = schema.typesOutputPath || outputPath.replace(/\.css$/, '.d.ts');
341
+ const names = themes.map((theme) => `'${theme.name}'`).join(' | ');
342
+ return `/*
343
+ * Auto-generated by @ethlete/core:tailwind-4-color-theme
344
+ * DO NOT EDIT THIS FILE MANUALLY
345
+ *
346
+ * Regenerate by running:
347
+ * 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}` : ''}
348
+ */
349
+
350
+ declare module '@ethlete/core' {
351
+ interface EthleteColorThemeNameRegistry {
352
+ name: ${names};
353
+ }
354
+ }
355
+
356
+ export {};
357
+ `;
358
+ }
359
+ function generateTailwindThemeCss(themes, utilityPrefix, runtimePrefix, schema) {
299
360
  const tailwindVars = [];
300
361
  const themeVars = [];
301
362
  const themesPath = schema.themesPath || 'src/themes.ts';
302
363
  const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
303
- const header = `/*
364
+ const header = `/*
304
365
  * Auto-generated Tailwind 4 theme colors from @ethlete/core
305
366
  * DO NOT EDIT THIS FILE MANUALLY
306
- *
367
+ *
307
368
  * Generated from your theme definitions
308
369
  * 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}` : ''}
370
+ * 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
371
  */
311
372
 
312
373
  `;
313
374
  // Validation is now done separately before this function is called
314
375
  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);
376
+ const regularThemes = themes.filter((t) => !t.isDefault);
317
377
  // Generate static Tailwind @theme block for each theme
318
378
  for (const theme of themes) {
319
379
  const name = createCssThemeName(theme.name);
320
380
  // Add comment for theme section
321
381
  tailwindVars.push(` /* ${theme.name} theme */`);
322
382
  // Primary colors for Tailwind utilities
323
- addTailwindColorVariants(tailwindVars, `${prefix}-${name}`, theme.primary.color);
383
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}`, theme.primary.color);
324
384
  tailwindVars.push('');
325
385
  // On colors for Tailwind utilities
326
- addTailwindColorVariants(tailwindVars, `${prefix}-on-${name}`, theme.primary.onColor);
386
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}`, theme.primary.onColor);
387
+ tailwindVars.push('');
388
+ // Ink colors for standalone foreground usage
389
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-ink`, theme.primary.inkColor || theme.primary.color);
327
390
  tailwindVars.push('');
328
391
  // Secondary colors if present
329
392
  if (theme.secondary) {
330
- addTailwindColorVariants(tailwindVars, `${prefix}-${name}-secondary`, theme.secondary.color);
393
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-secondary`, theme.secondary.color);
331
394
  tailwindVars.push('');
332
- addTailwindColorVariants(tailwindVars, `${prefix}-on-${name}-secondary`, theme.secondary.onColor);
395
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}-secondary`, theme.secondary.onColor);
333
396
  tailwindVars.push('');
334
397
  }
335
398
  // Tertiary colors if present
336
399
  if (theme.tertiary) {
337
- addTailwindColorVariants(tailwindVars, `${prefix}-${name}-tertiary`, theme.tertiary.color);
400
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-tertiary`, theme.tertiary.color);
338
401
  tailwindVars.push('');
339
- addTailwindColorVariants(tailwindVars, `${prefix}-on-${name}-tertiary`, theme.tertiary.onColor);
402
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}-tertiary`, theme.tertiary.onColor);
340
403
  tailwindVars.push('');
341
404
  }
342
405
  }
343
406
  tailwindVars.push('');
344
- // Generate dynamic theme variables - check main themes and alt themes separately
407
+ // Main theme dynamic colors
345
408
  const mainThemes = [...defaultThemes, ...regularThemes];
346
409
  const hasSecondary = mainThemes.some((t) => t.secondary);
347
410
  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);
411
+ // Collect the dynamic theme colors once. They are emitted in `@theme` (so Tailwind generates
412
+ // the utilities) and ALSO re-declared on every color selector in the alias block below. A
413
+ // Tailwind `@theme` variable only lands on `:root`, so `rgb(var(--<runtime>-color-*))` resolves
414
+ // once against the root color scope and inherits that concrete color into descendants — which
415
+ // means `bg-<prefix>-theme-*` utilities would ignore nested `.<runtime>-color--*` scopes.
416
+ // Re-declaring them per color selector makes the utilities resolve against the nearest scope.
417
+ const dynamicColorVars = [];
418
+ addDynamicThemeColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme', 'primary', false);
419
+ addDynamicInkColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme-ink', 'primary-ink');
353
420
  if (hasSecondary) {
354
- addDynamicThemeColors(tailwindVars, prefix, 'theme-secondary', 'secondary', false);
421
+ addDynamicThemeColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme-secondary', 'secondary', false);
355
422
  }
356
423
  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);
424
+ addDynamicThemeColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme-tertiary', 'tertiary', false);
367
425
  }
368
- // Generate runtime CSS for ALL themes (both main and alt variants)
426
+ tailwindVars.push(' /* Dynamic theme colors (references runtime CSS variables) */');
427
+ tailwindVars.push(...dynamicColorVars);
428
+ // Generate runtime CSS for all themes
369
429
  themes.forEach((theme) => {
370
430
  const name = createCssThemeName(theme.name);
371
- // Determine if this is the default or default-alt theme
431
+ // Determine if this is the default theme
372
432
  const isDefault = theme.isDefault;
373
- const isDefaultAlt = theme.isDefaultAlt;
374
- // Generate main theme variant (.et-theme--{name})
433
+ // Generate color variant (.<runtimePrefix>-color--{name})
375
434
  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}`];
435
+ // Default color gets :root and .<runtimePrefix>-color--default selectors
436
+ const selectors = [':root', `.${runtimePrefix}-color--default`, `.${runtimePrefix}-color--${name}`];
392
437
  themeVars.push(`${selectors.join(', ')} {`);
393
438
  }
394
439
  else {
395
- // All themes (including default and regular) get an alt variant
396
- themeVars.push(`.${prefix}-theme-alt--${name} {`);
440
+ // Regular colors just get their own class
441
+ themeVars.push(`.${runtimePrefix}-color--${name} {`);
397
442
  }
398
- addThemeColorVariants(themeVars, prefix, 'alt-', theme);
443
+ addThemeColorVariants(themeVars, runtimePrefix, '', theme);
399
444
  themeVars.push('}\n');
400
445
  });
446
+ // Re-indent the dynamic theme colors (from 2-space `@theme` indent to the 4-space alias-block
447
+ // indent) and drop trailing blank lines so they slot cleanly into the alias selector below.
448
+ const dynamicColorAliasLines = dynamicColorVars.map((line) => (line === '' ? '' : ` ${line}`));
449
+ while (dynamicColorAliasLines.length && dynamicColorAliasLines[dynamicColorAliasLines.length - 1] === '') {
450
+ dynamicColorAliasLines.pop();
451
+ }
452
+ // Convenience var aliases — available on any element with a color class (or root for default)
453
+ const aliasBlock = `/* Convenience aliases (rgb + solid + opacity variants) */
454
+ @layer base {
455
+ :root, :where([class*="${runtimePrefix}-color--"]) {
456
+ --${runtimePrefix}-theme-color-primary-rgb: var(--${runtimePrefix}-color-primary);
457
+ --${runtimePrefix}-theme-color-primary-opacity: 1;
458
+ --${runtimePrefix}-theme-color-primary-solid: rgb(var(--${runtimePrefix}-theme-color-primary-rgb));
459
+ --${runtimePrefix}-theme-color-primary: rgb(var(--${runtimePrefix}-theme-color-primary-rgb) / var(--${runtimePrefix}-theme-color-primary-opacity));
460
+
461
+ --${runtimePrefix}-theme-color-on-primary-rgb: var(--${runtimePrefix}-color-on-primary);
462
+ --${runtimePrefix}-theme-color-on-primary-opacity: 1;
463
+ --${runtimePrefix}-theme-color-on-primary-solid: rgb(var(--${runtimePrefix}-theme-color-on-primary-rgb) / var(--${runtimePrefix}-theme-color-on-primary-opacity));
464
+ --${runtimePrefix}-theme-color-on-primary: rgb(var(--${runtimePrefix}-theme-color-on-primary-rgb) / var(--${runtimePrefix}-theme-color-on-primary-opacity));
465
+
466
+ --${runtimePrefix}-theme-color-ink-rgb: var(--${runtimePrefix}-color-primary-ink, var(--${runtimePrefix}-color-primary));
467
+ --${runtimePrefix}-theme-color-ink-opacity: 1;
468
+ --${runtimePrefix}-theme-color-ink-solid: rgb(var(--${runtimePrefix}-theme-color-ink-rgb) / var(--${runtimePrefix}-theme-color-ink-opacity));
469
+ --${runtimePrefix}-theme-color-ink: rgb(var(--${runtimePrefix}-theme-color-ink-rgb) / var(--${runtimePrefix}-theme-color-ink-opacity));
470
+
471
+ /* Dynamic Tailwind theme colors — re-declared per color scope so that
472
+ bg-${utilityPrefix}-theme-* utilities resolve against the nearest .${runtimePrefix}-color--*
473
+ scope instead of the value computed once at :root. */
474
+ ${dynamicColorAliasLines.join('\n')}
475
+ }
476
+ }
477
+ `;
401
478
  return `${header}@theme {
402
479
  ${tailwindVars.join('\n')}
403
480
  }
404
481
 
405
- ${themeVars.join('\n')}`;
482
+ @layer base {
483
+ ${themeVars.join('\n')}
406
484
  }
407
- function addDynamicThemeColors(vars, prefix, tailwindName, cssVarName, addSpacingBefore) {
485
+
486
+ ${aliasBlock}`;
487
+ }
488
+ function addDynamicThemeColors(vars, utilityPrefix, runtimePrefix, tailwindName, cssVarName, addSpacingBefore) {
408
489
  if (addSpacingBefore && vars.length > 0 && vars[vars.length - 1] !== '') {
409
490
  vars.push('');
410
491
  }
411
492
  // 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));`);
493
+ vars.push(` --color-${utilityPrefix}-${tailwindName}: rgb(var(--${runtimePrefix}-color-${cssVarName}));`);
494
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-${cssVarName}-hover));`);
495
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-${cssVarName}-focus));`);
496
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-${cssVarName}-active));`);
497
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-${cssVarName}-disabled));`);
417
498
  vars.push('');
418
499
  // 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));`);
500
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}: rgb(var(--${runtimePrefix}-color-on-${cssVarName}));`);
501
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-hover));`);
502
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-focus));`);
503
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-active));`);
504
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-disabled));`);
505
+ vars.push('');
506
+ }
507
+ function addDynamicInkColors(vars, utilityPrefix, runtimePrefix, tailwindName, cssVarName) {
508
+ vars.push(` --color-${utilityPrefix}-${tailwindName}: rgb(var(--${runtimePrefix}-color-${cssVarName}));`);
509
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-${cssVarName}-hover));`);
510
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-${cssVarName}-focus));`);
511
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-${cssVarName}-active));`);
512
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-${cssVarName}-disabled));`);
424
513
  vars.push('');
425
514
  }
426
515
  function addTailwindColorVariants(vars, colorName, colorSet) {
@@ -465,6 +554,17 @@ function addThemeColorVariants(vars, prefix, altPrefix, theme) {
465
554
  vars.push(` --${prefix}-color-${altPrefix}on-${level}-focus: ${onFocusColor};`);
466
555
  vars.push(` --${prefix}-color-${altPrefix}on-${level}-active: ${onActiveColor};`);
467
556
  vars.push(` --${prefix}-color-${altPrefix}on-${level}-disabled: ${onDisabledColor};`);
557
+ const inkDefaultColor = swatch.inkColor?.default || defaultColor;
558
+ const inkHoverColor = swatch.inkColor?.hover || inkDefaultColor;
559
+ const inkFocusColor = swatch.inkColor?.focus || inkHoverColor;
560
+ const inkActiveColor = swatch.inkColor?.active || inkDefaultColor;
561
+ const inkDisabledColor = swatch.inkColor?.disabled || inkDefaultColor;
562
+ vars.push('');
563
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink: ${inkDefaultColor};`);
564
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-hover: ${inkHoverColor};`);
565
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-focus: ${inkFocusColor};`);
566
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-active: ${inkActiveColor};`);
567
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-disabled: ${inkDisabledColor};`);
468
568
  if (theme.secondary || theme.tertiary) {
469
569
  vars.push('');
470
570
  }
@@ -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
+ }