@ethlete/core 5.0.0-next.2 → 5.0.0-next.20

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,10 +5,15 @@
5
5
  "schema": "./migrate-to-v5/schema.json",
6
6
  "description": "Migrate to v5"
7
7
  },
8
- "tailwind-4-theme": {
9
- "factory": "./tailwind-4-theme/generator",
10
- "schema": "./tailwind-4-theme/schema.json",
11
- "description": "Generate theming for Tailwind CSS v4"
8
+ "tailwind-4-color-theme": {
9
+ "factory": "./tailwind-4-color-theme/generator",
10
+ "schema": "./tailwind-4-color-theme/schema.json",
11
+ "description": "Generate color theming for Tailwind CSS v4"
12
+ },
13
+ "tailwind-4-surface-theme": {
14
+ "factory": "./tailwind-4-surface-theme/generator",
15
+ "schema": "./tailwind-4-surface-theme/schema.json",
16
+ "description": "Generate surface theming for Tailwind CSS v4"
12
17
  }
13
18
  }
14
19
  }
@@ -0,0 +1,175 @@
1
+ import { logger } from '@nx/devkit';
2
+ /**
3
+ * Symbol renames in TypeScript files (imports + usage).
4
+ */
5
+ const TS_SYMBOL_RENAMES = {
6
+ ProvideThemeDirective: 'ProvideColorDirective',
7
+ THEME_PROVIDER: 'COLOR_PROVIDER',
8
+ ColorThemedDirective: 'ColoredDirective',
9
+ ColorThemedStylesComponent: 'ColoredStylesComponent',
10
+ SurfaceThemedDirective: 'SurfacedDirective',
11
+ SurfaceThemedStylesComponent: 'SurfacedStylesComponent',
12
+ injectThemesPrefix: 'injectColorThemesPrefix',
13
+ createCssThemeName: 'createCssColorThemeName',
14
+ };
15
+ /**
16
+ * String replacements applied to HTML templates and inline templates.
17
+ */
18
+ const TEMPLATE_REPLACEMENTS = [
19
+ ['[etProvideTheme]', '[etProvideColor]'],
20
+ ['etProvideTheme', 'etProvideColor'],
21
+ ['[etProvideAltTheme]', '[etProvideAltColor]'],
22
+ ['etProvideAltTheme', 'etProvideAltColor'],
23
+ ['[etColorThemed]', '[etColored]'],
24
+ ['etColorThemed', 'etColored'],
25
+ ['[etSurfaceThemed]', '[etSurfaced]'],
26
+ ['etSurfaceThemed', 'etSurfaced'],
27
+ ['[theme]=', '[color]='],
28
+ ['[altTheme]=', '[altColor]='],
29
+ ['et-theme-alt--', 'et-color-alt--'],
30
+ ['et-theme--', 'et-color--'],
31
+ ];
32
+ /**
33
+ * CSS class name replacements.
34
+ */
35
+ const CSS_REPLACEMENTS = [
36
+ ['.et-color-themed', '.et-colored'],
37
+ ['.et-surface-themed', '.et-surfaced'],
38
+ ['.et-theme--', '.et-color--'],
39
+ ['.et-theme-alt--', '.et-color-alt--'],
40
+ ];
41
+ /**
42
+ * Host directive input alias replacements in TypeScript files.
43
+ */
44
+ const HOST_DIRECTIVE_REPLACEMENTS = [
45
+ ["'etProvideTheme:theme'", "'etProvideColor:color'"],
46
+ ["'etProvideAltTheme:altTheme'", "'etProvideAltColor:altColor'"],
47
+ ["'etProvideTheme:color'", "'etProvideColor:color'"],
48
+ ["'etProvideAltTheme:altColor'", "'etProvideAltColor:altColor'"],
49
+ ['"etProvideTheme:theme"', '"etProvideColor:color"'],
50
+ ['"etProvideAltTheme:altTheme"', '"etProvideAltColor:altColor"'],
51
+ ['"etProvideTheme"', '"etProvideColor"'],
52
+ ['"etProvideAltTheme"', '"etProvideAltColor"'],
53
+ ];
54
+ export default async function migrateColorNaming(tree) {
55
+ logger.log('\nšŸ”„ Migrating theme → color naming...\n');
56
+ const tsFiles = [];
57
+ const htmlFiles = [];
58
+ const cssFiles = [];
59
+ findFiles('.', tree, tsFiles, htmlFiles, cssFiles);
60
+ let filesModified = 0;
61
+ for (const filePath of tsFiles) {
62
+ const wasModified = migrateTypeScriptFile(tree, filePath);
63
+ if (wasModified) {
64
+ filesModified++;
65
+ logger.log(` āœ“ ${filePath}`);
66
+ }
67
+ }
68
+ for (const filePath of htmlFiles) {
69
+ const wasModified = migrateTemplateFile(tree, filePath);
70
+ if (wasModified) {
71
+ filesModified++;
72
+ logger.log(` āœ“ ${filePath}`);
73
+ }
74
+ }
75
+ for (const filePath of cssFiles) {
76
+ const wasModified = migrateCssFile(tree, filePath);
77
+ if (wasModified) {
78
+ filesModified++;
79
+ logger.log(` āœ“ ${filePath}`);
80
+ }
81
+ }
82
+ if (filesModified > 0) {
83
+ logger.log(`\nāœ… Successfully migrated color naming in ${filesModified} file(s)\n`);
84
+ }
85
+ else {
86
+ logger.log('\nā„¹ļø No files needed migration\n');
87
+ }
88
+ }
89
+ function findFiles(dir, tree, tsFiles, htmlFiles, cssFiles) {
90
+ const children = tree.children(dir);
91
+ for (const child of children) {
92
+ const path = dir === '.' ? child : `${dir}/${child}`;
93
+ if (tree.isFile(path)) {
94
+ if (path.includes('node_modules'))
95
+ continue;
96
+ if (path.endsWith('.ts') && !path.endsWith('.spec.ts')) {
97
+ tsFiles.push(path);
98
+ }
99
+ else if (path.endsWith('.html')) {
100
+ htmlFiles.push(path);
101
+ }
102
+ else if (path.endsWith('.css') || path.endsWith('.scss')) {
103
+ cssFiles.push(path);
104
+ }
105
+ }
106
+ else {
107
+ if (child === 'node_modules')
108
+ continue;
109
+ findFiles(path, tree, tsFiles, htmlFiles, cssFiles);
110
+ }
111
+ }
112
+ }
113
+ function migrateTypeScriptFile(tree, filePath) {
114
+ let content = tree.read(filePath, 'utf-8');
115
+ if (!content)
116
+ return false;
117
+ const original = content;
118
+ // Rename symbols
119
+ for (const [oldName, newName] of Object.entries(TS_SYMBOL_RENAMES)) {
120
+ content = replaceWholeWord(content, oldName, newName);
121
+ }
122
+ // Rename host directive input aliases
123
+ for (const [oldStr, newStr] of HOST_DIRECTIVE_REPLACEMENTS) {
124
+ content = content.replaceAll(oldStr, newStr);
125
+ }
126
+ // Handle inline templates in TypeScript files
127
+ for (const [oldStr, newStr] of TEMPLATE_REPLACEMENTS) {
128
+ content = content.replaceAll(oldStr, newStr);
129
+ }
130
+ // Handle CSS in TypeScript files
131
+ for (const [oldStr, newStr] of CSS_REPLACEMENTS) {
132
+ content = content.replaceAll(oldStr, newStr);
133
+ }
134
+ if (content !== original) {
135
+ tree.write(filePath, content);
136
+ return true;
137
+ }
138
+ return false;
139
+ }
140
+ function migrateTemplateFile(tree, filePath) {
141
+ let content = tree.read(filePath, 'utf-8');
142
+ if (!content)
143
+ return false;
144
+ const original = content;
145
+ for (const [oldStr, newStr] of TEMPLATE_REPLACEMENTS) {
146
+ content = content.replaceAll(oldStr, newStr);
147
+ }
148
+ if (content !== original) {
149
+ tree.write(filePath, content);
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+ function migrateCssFile(tree, filePath) {
155
+ let content = tree.read(filePath, 'utf-8');
156
+ if (!content)
157
+ return false;
158
+ const original = content;
159
+ for (const [oldStr, newStr] of CSS_REPLACEMENTS) {
160
+ content = content.replaceAll(oldStr, newStr);
161
+ }
162
+ if (content !== original) {
163
+ tree.write(filePath, content);
164
+ return true;
165
+ }
166
+ return false;
167
+ }
168
+ function replaceWholeWord(content, oldWord, newWord) {
169
+ const regex = new RegExp(`\\b${escapeRegex(oldWord)}\\b`, 'g');
170
+ return content.replace(regex, newWord);
171
+ }
172
+ function escapeRegex(str) {
173
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
174
+ }
175
+ //# sourceMappingURL=color-naming.js.map
@@ -1,4 +1,5 @@
1
1
  import { formatFiles } from '@nx/devkit';
2
+ import migrateColorNaming from './color-naming.js';
2
3
  import migrateCreateProvider from './create-provider.js';
3
4
  import migrateRouterStateService from './router-state-service.js';
4
5
  import migrateViewportService from './viewport-service.js';
@@ -18,6 +19,11 @@ export default async function migrate(tree, schema) {
18
19
  if (shouldMigrateRouterStateService) {
19
20
  await migrateRouterStateService(tree);
20
21
  }
22
+ const shouldMigrateColorNaming = schema.migrateColorNaming !== false;
23
+ if (shouldMigrateColorNaming) {
24
+ console.log(' • Migrating theme → color naming...');
25
+ await migrateColorNaming(tree);
26
+ }
21
27
  if (!schema.skipFormat) {
22
28
  console.log(' • Formatting files...');
23
29
  await formatFiles(tree);
@@ -23,6 +23,11 @@
23
23
  "type": "boolean",
24
24
  "description": "Migrate router state service to v5",
25
25
  "default": true
26
+ },
27
+ "migrateColorNaming": {
28
+ "type": "boolean",
29
+ "description": "Migrate theme naming to color naming (ProvideThemeDirective -> ProvideColorDirective, etc.)",
30
+ "default": true
26
31
  }
27
32
  },
28
33
  "required": []
@@ -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}`);
@@ -43,7 +47,7 @@ export default async function generate(tree, schema) {
43
47
  }
44
48
  // Step 5: Generate Tailwind CSS
45
49
  logger.log('\nšŸŽØ Generating Tailwind theme CSS...');
46
- const css = generateTailwindThemeCss(themes, prefix, schema);
50
+ const css = generateTailwindThemeCss(themes, prefix, runtimePrefix, schema);
47
51
  // Step 6: Write the generated CSS file
48
52
  const outputDir = outputPath.substring(0, outputPath.lastIndexOf('/'));
49
53
  if (outputDir && !tree.exists(outputDir)) {
@@ -51,7 +55,14 @@ export default async function generate(tree, schema) {
51
55
  }
52
56
  tree.write(outputPath, css);
53
57
  logger.log(`āœ… Generated Tailwind themes at: ${outputPath}`);
54
- // Step 7: Try to find and update main styles file
58
+ // Step 7: Generate the `EthleteColorThemeNameRegistry` augmentation, so `etProvideColor`
59
+ // (and anything else that accepts a `RegisteredColorThemeName`) is checked/autocompleted
60
+ // against this app's actual theme names, instead of a plain `string`.
61
+ const typesOutputPath = schema.typesOutputPath || outputPath.replace(/\.css$/, '.d.ts');
62
+ const typesDts = generateColorThemeNameTypes(themes, schema);
63
+ tree.write(typesOutputPath, typesDts);
64
+ logger.log(`āœ… Generated color theme name types at: ${typesOutputPath}`);
65
+ // Step 8: Try to find and update main styles file
55
66
  const mainStylesFiles = findMainStylesFile(tree);
56
67
  if (mainStylesFiles.length > 0) {
57
68
  logger.log('\nšŸ“ Found potential main styles files:');
@@ -95,12 +106,9 @@ function extractThemesFromContent(content, filePath) {
95
106
  if (!initializer) {
96
107
  throw new Error('THEMES export has no initializer');
97
108
  }
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)) {
109
+ // Handle 'satisfies X'/'as const' wrappers, in either order: [array] satisfies Type[],
110
+ // [array] as const, or both combined.
111
+ while (initializer.isKind(SyntaxKind.SatisfiesExpression) || initializer.isKind(SyntaxKind.AsExpression)) {
104
112
  initializer = initializer.getExpression();
105
113
  }
106
114
  if (!initializer.isKind(SyntaxKind.ArrayLiteralExpression)) {
@@ -122,8 +130,9 @@ function extractThemesFromContent(content, filePath) {
122
130
  logger.warn(`āš ļø Theme ${name} has no initializer`);
123
131
  continue;
124
132
  }
125
- // Handle 'as const' on individual theme objects
126
- if (themeObj.isKind(SyntaxKind.AsExpression)) {
133
+ // Handle 'as const' and 'satisfies X' wrappers on individual theme objects, in
134
+ // either order (e.g. `{...} satisfies Theme`, `{...} as const`, or both combined).
135
+ while (themeObj.isKind(SyntaxKind.AsExpression) || themeObj.isKind(SyntaxKind.SatisfiesExpression)) {
127
136
  themeObj = themeObj.getExpression();
128
137
  }
129
138
  if (!themeObj.isKind(SyntaxKind.ObjectLiteralExpression)) {
@@ -167,9 +176,9 @@ function parseThemeObject(obj, sourceFile) {
167
176
  theme.isDefault = true;
168
177
  }
169
178
  break;
170
- case 'isDefaultAlt':
171
- if (initializer.isKind(SyntaxKind.TrueKeyword)) {
172
- theme.isDefaultAlt = true;
179
+ case 'type':
180
+ if (initializer.isKind(SyntaxKind.StringLiteral)) {
181
+ theme.type = initializer.getLiteralValue();
173
182
  }
174
183
  break;
175
184
  case 'primary':
@@ -188,8 +197,6 @@ function parseThemeObject(obj, sourceFile) {
188
197
  }
189
198
  function validateThemeConfiguration(themes) {
190
199
  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
200
  // Error: No default theme
194
201
  if (defaultThemes.length === 0) {
195
202
  throw new Error('No default theme found. At least one theme must have isDefault: true');
@@ -198,13 +205,18 @@ function validateThemeConfiguration(themes) {
198
205
  if (defaultThemes.length > 1) {
199
206
  throw new Error(`Multiple default themes found: ${defaultThemes.map((t) => t.name).join(', ')}. Only one theme can have isDefault: true`);
200
207
  }
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`);
208
+ // Error: Duplicate theme types
209
+ const typedThemes = themes.filter((t) => t.type);
210
+ const typeMap = new Map();
211
+ for (const theme of typedThemes) {
212
+ const existing = typeMap.get(theme.type) || [];
213
+ existing.push(theme.name);
214
+ typeMap.set(theme.type, existing);
215
+ }
216
+ for (const [type, names] of typeMap) {
217
+ if (names.length > 1) {
218
+ throw new Error(`Multiple themes with type "${type}" found: ${names.join(', ')}. Only one theme can have a given type`);
219
+ }
208
220
  }
209
221
  }
210
222
  function parseThemeSwatch(obj, sourceFile) {
@@ -219,7 +231,7 @@ function parseThemeSwatch(obj, sourceFile) {
219
231
  if (!initializer) {
220
232
  continue;
221
233
  }
222
- if (propName === 'color' || propName === 'onColor') {
234
+ if (propName === 'color' || propName === 'onColor' || propName === 'inkColor') {
223
235
  const colorMap = parseColorMap(initializer, sourceFile);
224
236
  if (colorMap) {
225
237
  swatch[propName] = colorMap;
@@ -295,132 +307,163 @@ function createCssThemeName(name) {
295
307
  .replace(/\s+/g, '-')
296
308
  .replace(/[^a-z0-9-]/g, '');
297
309
  }
298
- function generateTailwindThemeCss(themes, prefix, schema) {
310
+ function generateColorThemeNameTypes(themes, schema) {
311
+ const themesPath = schema.themesPath || 'src/themes.ts';
312
+ const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
313
+ const typesOutputPath = schema.typesOutputPath || outputPath.replace(/\.css$/, '.d.ts');
314
+ const names = themes.map((theme) => `'${theme.name}'`).join(' | ');
315
+ return `/*
316
+ * Auto-generated by @ethlete/core:tailwind-4-color-theme
317
+ * DO NOT EDIT THIS FILE MANUALLY
318
+ *
319
+ * Regenerate by running:
320
+ * 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}` : ''}
321
+ */
322
+
323
+ declare module '@ethlete/core' {
324
+ interface EthleteColorThemeNameRegistry {
325
+ name: ${names};
326
+ }
327
+ }
328
+
329
+ export {};
330
+ `;
331
+ }
332
+ function generateTailwindThemeCss(themes, utilityPrefix, runtimePrefix, schema) {
299
333
  const tailwindVars = [];
300
334
  const themeVars = [];
301
335
  const themesPath = schema.themesPath || 'src/themes.ts';
302
336
  const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
303
- const header = `/*
337
+ const header = `/*
304
338
  * Auto-generated Tailwind 4 theme colors from @ethlete/core
305
339
  * DO NOT EDIT THIS FILE MANUALLY
306
- *
340
+ *
307
341
  * Generated from your theme definitions
308
342
  * 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}` : ''}
343
+ * 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}` : ''}
310
344
  */
311
345
 
312
346
  `;
313
347
  // Validation is now done separately before this function is called
314
348
  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);
349
+ const regularThemes = themes.filter((t) => !t.isDefault);
317
350
  // Generate static Tailwind @theme block for each theme
318
351
  for (const theme of themes) {
319
352
  const name = createCssThemeName(theme.name);
320
353
  // Add comment for theme section
321
354
  tailwindVars.push(` /* ${theme.name} theme */`);
322
355
  // Primary colors for Tailwind utilities
323
- addTailwindColorVariants(tailwindVars, `${prefix}-${name}`, theme.primary.color);
356
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}`, theme.primary.color);
324
357
  tailwindVars.push('');
325
358
  // On colors for Tailwind utilities
326
- addTailwindColorVariants(tailwindVars, `${prefix}-on-${name}`, theme.primary.onColor);
359
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}`, theme.primary.onColor);
360
+ tailwindVars.push('');
361
+ // Ink colors for standalone foreground usage
362
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-ink`, theme.primary.inkColor || theme.primary.color);
327
363
  tailwindVars.push('');
328
364
  // Secondary colors if present
329
365
  if (theme.secondary) {
330
- addTailwindColorVariants(tailwindVars, `${prefix}-${name}-secondary`, theme.secondary.color);
366
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-secondary`, theme.secondary.color);
331
367
  tailwindVars.push('');
332
- addTailwindColorVariants(tailwindVars, `${prefix}-on-${name}-secondary`, theme.secondary.onColor);
368
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}-secondary`, theme.secondary.onColor);
333
369
  tailwindVars.push('');
334
370
  }
335
371
  // Tertiary colors if present
336
372
  if (theme.tertiary) {
337
- addTailwindColorVariants(tailwindVars, `${prefix}-${name}-tertiary`, theme.tertiary.color);
373
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-tertiary`, theme.tertiary.color);
338
374
  tailwindVars.push('');
339
- addTailwindColorVariants(tailwindVars, `${prefix}-on-${name}-tertiary`, theme.tertiary.onColor);
375
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}-tertiary`, theme.tertiary.onColor);
340
376
  tailwindVars.push('');
341
377
  }
342
378
  }
343
379
  tailwindVars.push('');
344
- // Generate dynamic theme variables - check main themes and alt themes separately
380
+ // Main theme dynamic colors
345
381
  const mainThemes = [...defaultThemes, ...regularThemes];
346
382
  const hasSecondary = mainThemes.some((t) => t.secondary);
347
383
  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
384
  tailwindVars.push(' /* Dynamic theme colors (references runtime CSS variables) */');
352
- addDynamicThemeColors(tailwindVars, prefix, 'theme', 'primary', true);
385
+ addDynamicThemeColors(tailwindVars, utilityPrefix, runtimePrefix, 'theme', 'primary', true);
386
+ addDynamicInkColors(tailwindVars, utilityPrefix, runtimePrefix, 'theme-ink', 'primary-ink');
353
387
  if (hasSecondary) {
354
- addDynamicThemeColors(tailwindVars, prefix, 'theme-secondary', 'secondary', false);
388
+ addDynamicThemeColors(tailwindVars, utilityPrefix, runtimePrefix, 'theme-secondary', 'secondary', false);
355
389
  }
356
390
  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);
391
+ addDynamicThemeColors(tailwindVars, utilityPrefix, runtimePrefix, 'theme-tertiary', 'tertiary', false);
364
392
  }
365
- if (hasAltTertiary) {
366
- addDynamicThemeColors(tailwindVars, prefix, 'alt-theme-tertiary', 'alt-tertiary', false);
367
- }
368
- // Generate runtime CSS for ALL themes (both main and alt variants)
393
+ // Generate runtime CSS for all themes
369
394
  themes.forEach((theme) => {
370
395
  const name = createCssThemeName(theme.name);
371
- // Determine if this is the default or default-alt theme
396
+ // Determine if this is the default theme
372
397
  const isDefault = theme.isDefault;
373
- const isDefaultAlt = theme.isDefaultAlt;
374
- // Generate main theme variant (.et-theme--{name})
398
+ // Generate color variant (.<runtimePrefix>-color--{name})
375
399
  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}`];
400
+ // Default color gets :root and .<runtimePrefix>-color--default selectors
401
+ const selectors = [':root', `.${runtimePrefix}-color--default`, `.${runtimePrefix}-color--${name}`];
392
402
  themeVars.push(`${selectors.join(', ')} {`);
393
403
  }
394
404
  else {
395
- // All themes (including default and regular) get an alt variant
396
- themeVars.push(`.${prefix}-theme-alt--${name} {`);
405
+ // Regular colors just get their own class
406
+ themeVars.push(`.${runtimePrefix}-color--${name} {`);
397
407
  }
398
- addThemeColorVariants(themeVars, prefix, 'alt-', theme);
408
+ addThemeColorVariants(themeVars, runtimePrefix, '', theme);
399
409
  themeVars.push('}\n');
400
410
  });
411
+ // Convenience var aliases — available on any element with a color class (or root for default)
412
+ const aliasBlock = `/* Convenience aliases (rgb + solid + opacity variants) */
413
+ @layer base {
414
+ :root, :where([class*="${runtimePrefix}-color--"]) {
415
+ --${runtimePrefix}-theme-color-primary-rgb: var(--${runtimePrefix}-color-primary);
416
+ --${runtimePrefix}-theme-color-primary-opacity: 1;
417
+ --${runtimePrefix}-theme-color-primary-solid: rgb(var(--${runtimePrefix}-theme-color-primary-rgb));
418
+ --${runtimePrefix}-theme-color-primary: rgb(var(--${runtimePrefix}-theme-color-primary-rgb) / var(--${runtimePrefix}-theme-color-primary-opacity));
419
+
420
+ --${runtimePrefix}-theme-color-on-primary-rgb: var(--${runtimePrefix}-color-on-primary);
421
+ --${runtimePrefix}-theme-color-on-primary-opacity: 1;
422
+ --${runtimePrefix}-theme-color-on-primary-solid: rgb(var(--${runtimePrefix}-theme-color-on-primary-rgb) / var(--${runtimePrefix}-theme-color-on-primary-opacity));
423
+ --${runtimePrefix}-theme-color-on-primary: rgb(var(--${runtimePrefix}-theme-color-on-primary-rgb) / var(--${runtimePrefix}-theme-color-on-primary-opacity));
424
+
425
+ --${runtimePrefix}-theme-color-ink-rgb: var(--${runtimePrefix}-color-primary-ink, var(--${runtimePrefix}-color-primary));
426
+ --${runtimePrefix}-theme-color-ink-opacity: 1;
427
+ --${runtimePrefix}-theme-color-ink-solid: rgb(var(--${runtimePrefix}-theme-color-ink-rgb) / var(--${runtimePrefix}-theme-color-ink-opacity));
428
+ --${runtimePrefix}-theme-color-ink: rgb(var(--${runtimePrefix}-theme-color-ink-rgb) / var(--${runtimePrefix}-theme-color-ink-opacity));
429
+ }
430
+ }
431
+ `;
401
432
  return `${header}@theme {
402
433
  ${tailwindVars.join('\n')}
403
434
  }
404
435
 
405
- ${themeVars.join('\n')}`;
436
+ @layer base {
437
+ ${themeVars.join('\n')}
438
+ }
439
+
440
+ ${aliasBlock}`;
406
441
  }
407
- function addDynamicThemeColors(vars, prefix, tailwindName, cssVarName, addSpacingBefore) {
442
+ function addDynamicThemeColors(vars, utilityPrefix, runtimePrefix, tailwindName, cssVarName, addSpacingBefore) {
408
443
  if (addSpacingBefore && vars.length > 0 && vars[vars.length - 1] !== '') {
409
444
  vars.push('');
410
445
  }
411
446
  // 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));`);
447
+ vars.push(` --color-${utilityPrefix}-${tailwindName}: rgb(var(--${runtimePrefix}-color-${cssVarName}));`);
448
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-${cssVarName}-hover));`);
449
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-${cssVarName}-focus));`);
450
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-${cssVarName}-active));`);
451
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-${cssVarName}-disabled));`);
417
452
  vars.push('');
418
453
  // 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));`);
454
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}: rgb(var(--${runtimePrefix}-color-on-${cssVarName}));`);
455
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-hover));`);
456
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-focus));`);
457
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-active));`);
458
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-disabled));`);
459
+ vars.push('');
460
+ }
461
+ function addDynamicInkColors(vars, utilityPrefix, runtimePrefix, tailwindName, cssVarName) {
462
+ vars.push(` --color-${utilityPrefix}-${tailwindName}: rgb(var(--${runtimePrefix}-color-${cssVarName}));`);
463
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-${cssVarName}-hover));`);
464
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-${cssVarName}-focus));`);
465
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-${cssVarName}-active));`);
466
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-${cssVarName}-disabled));`);
424
467
  vars.push('');
425
468
  }
426
469
  function addTailwindColorVariants(vars, colorName, colorSet) {
@@ -465,6 +508,17 @@ function addThemeColorVariants(vars, prefix, altPrefix, theme) {
465
508
  vars.push(` --${prefix}-color-${altPrefix}on-${level}-focus: ${onFocusColor};`);
466
509
  vars.push(` --${prefix}-color-${altPrefix}on-${level}-active: ${onActiveColor};`);
467
510
  vars.push(` --${prefix}-color-${altPrefix}on-${level}-disabled: ${onDisabledColor};`);
511
+ const inkDefaultColor = swatch.inkColor?.default || defaultColor;
512
+ const inkHoverColor = swatch.inkColor?.hover || inkDefaultColor;
513
+ const inkFocusColor = swatch.inkColor?.focus || inkHoverColor;
514
+ const inkActiveColor = swatch.inkColor?.active || inkDefaultColor;
515
+ const inkDisabledColor = swatch.inkColor?.disabled || inkDefaultColor;
516
+ vars.push('');
517
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink: ${inkDefaultColor};`);
518
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-hover: ${inkHoverColor};`);
519
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-focus: ${inkFocusColor};`);
520
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-active: ${inkActiveColor};`);
521
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-disabled: ${inkDisabledColor};`);
468
522
  if (theme.secondary || theme.tertiary) {
469
523
  vars.push('');
470
524
  }
@@ -0,0 +1,40 @@
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
+ "skipFormat": {
34
+ "type": "boolean",
35
+ "description": "Skip formatting files after generation",
36
+ "default": false
37
+ }
38
+ },
39
+ "required": ["themesPath"]
40
+ }