@ethlete/core 5.0.0-next.60 → 5.0.0-next.62

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.
@@ -1,15 +1,12 @@
1
1
  import { formatFiles, logger, visitNotIgnoredFiles } from '@nx/devkit';
2
+ import { posix } from 'path';
2
3
  import { Project, SyntaxKind } from 'ts-morph';
3
4
  export default async function generate(tree, schema) {
4
5
  logger.log('\nšŸ”„ Starting Tailwind 4 theme generator...\n');
5
6
  const themesPath = schema.themesPath || 'src/themes.ts';
6
7
  const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
7
8
  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
9
  const runtimePrefix = schema.runtimePrefix || prefix;
12
- // Step 1: Check if themes file exists
13
10
  if (!tree.exists(themesPath)) {
14
11
  logger.error(`āŒ Themes file not found at: ${themesPath}`);
15
12
  logger.log(`\nPlease specify the correct path using --themesPath option.`);
@@ -17,16 +14,14 @@ export default async function generate(tree, schema) {
17
14
  return;
18
15
  }
19
16
  logger.log(`šŸ“ Reading themes from: ${themesPath}`);
20
- // Step 2: Read and parse themes file
21
17
  const themesContent = tree.read(themesPath, 'utf-8');
22
18
  if (!themesContent) {
23
19
  logger.error('āŒ Failed to read themes file');
24
20
  return;
25
21
  }
26
- // Step 3: Try to extract themes using TypeScript
27
22
  let themes;
28
23
  try {
29
- themes = extractThemesFromContent(themesContent, themesPath);
24
+ themes = extractThemesFromContent(tree, themesContent, themesPath);
30
25
  logger.log(`āœ… Found ${themes.length} theme(s)`);
31
26
  }
32
27
  catch (error) {
@@ -36,7 +31,7 @@ export default async function generate(tree, schema) {
36
31
  logger.log(' export const THEMES = [...] satisfies Theme[];\n');
37
32
  return;
38
33
  }
39
- // Step 4: Apply the generation-time default override, if any. The shared theme
34
+ // Apply the generation-time default override, if any. The shared theme
40
35
  // definitions may mark a default via isDefault, but in a monorepo each app picks its
41
36
  // own default at its generation invocation - the option wins over the definitions.
42
37
  if (schema.defaultTheme) {
@@ -50,7 +45,6 @@ export default async function generate(tree, schema) {
50
45
  return;
51
46
  }
52
47
  }
53
- // Step 5: Validate theme configuration
54
48
  try {
55
49
  validateThemeConfiguration(themes);
56
50
  }
@@ -59,27 +53,24 @@ export default async function generate(tree, schema) {
59
53
  logger.error(` ${error instanceof Error ? error.message : String(error)}`);
60
54
  return;
61
55
  }
62
- // Step 6: Generate Tailwind CSS
63
56
  logger.log('\nšŸŽØ Generating Tailwind theme CSS...');
64
57
  const css = generateTailwindThemeCss(themes, prefix, runtimePrefix, schema);
65
58
  const typesOutputPath = schema.typesOutputPath ?? outputPath.replace(/\.css$/, '.d.ts');
66
59
  if (typesOutputPath === outputPath) {
67
60
  throw new Error('A custom outputPath must end in .css or be paired with a distinct typesOutputPath.');
68
61
  }
69
- // Step 7: Write the generated CSS file
70
62
  const outputDir = outputPath.substring(0, outputPath.lastIndexOf('/'));
71
63
  if (outputDir && !tree.exists(outputDir)) {
72
64
  logger.log(`šŸ“ Creating directory: ${outputDir}`);
73
65
  }
74
66
  tree.write(outputPath, css);
75
67
  logger.log(`āœ… Generated Tailwind themes at: ${outputPath}`);
76
- // Step 8: Generate the `EthleteColorThemeNameRegistry` augmentation, so `etProvideColor`
68
+ // Generate the `EthleteColorThemeNameRegistry` augmentation, so `etProvideColor`
77
69
  // (and anything else that accepts a `RegisteredColorThemeName`) is checked/autocompleted
78
70
  // against this app's actual theme names, instead of a plain `string`.
79
71
  const typesDts = generateColorThemeNameTypes(themes, schema);
80
72
  tree.write(typesOutputPath, typesDts);
81
73
  logger.log(`āœ… Generated color theme name types at: ${typesOutputPath}`);
82
- // Step 9: Try to find and update main styles file
83
74
  const mainStylesFiles = findMainStylesFile(tree);
84
75
  if (mainStylesFiles.length > 0) {
85
76
  logger.log('\nšŸ“ Found potential main styles files:');
@@ -92,26 +83,21 @@ export default async function generate(tree, schema) {
92
83
  }
93
84
  logger.log('\nāœ… Generation completed successfully!\n');
94
85
  }
95
- //#endregion
96
- //#region Helper Functions
97
- function extractThemesFromContent(content, filePath) {
98
- // Create an in-memory TypeScript project
86
+ function extractThemesFromContent(tree, content, filePath) {
99
87
  const project = new Project({
100
88
  useInMemoryFileSystem: true,
101
89
  compilerOptions: {
102
- target: 99, // ESNext
103
- module: 99, // ESNext
90
+ target: 99,
91
+ module: 99,
104
92
  },
105
93
  });
106
- // Add the source file
107
94
  const sourceFile = project.createSourceFile(filePath, content);
95
+ const context = { tree, project };
108
96
  const themes = [];
109
- // Find all exported const declarations
110
97
  const exportedDeclarations = sourceFile.getVariableDeclarations().filter((decl) => {
111
98
  const statement = decl.getVariableStatement();
112
99
  return statement?.isExported();
113
100
  });
114
- // Look for the THEMES or themes array
115
101
  const themesArray = exportedDeclarations.find((decl) => {
116
102
  const name = decl.getName();
117
103
  return name === 'THEMES' || name === 'themes';
@@ -131,7 +117,6 @@ function extractThemesFromContent(content, filePath) {
131
117
  if (!initializer.isKind(SyntaxKind.ArrayLiteralExpression)) {
132
118
  throw new Error('THEMES export must be an array literal');
133
119
  }
134
- // Get the array elements (these are references to the theme const declarations)
135
120
  const elements = initializer.getElements();
136
121
  for (const element of elements) {
137
122
  let themeObj;
@@ -160,7 +145,7 @@ function extractThemesFromContent(content, filePath) {
160
145
  continue;
161
146
  }
162
147
  try {
163
- const theme = parseThemeObject(themeObj, sourceFile);
148
+ const theme = parseThemeObject(themeObj, sourceFile, context);
164
149
  themes.push(theme);
165
150
  }
166
151
  catch (error) {
@@ -172,7 +157,7 @@ function extractThemesFromContent(content, filePath) {
172
157
  }
173
158
  return themes;
174
159
  }
175
- function parseThemeObject(obj, sourceFile) {
160
+ function parseThemeObject(obj, sourceFile, context) {
176
161
  const properties = obj.getProperties();
177
162
  const theme = {};
178
163
  for (const prop of properties) {
@@ -204,7 +189,7 @@ function parseThemeObject(obj, sourceFile) {
204
189
  case 'secondary':
205
190
  case 'tertiary':
206
191
  if (initializer.isKind(SyntaxKind.ObjectLiteralExpression)) {
207
- theme[propName] = parseThemeSwatch(initializer, sourceFile);
192
+ theme[propName] = parseThemeSwatch(initializer, sourceFile, context);
208
193
  }
209
194
  break;
210
195
  }
@@ -214,8 +199,6 @@ function parseThemeObject(obj, sourceFile) {
214
199
  }
215
200
  return theme;
216
201
  }
217
- // Makes the named theme the sole default, overriding any isDefault flags in the theme
218
- // definitions. Matches the theme's name or its CSS-safe form. Returns the resolved name.
219
202
  function applyDefaultThemeOverride(themes, defaultTheme) {
220
203
  const wanted = createCssThemeName(defaultTheme);
221
204
  const target = themes.find((t) => t.name === defaultTheme || createCssThemeName(t.name) === wanted);
@@ -229,15 +212,12 @@ function applyDefaultThemeOverride(themes, defaultTheme) {
229
212
  }
230
213
  function validateThemeConfiguration(themes) {
231
214
  const defaultThemes = themes.filter((t) => t.isDefault);
232
- // Error: No default theme
233
215
  if (defaultThemes.length === 0) {
234
216
  throw new Error('No default theme found. At least one theme must have isDefault: true');
235
217
  }
236
- // Error: Multiple default themes
237
218
  if (defaultThemes.length > 1) {
238
219
  throw new Error(`Multiple default themes found: ${defaultThemes.map((t) => t.name).join(', ')}. Only one theme can have isDefault: true`);
239
220
  }
240
- // Error: Duplicate theme types
241
221
  const typeMap = new Map();
242
222
  for (const theme of themes) {
243
223
  if (!theme.type) {
@@ -253,7 +233,7 @@ function validateThemeConfiguration(themes) {
253
233
  }
254
234
  }
255
235
  }
256
- function parseThemeSwatch(obj, sourceFile) {
236
+ function parseThemeSwatch(obj, sourceFile, context) {
257
237
  const properties = obj.getProperties();
258
238
  const swatch = {};
259
239
  for (const prop of properties) {
@@ -265,7 +245,7 @@ function parseThemeSwatch(obj, sourceFile) {
265
245
  if (!initializer) {
266
246
  continue;
267
247
  }
268
- const colorMap = parseColorMap(initializer, sourceFile);
248
+ const colorMap = parseColorMap(initializer, sourceFile, context);
269
249
  if (!colorMap) {
270
250
  continue;
271
251
  }
@@ -286,13 +266,58 @@ function parseThemeSwatch(obj, sourceFile) {
286
266
  }
287
267
  const COLOR_MAP_KEYS = ['default', 'hover', 'focus', 'active', 'disabled'];
288
268
  const isColorMapKey = (name) => COLOR_MAP_KEYS.some((key) => key === name);
289
- function parseColorMap(initializer, sourceFile) {
269
+ function unwrapTypeExpressions(expression) {
270
+ let current = expression;
271
+ while (current?.isKind(SyntaxKind.AsExpression) || current?.isKind(SyntaxKind.SatisfiesExpression)) {
272
+ current = current.getExpression();
273
+ }
274
+ return current;
275
+ }
276
+ function resolveConstInitializer(name, sourceFile, context) {
277
+ const local = sourceFile.getVariableDeclarations().find((decl) => decl.getName() === name);
278
+ if (local) {
279
+ const initializer = unwrapTypeExpressions(local.getInitializer());
280
+ return initializer ? { initializer, sourceFile } : null;
281
+ }
282
+ const importDecl = sourceFile
283
+ .getImportDeclarations()
284
+ .find((decl) => decl.getNamedImports().some((named) => (named.getAliasNode()?.getText() ?? named.getName()) === name));
285
+ if (!importDecl) {
286
+ return null;
287
+ }
288
+ const specifier = importDecl.getModuleSpecifierValue();
289
+ const filePath = sourceFile.getFilePath();
290
+ const importedName = importDecl
291
+ .getNamedImports()
292
+ .find((named) => (named.getAliasNode()?.getText() ?? named.getName()) === name)
293
+ ?.getName() ?? name;
294
+ const warnUnresolved = (reason) => logger.warn(`āš ļø Could not resolve "${name}" imported from "${specifier}" in ${filePath} (${reason}); it is ignored.`);
295
+ if (!specifier.startsWith('.')) {
296
+ warnUnresolved('package imports are not followed');
297
+ return null;
298
+ }
299
+ const base = posix.join(posix.dirname(filePath), specifier).replace(/^\//, '');
300
+ const candidates = [`${base}.ts`, posix.join(base, 'index.ts'), base];
301
+ const resolvedPath = candidates.find((candidate) => context.tree.isFile(candidate));
302
+ if (!resolvedPath) {
303
+ warnUnresolved('file not found in the workspace');
304
+ return null;
305
+ }
306
+ const importedFile = context.project.getSourceFile(resolvedPath) ??
307
+ context.project.createSourceFile(resolvedPath, context.tree.read(resolvedPath, 'utf-8') ?? '');
308
+ const declaration = importedFile.getVariableDeclarations().find((decl) => decl.getName() === importedName);
309
+ const initializer = unwrapTypeExpressions(declaration?.getInitializer());
310
+ if (!initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
311
+ warnUnresolved(`not an object literal in ${resolvedPath}`);
312
+ return null;
313
+ }
314
+ return { initializer, sourceFile: importedFile };
315
+ }
316
+ function parseColorMap(initializer, sourceFile, context) {
290
317
  if (initializer.isKind(SyntaxKind.Identifier)) {
291
- const declaration = sourceFile.getVariableDeclarations().find((decl) => decl.getName() === initializer.getText());
292
- const referencedInitializer = declaration?.getInitializer();
293
- return referencedInitializer ? parseColorMap(referencedInitializer, sourceFile) : null;
318
+ const resolved = resolveConstInitializer(initializer.getText(), sourceFile, context);
319
+ return resolved ? parseColorMap(resolved.initializer, resolved.sourceFile, context) : null;
294
320
  }
295
- // Handle spread expressions by resolving references
296
321
  if (initializer.isKind(SyntaxKind.ObjectLiteralExpression)) {
297
322
  const colorMap = {};
298
323
  const properties = initializer.getProperties();
@@ -305,30 +330,22 @@ function parseColorMap(initializer, sourceFile) {
305
330
  }
306
331
  }
307
332
  else if (prop.isKind(SyntaxKind.SpreadAssignment)) {
308
- // Handle spread: { ...onColorDark, disabled: '...' }
309
333
  const spreadExpr = prop.getExpression();
310
334
  if (spreadExpr.isKind(SyntaxKind.Identifier)) {
311
- const referencedName = spreadExpr.getText();
312
- const referencedDecl = sourceFile.getVariableDeclarations().find((decl) => decl.getName() === referencedName);
313
- if (referencedDecl) {
314
- const referencedObj = referencedDecl.getInitializer();
315
- if (referencedObj?.isKind(SyntaxKind.ObjectLiteralExpression)) {
316
- const spreadColors = parseColorMap(referencedObj, sourceFile);
317
- if (spreadColors) {
318
- Object.assign(colorMap, spreadColors);
319
- }
320
- }
335
+ const resolved = resolveConstInitializer(spreadExpr.getText(), sourceFile, context);
336
+ if (resolved) {
337
+ Object.assign(colorMap, parseColorMap(resolved.initializer, resolved.sourceFile, context));
321
338
  }
322
339
  }
340
+ else {
341
+ logger.warn(`āš ļø Unsupported spread "...${spreadExpr.getText()}" in ${sourceFile.getFilePath()}; it is ignored.`);
342
+ }
323
343
  }
324
344
  }
325
- // Apply fallbacks for required fields
326
345
  const defaultColor = colorMap.default;
327
346
  if (defaultColor) {
328
- // Check if this is a ThemeColorMap (has hover, active, or disabled explicitly set)
329
347
  const isThemeColorMap = colorMap.hover !== undefined || colorMap.active !== undefined || colorMap.disabled !== undefined;
330
348
  if (isThemeColorMap) {
331
- // For ThemeColorMap - apply fallbacks for all required fields
332
349
  const hoverColor = colorMap.hover || defaultColor;
333
350
  const result = {
334
351
  default: defaultColor,
@@ -339,7 +356,6 @@ function parseColorMap(initializer, sourceFile) {
339
356
  };
340
357
  return result;
341
358
  }
342
- // For OnThemeColorMap - no fallbacks needed, all fields are optional except default
343
359
  return colorMap;
344
360
  }
345
361
  return null;
@@ -347,7 +363,6 @@ function parseColorMap(initializer, sourceFile) {
347
363
  return null;
348
364
  }
349
365
  function createCssThemeName(name) {
350
- // Convert theme name to CSS-safe format (e.g., "Primary Blue" -> "primary-blue")
351
366
  return name
352
367
  .toLowerCase()
353
368
  .replace(/\s+/g, '-')
@@ -390,31 +405,21 @@ function generateTailwindThemeCss(themes, utilityPrefix, runtimePrefix, schema)
390
405
  */
391
406
 
392
407
  `;
393
- // Validation is now done separately before this function is called
394
- const defaultThemes = themes.filter((t) => t.isDefault);
395
- const regularThemes = themes.filter((t) => !t.isDefault);
396
- // Generate static Tailwind @theme block for each theme
397
408
  for (const theme of themes) {
398
409
  const name = createCssThemeName(theme.name);
399
- // Add comment for theme section
400
410
  tailwindVars.push(` /* ${theme.name} theme */`);
401
- // Primary colors for Tailwind utilities
402
411
  addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}`, theme.primary.color);
403
412
  tailwindVars.push('');
404
- // On colors for Tailwind utilities
405
413
  addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}`, theme.primary.onColor);
406
414
  tailwindVars.push('');
407
- // Ink colors for standalone foreground usage
408
415
  addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-ink`, theme.primary.inkColor || theme.primary.color);
409
416
  tailwindVars.push('');
410
- // Secondary colors if present
411
417
  if (theme.secondary) {
412
418
  addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-secondary`, theme.secondary.color);
413
419
  tailwindVars.push('');
414
420
  addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}-secondary`, theme.secondary.onColor);
415
421
  tailwindVars.push('');
416
422
  }
417
- // Tertiary colors if present
418
423
  if (theme.tertiary) {
419
424
  addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-tertiary`, theme.tertiary.color);
420
425
  tailwindVars.push('');
@@ -423,10 +428,8 @@ function generateTailwindThemeCss(themes, utilityPrefix, runtimePrefix, schema)
423
428
  }
424
429
  }
425
430
  tailwindVars.push('');
426
- // Main theme dynamic colors
427
- const mainThemes = [...defaultThemes, ...regularThemes];
428
- const hasSecondary = mainThemes.some((t) => t.secondary);
429
- const hasTertiary = mainThemes.some((t) => t.tertiary);
431
+ const hasSecondary = themes.some((t) => t.secondary);
432
+ const hasTertiary = themes.some((t) => t.tertiary);
430
433
  // Collect the dynamic theme colors once. They are emitted in `@theme` (so Tailwind generates
431
434
  // the utilities) and ALSO re-declared on every color selector in the alias block below. A
432
435
  // Tailwind `@theme` variable only lands on `:root`, so `rgb(var(--<runtime>-color-*))` resolves
@@ -444,19 +447,14 @@ function generateTailwindThemeCss(themes, utilityPrefix, runtimePrefix, schema)
444
447
  }
445
448
  tailwindVars.push(' /* Dynamic theme colors (references runtime CSS variables) */');
446
449
  tailwindVars.push(...dynamicColorVars);
447
- // Generate runtime CSS for all themes
448
450
  themes.forEach((theme) => {
449
451
  const name = createCssThemeName(theme.name);
450
- // Determine if this is the default theme
451
452
  const isDefault = theme.isDefault;
452
- // Generate color variant (.<runtimePrefix>-color--{name})
453
453
  if (isDefault) {
454
- // Default color gets :root and .<runtimePrefix>-color--default selectors
455
454
  const selectors = [':root', `.${runtimePrefix}-color--default`, `.${runtimePrefix}-color--${name}`];
456
455
  themeVars.push(`${selectors.join(', ')} {`);
457
456
  }
458
457
  else {
459
- // Regular colors just get their own class
460
458
  themeVars.push(`.${runtimePrefix}-color--${name} {`);
461
459
  }
462
460
  addThemeColorVariants(themeVars, runtimePrefix, '', theme);
@@ -468,7 +466,6 @@ function generateTailwindThemeCss(themes, utilityPrefix, runtimePrefix, schema)
468
466
  while (dynamicColorAliasLines.length && dynamicColorAliasLines[dynamicColorAliasLines.length - 1] === '') {
469
467
  dynamicColorAliasLines.pop();
470
468
  }
471
- // Convenience var aliases - available on any element with a color class (or root for default)
472
469
  const aliasBlock = `/* Convenience aliases (rgb + solid + opacity variants) */
473
470
  @layer base {
474
471
  :root, :where([class*="${runtimePrefix}-color--"]) {
@@ -508,14 +505,12 @@ function addDynamicThemeColors(vars, utilityPrefix, runtimePrefix, tailwindName,
508
505
  if (addSpacingBefore && vars.length > 0 && vars[vars.length - 1] !== '') {
509
506
  vars.push('');
510
507
  }
511
- // Color variants
512
508
  vars.push(` --color-${utilityPrefix}-${tailwindName}: rgb(var(--${runtimePrefix}-color-${cssVarName}));`);
513
509
  vars.push(` --color-${utilityPrefix}-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-${cssVarName}-hover));`);
514
510
  vars.push(` --color-${utilityPrefix}-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-${cssVarName}-focus));`);
515
511
  vars.push(` --color-${utilityPrefix}-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-${cssVarName}-active));`);
516
512
  vars.push(` --color-${utilityPrefix}-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-${cssVarName}-disabled));`);
517
513
  vars.push('');
518
- // On-color variants
519
514
  vars.push(` --color-${utilityPrefix}-on-${tailwindName}: rgb(var(--${runtimePrefix}-color-on-${cssVarName}));`);
520
515
  vars.push(` --color-${utilityPrefix}-on-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-hover));`);
521
516
  vars.push(` --color-${utilityPrefix}-on-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-focus));`);
@@ -532,25 +527,18 @@ function addDynamicInkColors(vars, utilityPrefix, runtimePrefix, tailwindName, c
532
527
  vars.push('');
533
528
  }
534
529
  function addTailwindColorVariants(vars, colorName, colorSet) {
535
- // Tailwind 4 requires --color-* prefix and rgb() wrapper
536
- // Always generate all variants with fallbacks
537
530
  vars.push(` --color-${colorName}: rgb(${colorSet.default});`);
538
- // For hover: use hover if exists, otherwise default
539
531
  const hoverValue = 'hover' in colorSet && colorSet.hover ? colorSet.hover : colorSet.default;
540
532
  vars.push(` --color-${colorName}-hover: rgb(${hoverValue});`);
541
- // For focus: use focus if exists, otherwise hover, otherwise default
542
533
  const focusValue = 'focus' in colorSet && colorSet.focus ? colorSet.focus : hoverValue;
543
534
  vars.push(` --color-${colorName}-focus: rgb(${focusValue});`);
544
- // For active: use active if exists, otherwise hover, otherwise default
545
535
  const activeValue = 'active' in colorSet && colorSet.active ? colorSet.active : hoverValue;
546
536
  vars.push(` --color-${colorName}-active: rgb(${activeValue});`);
547
- // For disabled: use disabled if exists, otherwise default
548
537
  const disabledValue = 'disabled' in colorSet && colorSet.disabled ? colorSet.disabled : colorSet.default;
549
538
  vars.push(` --color-${colorName}-disabled: rgb(${disabledValue});`);
550
539
  }
551
540
  function addThemeColorVariants(vars, prefix, altPrefix, theme) {
552
541
  const addSwatch = (level, swatch) => {
553
- // Color variants with fallbacks
554
542
  const defaultColor = swatch.color.default;
555
543
  const hoverColor = swatch.color.hover || defaultColor;
556
544
  const focusColor = swatch.color.focus || hoverColor;
@@ -562,7 +550,6 @@ function addThemeColorVariants(vars, prefix, altPrefix, theme) {
562
550
  vars.push(` --${prefix}-color-${altPrefix}${level}-active: ${activeColor};`);
563
551
  vars.push(` --${prefix}-color-${altPrefix}${level}-disabled: ${disabledColor};`);
564
552
  vars.push('');
565
- // On color variants with fallbacks
566
553
  const onDefaultColor = swatch.onColor.default;
567
554
  const onHoverColor = swatch.onColor.hover || onDefaultColor;
568
555
  const onFocusColor = swatch.onColor.focus || onHoverColor;
@@ -605,4 +592,3 @@ function findMainStylesFile(tree) {
605
592
  });
606
593
  return potentialFiles;
607
594
  }
608
- //#endregion
@@ -0,0 +1,38 @@
1
+ # Move the runtime color theming to Tailwind 4
2
+
3
+ `provideColorThemes()` injects the color theme CSS at runtime and pairs with a Tailwind 3 config built from
4
+ `createTailwindColorThemes()`. It is deprecated: `@ethlete/components` expects
5
+ `provideColorThemesWithTailwind4()`, the CSS the Tailwind 4 generators emit, and a set of surface themes.
6
+
7
+ The theme data carries over unchanged. A codemod does the mechanical part; the rest is setup that depends
8
+ on the app.
9
+
10
+ ## What to change
11
+
12
+ 1. Run the codemod:
13
+
14
+ ```bash
15
+ nx g @ethlete/core:migrate-legacy-color-themes
16
+ ```
17
+
18
+ It rewrites every `provideColorThemes(X)` imported from `@ethlete/core` into
19
+ `provideColorThemesWithTailwind4(X)`, fixes the import, and writes
20
+ `legacy-color-themes-migration-tasks.md` at the workspace root. Pass `--projects` or `--include` to
21
+ migrate one app at a time.
22
+
23
+ 2. Work through `legacy-color-themes-migration-tasks.md`. It lists, per call site, the
24
+ `tailwind-4-color-theme` generator run that replaces the runtime styles, every Tailwind 3 helper left
25
+ in a config, and - when no app provides any yet - the surface themes to build.
26
+
27
+ 3. Move the Tailwind 3 config to Tailwind 4, run both generators, and import the generated `.css` in each
28
+ app's global stylesheet. Keep the prefix the Tailwind 3 config used, so the utility names stay.
29
+
30
+ 4. Derive the first surface themes from the app's existing page background, text, muted text and border
31
+ colors. Ask the user before inventing colors the app does not have.
32
+
33
+ 5. Search the app's own CSS for `--et-color-alt-`. Those variables are not emitted any more; move the
34
+ region into its own `etProvideColor` scope and read `--et-color-primary` there.
35
+
36
+ 6. Build the app and compare a page against the old one: the colors must match.
37
+
38
+ The guide is at <https://ethlete-sdk-docs.web.app/core/theming#migrating-from-runtime-theming>.
@@ -9,7 +9,8 @@ function call. Both are decisions about the call site.
9
9
 
10
10
  ## What to change
11
11
 
12
- 1. Find every component that injects `SeoDirective` or lists it in `hostDirectives` or `imports`.
12
+ 1. Find every component that injects `SeoDirective` or `SEO_DIRECTIVE_TOKEN`, or lists the directive in
13
+ `hostDirectives` or `imports`.
13
14
  2. Remove the directive from the component.
14
15
  3. Replace the `updateConfig({ … })` call with one `apply*Binding` call per key it set.
15
16
  4. Turn an Observable value into a signal: `toSignal()` at the edge of the component, or a
package/migrations.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "name": "to-v5",
5
5
  "version": "5.0.0-beta.11",
6
6
  "kind": "auto",
7
+ "level": "required",
7
8
  "description": "Rewrite the v4 core API into its v5 shape.",
8
9
  "generator": "@ethlete/core:migrate-to-v5"
9
10
  },
@@ -11,6 +12,7 @@
11
12
  "name": "provider-shape",
12
13
  "version": "5.0.0-next.37",
13
14
  "kind": "auto",
15
+ "level": "required",
14
16
  "description": "Rewrite destructured provider tuples into the tree-shakeable definition shape.",
15
17
  "generator": "@ethlete/core:migrate-provider-shape",
16
18
  "docs": "/core/providers"
@@ -19,6 +21,7 @@
19
21
  "name": "seo-directive-removed",
20
22
  "version": "5.0.0-next.41",
21
23
  "kind": "assisted",
24
+ "level": "required",
22
25
  "description": "SeoDirective and SeoConfig are gone. Every call site moves to the apply*Binding function for its key.",
23
26
  "instructions": "./migrations/seo-directive-removed.md",
24
27
  "docs": "/core/seo"
@@ -27,6 +30,7 @@
27
30
  "name": "surface-interaction-swatch",
28
31
  "version": "5.0.0-next.46",
29
32
  "kind": "auto",
33
+ "level": "required",
30
34
  "description": "Rewrite surface theme interactionColor maps into the { color, onColor, inkColor } swatch shape.",
31
35
  "generator": "@ethlete/core:migrate-surface-interaction-swatch",
32
36
  "docs": "/core/theming"
@@ -35,9 +39,20 @@
35
39
  "name": "surface-theme-regenerate",
36
40
  "version": "5.0.0-next.46",
37
41
  "kind": "manual",
42
+ "level": "required",
38
43
  "description": "Regenerate the surface theme stylesheet, so the new swatch reaches the CSS variables.",
39
44
  "instructions": "./migrations/surface-theme-regenerate.md",
40
45
  "docs": "/core/theming"
46
+ },
47
+ {
48
+ "name": "legacy-color-themes",
49
+ "version": "5.0.0-next.62",
50
+ "kind": "assisted",
51
+ "level": "recommended",
52
+ "description": "Move the runtime color theming to Tailwind 4: provideColorThemes becomes provideColorThemesWithTailwind4, and the generated CSS and surface themes replace the runtime styles.",
53
+ "scan": "@ethlete/core:migrate-legacy-color-themes",
54
+ "instructions": "./migrations/legacy-color-themes.md",
55
+ "docs": "/core/theming#migrating-from-runtime-theming"
41
56
  }
42
57
  ]
43
58
  }
package/package.json CHANGED
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "name": "@ethlete/core",
3
- "version": "5.0.0-next.60",
3
+ "version": "5.0.0-next.62",
4
4
  "license": "MIT",
5
5
  "sideEffects": false,
6
6
  "peerDependencies": {
7
- "@angular/common": "22.1.6",
8
- "@angular/core": "22.1.6",
9
- "@angular/forms": "22.1.6",
10
- "@angular/platform-browser": "22.1.6",
11
- "@angular/router": "22.1.6",
7
+ "@angular/common": "^22.1.0",
8
+ "@angular/core": "^22.1.0",
9
+ "@angular/forms": "^22.1.0",
10
+ "@angular/platform-browser": "^22.1.0",
11
+ "@angular/router": "^22.1.0",
12
12
  "@ethlete/types": "^2.0.0-beta.4",
13
- "@floating-ui/dom": "1.8.0",
14
- "@nx/devkit": "23.1.0",
15
- "rxjs": "7.8.2",
16
- "ts-morph": "21.0.1",
17
- "typescript": "6.0.3"
13
+ "@floating-ui/dom": "^1.6.0",
14
+ "@nx/devkit": "^23.0.0",
15
+ "rxjs": "^7.8.0",
16
+ "ts-morph": "^21.0.0",
17
+ "typescript": "^6.0.0"
18
18
  },
19
19
  "peerDependenciesMeta": {
20
20
  "@nx/devkit": {