@ethlete/core 5.0.0-next.7 → 5.0.0-next.71

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +849 -0
  2. package/README.md +28 -4
  3. package/design/interactive-styles.ts +5 -0
  4. package/design/theme-css.js +392 -0
  5. package/design/theme-css.ts +547 -0
  6. package/fesm2022/ethlete-core.mjs +9357 -5667
  7. package/fesm2022/ethlete-core.mjs.map +1 -1
  8. package/generators/devtools-about/generator.js +122 -0
  9. package/generators/devtools-about/schema.json +25 -0
  10. package/generators/generators.json +41 -5
  11. package/generators/migrate-color-theme-variants/color-theme-variants.js +136 -0
  12. package/generators/migrate-color-theme-variants/migration.js +46 -0
  13. package/generators/migrate-color-theme-variants/schema.json +26 -0
  14. package/generators/migrate-create-destroy/create-destroy.js +317 -0
  15. package/generators/migrate-create-destroy/migration.js +46 -0
  16. package/generators/migrate-create-destroy/schema.json +26 -0
  17. package/generators/migrate-legacy-color-themes/legacy-color-themes.js +123 -0
  18. package/generators/migrate-legacy-color-themes/migration.js +93 -0
  19. package/generators/migrate-legacy-color-themes/schema.json +26 -0
  20. package/generators/migrate-provider-shape/migration-scope.js +35 -0
  21. package/generators/migrate-provider-shape/migration.js +50 -0
  22. package/generators/migrate-provider-shape/provider-shape.js +284 -0
  23. package/generators/migrate-provider-shape/schema.json +26 -0
  24. package/generators/migrate-surface-interaction-swatch/interaction-swatch.js +63 -0
  25. package/generators/migrate-surface-interaction-swatch/migration.js +36 -0
  26. package/generators/migrate-surface-interaction-swatch/schema.json +26 -0
  27. package/generators/migrate-to-v5/apply-replacements.js +14 -0
  28. package/generators/migrate-to-v5/color-naming.js +124 -0
  29. package/generators/migrate-to-v5/create-provider.js +67 -55
  30. package/generators/migrate-to-v5/migration-files.js +13 -0
  31. package/generators/migrate-to-v5/migration.js +33 -19
  32. package/generators/migrate-to-v5/removed-exports.js +161 -0
  33. package/generators/migrate-to-v5/router-state-service.js +125 -330
  34. package/generators/migrate-to-v5/schema.json +22 -0
  35. package/generators/migrate-to-v5/service-classes.js +108 -0
  36. package/generators/migrate-to-v5/sync-observable-consumers.js +80 -0
  37. package/generators/migrate-to-v5/viewport-service.js +226 -491
  38. package/generators/tailwind-4-color-theme/generator.js +411 -0
  39. package/generators/tailwind-4-color-theme/schema.json +44 -0
  40. package/generators/tailwind-4-surface-theme/generator.js +379 -0
  41. package/generators/tailwind-4-surface-theme/schema.json +48 -0
  42. package/migrations/legacy-color-themes.md +38 -0
  43. package/migrations/seo-directive-removed.md +81 -0
  44. package/migrations/surface-theme-regenerate.md +20 -0
  45. package/migrations.json +59 -0
  46. package/package.json +38 -21
  47. package/src/lib/animations/easing-tokens-styles.component.css +155 -0
  48. package/src/lib/providers/visually-hidden-styles.component.css +13 -0
  49. package/src/lib/resize-handles/resize-handles.component.css +137 -0
  50. package/src/lib/theming/color-interactive-styles.component.css +147 -0
  51. package/src/lib/theming/surface-interactive-styles.component.css +42 -0
  52. package/types/ethlete-core.d.ts +3842 -2205
  53. package/generators/tailwind-4-theme/generator.js +0 -514
  54. package/generators/tailwind-4-theme/schema.json +0 -32
@@ -0,0 +1,379 @@
1
+ import { formatFiles, logger, visitNotIgnoredFiles } from '@nx/devkit';
2
+ import { Project, SyntaxKind } from 'ts-morph';
3
+ import { cssThemeName, surfaceThemeCss } from '../../design/theme-css.js';
4
+ export default async function generate(tree, schema) {
5
+ logger.log('\nšŸ”„ Starting Tailwind 4 surface theme generator...\n');
6
+ const themesPath = schema.themesPath || 'src/surface-themes.ts';
7
+ const outputPath = schema.outputPath || 'src/styles/generated-tailwind-surface-themes.css';
8
+ const prefix = schema.prefix || 'et';
9
+ // The runtime prefix drives the theme-swap CSS variables (--<runtimePrefix>-surface-*)
10
+ // and selectors (.<runtimePrefix>-surface--<name>). It defaults to `prefix` so existing
11
+ // consumers who only ever passed one prefix see no change in output.
12
+ const runtimePrefix = schema.runtimePrefix || prefix;
13
+ if (!tree.exists(themesPath)) {
14
+ logger.error(`āŒ Surface themes file not found at: ${themesPath}`);
15
+ logger.log(`\nPlease specify the correct path using --themesPath option.`);
16
+ logger.log(`Example: nx g @ethlete/core:tailwind-4-surface-theme --themesPath=src/app/surface-themes.ts\n`);
17
+ return;
18
+ }
19
+ logger.log(`šŸ“ Reading surface themes from: ${themesPath}`);
20
+ const themesContent = tree.read(themesPath, 'utf-8');
21
+ if (!themesContent) {
22
+ logger.error('āŒ Failed to read surface themes file');
23
+ return;
24
+ }
25
+ let themes;
26
+ try {
27
+ themes = extractSurfaceThemesFromContent(themesContent, themesPath);
28
+ logger.log(`āœ… Found ${themes.length} surface theme(s)`);
29
+ }
30
+ catch (error) {
31
+ logger.error('āŒ Failed to parse surface themes file');
32
+ logger.error(` ${error instanceof Error ? error.message : String(error)}`);
33
+ logger.log('\nThe surface themes file must export themes as:');
34
+ logger.log(' export const SURFACE_THEMES = [...] satisfies SurfaceTheme[];\n');
35
+ return;
36
+ }
37
+ // Apply the generation-time default overrides, if any. The shared theme definitions may
38
+ // mark defaults via isDefault, but in a monorepo each app picks its own defaults at its
39
+ // generation invocation - the options win over the definitions (per surface type).
40
+ try {
41
+ if (schema.defaultLightTheme) {
42
+ const name = applyDefaultSurfaceThemeOverride(themes, schema.defaultLightTheme, 'light');
43
+ logger.log(`šŸŽÆ Default light surface theme set at generation time: ${name}`);
44
+ }
45
+ if (schema.defaultDarkTheme) {
46
+ const name = applyDefaultSurfaceThemeOverride(themes, schema.defaultDarkTheme, 'dark');
47
+ logger.log(`šŸŽÆ Default dark surface theme set at generation time: ${name}`);
48
+ }
49
+ }
50
+ catch (error) {
51
+ logger.error('āŒ Invalid default theme option');
52
+ logger.error(` ${error instanceof Error ? error.message : String(error)}`);
53
+ return;
54
+ }
55
+ try {
56
+ validateSurfaceThemeConfiguration(themes);
57
+ }
58
+ catch (error) {
59
+ logger.error('āŒ Surface theme configuration error');
60
+ logger.error(` ${error instanceof Error ? error.message : String(error)}`);
61
+ return;
62
+ }
63
+ logger.log('\nšŸŽØ Generating Tailwind surface theme CSS...');
64
+ const css = generateSurfaceThemeCss(themes, prefix, runtimePrefix, schema);
65
+ const typesOutputPath = schema.typesOutputPath ?? outputPath.replace(/\.css$/, '.d.ts');
66
+ if (typesOutputPath === outputPath) {
67
+ throw new Error('A custom outputPath must end in .css or be paired with a distinct typesOutputPath.');
68
+ }
69
+ tree.write(outputPath, css);
70
+ logger.log(`āœ… Generated Tailwind surface themes at: ${outputPath}`);
71
+ // Generate the `EthleteSurfaceThemeNameRegistry` augmentation, so `etProvideSurface` (and
72
+ // anything else that accepts a `RegisteredSurfaceThemeName`) is checked/autocompleted
73
+ // against this app's actual surface theme names, instead of a plain `string`.
74
+ const typesDts = generateSurfaceThemeNameTypes(themes, schema);
75
+ tree.write(typesOutputPath, typesDts);
76
+ logger.log(`āœ… Generated surface theme name types at: ${typesOutputPath}`);
77
+ const mainStylesFiles = findMainStylesFile(tree);
78
+ if (mainStylesFiles.length > 0) {
79
+ logger.log('\nšŸ“ Found potential main styles files:');
80
+ mainStylesFiles.forEach((file) => logger.log(` - ${file}`));
81
+ logger.log('\nāš ļø Please manually import the generated themes:');
82
+ logger.log(` @import './${outputPath.replace('src/styles/', '')}';`);
83
+ }
84
+ if (!schema.skipFormat) {
85
+ await formatFiles(tree);
86
+ }
87
+ logger.log('\nāœ… Generation completed successfully!\n');
88
+ }
89
+ //#endregion
90
+ //#region Parsing
91
+ function extractSurfaceThemesFromContent(content, filePath) {
92
+ const project = new Project({
93
+ useInMemoryFileSystem: true,
94
+ compilerOptions: {
95
+ target: 99,
96
+ module: 99,
97
+ },
98
+ });
99
+ const sourceFile = project.createSourceFile(filePath, content);
100
+ const themes = [];
101
+ const exportedDeclarations = sourceFile.getVariableDeclarations().filter((decl) => {
102
+ const statement = decl.getVariableStatement();
103
+ return statement?.isExported();
104
+ });
105
+ const themesArray = exportedDeclarations.find((decl) => {
106
+ const name = decl.getName();
107
+ return name === 'SURFACE_THEMES' || name === 'surfaceThemes';
108
+ });
109
+ if (!themesArray) {
110
+ throw new Error('Could not find SURFACE_THEMES or surfaceThemes export');
111
+ }
112
+ let initializer = themesArray.getInitializer();
113
+ if (!initializer) {
114
+ throw new Error('SURFACE_THEMES export has no initializer');
115
+ }
116
+ // Handle 'satisfies X'/'as const' wrappers, in either order: [array] satisfies Type[],
117
+ // [array] as const, or both combined.
118
+ while (initializer.isKind(SyntaxKind.SatisfiesExpression) || initializer.isKind(SyntaxKind.AsExpression)) {
119
+ initializer = initializer.getExpression();
120
+ }
121
+ if (!initializer.isKind(SyntaxKind.ArrayLiteralExpression)) {
122
+ throw new Error('SURFACE_THEMES export must be an array literal');
123
+ }
124
+ const elements = initializer.getElements();
125
+ for (const element of elements) {
126
+ let themeObj;
127
+ let name = '<inline>';
128
+ if (element.isKind(SyntaxKind.Identifier)) {
129
+ name = element.getText();
130
+ const themeDecl = exportedDeclarations.find((decl) => decl.getName() === name);
131
+ if (!themeDecl) {
132
+ logger.warn(`āš ļø Could not find declaration for surface theme: ${name}`);
133
+ continue;
134
+ }
135
+ themeObj = themeDecl.getInitializer();
136
+ if (!themeObj) {
137
+ logger.warn(`āš ļø Surface theme ${name} has no initializer`);
138
+ continue;
139
+ }
140
+ }
141
+ else {
142
+ themeObj = element;
143
+ }
144
+ while (themeObj.isKind(SyntaxKind.AsExpression) || themeObj.isKind(SyntaxKind.SatisfiesExpression)) {
145
+ themeObj = themeObj.getExpression();
146
+ }
147
+ if (!themeObj.isKind(SyntaxKind.ObjectLiteralExpression)) {
148
+ logger.warn(`āš ļø Surface theme ${name} is not an object literal`);
149
+ continue;
150
+ }
151
+ try {
152
+ const theme = parseSurfaceThemeObject(themeObj, sourceFile);
153
+ themes.push(theme);
154
+ }
155
+ catch (error) {
156
+ logger.warn(`āš ļø Failed to parse surface theme ${name}: ${error instanceof Error ? error.message : String(error)}`);
157
+ }
158
+ }
159
+ if (themes.length === 0) {
160
+ throw new Error('No valid surface themes found in SURFACE_THEMES array');
161
+ }
162
+ return themes;
163
+ }
164
+ const COLOR_PROPS = ['background', 'color', 'colorMuted', 'colorSubtle', 'border'];
165
+ const isColorProp = (name) => COLOR_PROPS.some((p) => p === name);
166
+ const INTERACTION_KEYS = ['default', 'hover', 'focus', 'active', 'disabled'];
167
+ const isInteractionKey = (name) => INTERACTION_KEYS.some((key) => key === name);
168
+ const SWATCH_KEYS = ['color', 'onColor', 'inkColor'];
169
+ const isSwatchKey = (name) => SWATCH_KEYS.some((key) => key === name);
170
+ const resolveObjectLiteral = (expression, sourceFile) => {
171
+ let resolved = expression;
172
+ while (resolved.isKind(SyntaxKind.AsExpression) || resolved.isKind(SyntaxKind.SatisfiesExpression)) {
173
+ resolved = resolved.getExpression();
174
+ }
175
+ if (resolved.isKind(SyntaxKind.Identifier)) {
176
+ const declaration = sourceFile
177
+ .getVariableDeclarations()
178
+ .find((candidate) => candidate.getName() === resolved.getText());
179
+ const initializer = declaration?.getInitializer();
180
+ return initializer ? resolveObjectLiteral(initializer, sourceFile) : null;
181
+ }
182
+ return resolved.isKind(SyntaxKind.ObjectLiteralExpression) ? resolved : null;
183
+ };
184
+ function parseInteractionColorMap(obj) {
185
+ const map = {};
186
+ for (const prop of obj.getProperties()) {
187
+ if (!prop.isKind(SyntaxKind.PropertyAssignment))
188
+ continue;
189
+ const name = prop.getName();
190
+ const initializer = prop.getInitializer();
191
+ if (initializer?.isKind(SyntaxKind.StringLiteral) && isInteractionKey(name)) {
192
+ map[name] = initializer.getLiteralValue();
193
+ }
194
+ }
195
+ return map;
196
+ }
197
+ function parseInteractionColor(obj, themeName, sourceFile) {
198
+ const swatch = {};
199
+ for (const prop of obj.getProperties()) {
200
+ if (!prop.isKind(SyntaxKind.PropertyAssignment))
201
+ continue;
202
+ const name = prop.getName();
203
+ const initializer = prop.getInitializer();
204
+ if (isInteractionKey(name)) {
205
+ throw new Error(`Surface theme "${themeName}" uses the old flat \`interactionColor\` shape. It now takes a swatch: ` +
206
+ `{ color: { default, hover, focus, active, disabled }, onColor?, inkColor? }. ` +
207
+ `Run \`nx g @ethlete/core:migrate-surface-interaction-swatch\` to convert your themes.`);
208
+ }
209
+ if (!initializer || !isSwatchKey(name))
210
+ continue;
211
+ const mapObject = resolveObjectLiteral(initializer, sourceFile);
212
+ if (!mapObject) {
213
+ throw new Error(`Surface theme "${themeName}" has an interactionColor.${name} value that cannot be resolved.`);
214
+ }
215
+ const map = parseInteractionColorMap(mapObject);
216
+ if (name === 'color') {
217
+ if (!map.default || !map.hover || !map.focus || !map.active || !map.disabled) {
218
+ throw new Error(`Surface theme "${themeName}" must define default, hover, focus, active and disabled interaction colors.`);
219
+ }
220
+ swatch.color = map;
221
+ continue;
222
+ }
223
+ if (map.default) {
224
+ swatch[name] = map;
225
+ }
226
+ }
227
+ if (!swatch.color) {
228
+ throw new Error(`Surface theme "${themeName}" interactionColor must define a color swatch.`);
229
+ }
230
+ return swatch;
231
+ }
232
+ function parseSurfaceThemeObject(obj, sourceFile) {
233
+ const properties = obj.getProperties();
234
+ const theme = {};
235
+ let interactionColorObj = null;
236
+ for (const prop of properties) {
237
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) {
238
+ continue;
239
+ }
240
+ const propName = prop.getName();
241
+ const initializer = prop.getInitializer();
242
+ if (!initializer) {
243
+ continue;
244
+ }
245
+ if (propName === 'isDefault') {
246
+ if (initializer.isKind(SyntaxKind.TrueKeyword)) {
247
+ theme.isDefault = true;
248
+ }
249
+ continue;
250
+ }
251
+ if (propName === 'interactionColor') {
252
+ interactionColorObj = resolveObjectLiteral(initializer, sourceFile);
253
+ if (!interactionColorObj) {
254
+ throw new Error('Surface theme interactionColor must be an inline or same-file object literal.');
255
+ }
256
+ continue;
257
+ }
258
+ if (propName === 'elevation') {
259
+ if (initializer.isKind(SyntaxKind.NumericLiteral)) {
260
+ theme.elevation = Number(initializer.getLiteralValue());
261
+ }
262
+ continue;
263
+ }
264
+ if (propName === 'type' && initializer.isKind(SyntaxKind.StringLiteral)) {
265
+ theme.type = initializer.getLiteralValue();
266
+ continue;
267
+ }
268
+ if (!initializer.isKind(SyntaxKind.StringLiteral)) {
269
+ continue;
270
+ }
271
+ if (propName === 'name') {
272
+ theme.name = initializer.getLiteralValue();
273
+ }
274
+ else if (isColorProp(propName)) {
275
+ theme[propName] = initializer.getLiteralValue();
276
+ }
277
+ }
278
+ if (!theme.name ||
279
+ !theme.type ||
280
+ theme.elevation === undefined ||
281
+ !theme.background ||
282
+ !theme.color ||
283
+ !theme.colorMuted ||
284
+ !theme.colorSubtle ||
285
+ !theme.border) {
286
+ throw new Error('SurfaceTheme must have name, type, elevation, background, color, colorMuted, colorSubtle, and border properties');
287
+ }
288
+ if (interactionColorObj) {
289
+ theme.interactionColor = parseInteractionColor(interactionColorObj, theme.name, sourceFile);
290
+ }
291
+ return theme;
292
+ }
293
+ // Makes the named theme the sole default of its surface type, overriding any isDefault
294
+ // flags on themes of that type. Matches the theme's name or its CSS-safe form. Themes of
295
+ // the other type keep their flags. Returns the resolved name.
296
+ function applyDefaultSurfaceThemeOverride(themes, defaultTheme, type) {
297
+ const wanted = cssThemeName(defaultTheme);
298
+ const matches = themes.filter((t) => t.name === defaultTheme || cssThemeName(t.name) === wanted);
299
+ const target = matches.find((t) => t.type === type);
300
+ if (!target) {
301
+ const [firstMatch] = matches;
302
+ if (firstMatch) {
303
+ throw new Error(`Theme "${defaultTheme}" has type '${firstMatch.type}', but was passed as the default '${type}' theme`);
304
+ }
305
+ const available = themes.filter((t) => t.type === type).map((t) => t.name);
306
+ throw new Error(`No surface theme named "${defaultTheme}" found. Available '${type}' themes: ${available.join(', ')}`);
307
+ }
308
+ for (const theme of themes) {
309
+ if (theme.type === type) {
310
+ theme.isDefault = theme === target;
311
+ }
312
+ }
313
+ return target.name;
314
+ }
315
+ function validateSurfaceThemeConfiguration(themes) {
316
+ const defaultThemes = themes.filter((t) => t.isDefault);
317
+ if (defaultThemes.length === 0) {
318
+ throw new Error('No default surface theme found. Each type (light/dark) must have exactly one theme with isDefault: true');
319
+ }
320
+ const types = new Set(themes.map((t) => t.type));
321
+ for (const type of types) {
322
+ const defaultsForType = defaultThemes.filter((t) => t.type === type);
323
+ if (defaultsForType.length === 0) {
324
+ throw new Error(`No default surface theme found for type '${type}'. Add isDefault: true to one '${type}' theme`);
325
+ }
326
+ if (defaultsForType.length > 1) {
327
+ throw new Error(`Multiple default surface themes found for type '${type}': ${defaultsForType.map((t) => t.name).join(', ')}. Only one theme per type can have isDefault: true`);
328
+ }
329
+ }
330
+ }
331
+ //#endregion
332
+ //#region CSS Generation
333
+ function generateSurfaceThemeNameTypes(themes, schema) {
334
+ const themesPath = schema.themesPath || 'src/surface-themes.ts';
335
+ const outputPath = schema.outputPath || 'src/styles/generated-tailwind-surface-themes.css';
336
+ const typesOutputPath = schema.typesOutputPath || outputPath.replace(/\.css$/, '.d.ts');
337
+ const names = themes.map((theme) => `'${theme.name}'`).join(' | ');
338
+ return `/*
339
+ * Auto-generated by @ethlete/core:tailwind-4-surface-theme
340
+ * DO NOT EDIT THIS FILE MANUALLY
341
+ *
342
+ * Regenerate by running:
343
+ * nx g @ethlete/core:tailwind-4-surface-theme --themesPath=${themesPath}${schema.outputPath ? ` --outputPath=${outputPath}` : ''}${schema.typesOutputPath ? ` --typesOutputPath=${typesOutputPath}` : ''}${schema.prefix && schema.prefix !== 'et' ? ` --prefix=${schema.prefix}` : ''}${schema.defaultLightTheme ? ` --defaultLightTheme=${schema.defaultLightTheme}` : ''}${schema.defaultDarkTheme ? ` --defaultDarkTheme=${schema.defaultDarkTheme}` : ''}
344
+ */
345
+
346
+ declare module '@ethlete/core' {
347
+ interface EthleteSurfaceThemeNameRegistry {
348
+ name: ${names};
349
+ }
350
+ }
351
+
352
+ export {};
353
+ `;
354
+ }
355
+ function generateSurfaceThemeCss(themes, utilityPrefix, runtimePrefix, schema) {
356
+ const themesPath = schema.themesPath || 'src/surface-themes.ts';
357
+ const outputPath = schema.outputPath || 'src/styles/generated-tailwind-surface-themes.css';
358
+ const header = `/*
359
+ * Auto-generated Tailwind 4 surface theme colors from @ethlete/core
360
+ * DO NOT EDIT THIS FILE MANUALLY
361
+ *
362
+ * Generated from your surface theme definitions
363
+ * This file can be regenerated by running:
364
+ * nx g @ethlete/core:tailwind-4-surface-theme --themesPath=${themesPath}${schema.outputPath ? ` --outputPath=${outputPath}` : ''}${schema.prefix && schema.prefix !== 'et' ? ` --prefix=${schema.prefix}` : ''}${schema.runtimePrefix && schema.runtimePrefix !== schema.prefix ? ` --runtimePrefix=${schema.runtimePrefix}` : ''}${schema.defaultLightTheme ? ` --defaultLightTheme=${schema.defaultLightTheme}` : ''}${schema.defaultDarkTheme ? ` --defaultDarkTheme=${schema.defaultDarkTheme}` : ''}
365
+ */
366
+
367
+ `;
368
+ return `${header}${surfaceThemeCss(themes, { prefix: utilityPrefix, runtimePrefix })}`;
369
+ }
370
+ function findMainStylesFile(tree) {
371
+ const potentialFiles = [];
372
+ visitNotIgnoredFiles(tree, '', (path) => {
373
+ if (path.match(/styles\.(css|scss)$/) && !path.includes('node_modules') && !path.includes('dist')) {
374
+ potentialFiles.push(path);
375
+ }
376
+ });
377
+ return potentialFiles;
378
+ }
379
+ //#endregion
@@ -0,0 +1,48 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "tailwind-4-surface-theme",
4
+ "title": "Tailwind 4 Surface Theming Generator",
5
+ "description": "Generates Tailwind 4 compatible CSS from your Ethlete surface theme definitions",
6
+ "type": "object",
7
+ "properties": {
8
+ "themesPath": {
9
+ "type": "string",
10
+ "description": "Path to your surface themes file (relative to workspace root)",
11
+ "default": "src/surface-themes.ts",
12
+ "x-prompt": "Where is your surface themes file located?"
13
+ },
14
+ "outputPath": {
15
+ "type": "string",
16
+ "description": "Where to output the generated Tailwind surface themes CSS",
17
+ "default": "src/styles/generated-tailwind-surface-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 `EthleteSurfaceThemeNameRegistry` augmentation (a .d.ts that narrows `etProvideSurface` and other surface-theme-name APIs to your actual surface theme names). Defaults to `outputPath` with its extension swapped to `.d.ts`."
23
+ },
24
+ "prefix": {
25
+ "type": "string",
26
+ "description": "Prefix for Tailwind utility surface variable names (e.g., 'et' creates --color-et-surface-*, usable as bg-et-surface)",
27
+ "default": "et"
28
+ },
29
+ "runtimePrefix": {
30
+ "type": "string",
31
+ "description": "Prefix for the runtime theme-swap mechanism: the CSS custom properties (--<runtimePrefix>-surface-*) and selectors (.<runtimePrefix>-surface--<name>) that ProvideSurfaceDirective (etProvideSurface) 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."
32
+ },
33
+ "defaultLightTheme": {
34
+ "type": "string",
35
+ "description": "Name of the 'light' surface theme to use as the default, overriding any isDefault flags on light themes in the definitions. Use this when multiple apps share one surface theme definition set (e.g. in a monorepo) but each app needs a different default."
36
+ },
37
+ "defaultDarkTheme": {
38
+ "type": "string",
39
+ "description": "Name of the 'dark' surface theme to use as the default, overriding any isDefault flags on dark themes in the definitions. Use this when multiple apps share one surface theme definition set (e.g. in a monorepo) but each app needs a different default."
40
+ },
41
+ "skipFormat": {
42
+ "type": "boolean",
43
+ "description": "Skip formatting files after generation",
44
+ "default": false
45
+ }
46
+ },
47
+ "required": []
48
+ }
@@ -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>.
@@ -0,0 +1,81 @@
1
+ # Replace `SeoDirective` with the `apply*Binding` functions
2
+
3
+ `SeoDirective` and its `SeoConfig` types were removed in `@ethlete/core` v5. The directive wrote to
4
+ `document` directly, so it never worked under server rendering, and it held one config object per
5
+ component, so a child route could only override a parent by re-declaring the whole config.
6
+
7
+ No codemod can do this rewrite: an Observable value becomes a signal, and one config key becomes one
8
+ function call. Both are decisions about the call site.
9
+
10
+ ## What to change
11
+
12
+ 1. Find every component that injects `SeoDirective` or `SEO_DIRECTIVE_TOKEN`, or lists the directive in
13
+ `hostDirectives` or `imports`.
14
+ 2. Remove the directive from the component.
15
+ 3. Replace the `updateConfig({ … })` call with one `apply*Binding` call per key it set.
16
+ 4. Turn an Observable value into a signal: `toSignal()` at the edge of the component, or a
17
+ `computed()` over a signal that is already there.
18
+ 5. Import each `apply*Binding` function from `@ethlete/core`.
19
+
20
+ ## The replacement for each key
21
+
22
+ | `SeoConfig` key | Replacement |
23
+ | ---------------------------- | ------------------------------------------------------------- |
24
+ | `title` | `applyHeadTitleBinding` |
25
+ | `description` | `applyDescriptionBinding` |
26
+ | `keywords` | `applyKeywordsBinding` |
27
+ | `robots` | `applyRobotsBinding` (takes a directive object, not a string) |
28
+ | `icon` | `applyLinkBinding({ rel: 'icon', href })` |
29
+ | `themeColor` / `colorScheme` | `applyMetaBinding({ name: 'theme-color' \| 'color-scheme' })` |
30
+ | `canonical` | `applyCanonicalBinding` |
31
+ | `alternate` | `applyAlternateBinding` / `applyAlternateLanguagesBindings` |
32
+ | `og` | `applyOpenGraphBindings` |
33
+ | `twitter` | `applyTwitterCardBindings` |
34
+ | `facebook` | `applyOpenGraphBindings` (Facebook reads Open Graph) |
35
+ | any other key | `applyMetaBinding({ name, content })` |
36
+
37
+ ## Example
38
+
39
+ ```ts
40
+ // before
41
+ @Component({ hostDirectives: [SeoDirective] })
42
+ export class ArticleComponent {
43
+ private seo = inject(SeoDirective);
44
+
45
+ constructor() {
46
+ this.seo.updateConfig({
47
+ title: this.article$.pipe(map((a) => a.title)),
48
+ description: 'A brief description',
49
+ canonical: 'https://example.com/article',
50
+ og: { image: 'https://example.com/image.jpg' },
51
+ });
52
+ }
53
+ }
54
+
55
+ // after
56
+ @Component({})
57
+ export class ArticleComponent {
58
+ private article = toSignal(this.article$);
59
+
60
+ constructor() {
61
+ applyHeadTitleBinding(computed(() => this.article()?.title ?? ''));
62
+ applyDescriptionBinding('A brief description');
63
+ applyCanonicalBinding('https://example.com/article');
64
+ applyOpenGraphBindings({ images: ['https://example.com/image.jpg'] });
65
+ }
66
+ }
67
+ ```
68
+
69
+ ## Two things to watch
70
+
71
+ - A child component no longer re-declares its parent's config to override one key. Title parts
72
+ compose through the title store, and duplicate meta and link tags are deduplicated by selector.
73
+ Delete a re-declaration instead of porting it.
74
+ - `robots` took a string and now takes a directive object, for example `{ index: true, follow: true }`.
75
+
76
+ ## When you are done
77
+
78
+ Nothing may import `SeoDirective` or `SeoConfig` any more. Run the type check and the lint task of
79
+ every project you changed.
80
+
81
+ The full guide, with every binding and its options, is at <https://ethlete-sdk-docs.web.app/core/seo>.
@@ -0,0 +1,20 @@
1
+ # Regenerate the surface theme after the swatch change
2
+
3
+ `SurfaceTheme['interactionColor']` is a swatch now: `{ color, onColor, inkColor }` instead of a single
4
+ colour. `nx g @ethlete/core:migrate-surface-interaction-swatch` rewrites the theme definitions, but the
5
+ generated stylesheet still holds the CSS variables of the old shape.
6
+
7
+ Regenerate it with the same command that created it, in each app that has a surface theme:
8
+
9
+ ```bash
10
+ nx g @ethlete/core:tailwind-4-surface-theme
11
+ ```
12
+
13
+ The generator asks for the same answers as the first time. It overwrites the generated stylesheet, so
14
+ check the diff: a hand-written override inside the generated file is lost.
15
+
16
+ `onColor` and `inkColor` carry the two foreground colours an interaction surface needs. The codemod
17
+ fills them from the old single colour, which keeps the rendering identical but is rarely the design
18
+ intent. Read the swatch through with the designer once, then correct the values.
19
+
20
+ The theming guide is at <https://ethlete-sdk-docs.web.app/core/theming>.
@@ -0,0 +1,59 @@
1
+ {
2
+ "migrations": [
3
+ {
4
+ "name": "to-v5",
5
+ "version": "5.0.0-beta.11",
6
+ "kind": "auto",
7
+ "level": "required",
8
+ "description": "Rewrite the v4 core API into its v5 shape.",
9
+ "generator": "@ethlete/core:migrate-to-v5"
10
+ },
11
+ {
12
+ "name": "provider-shape",
13
+ "version": "5.0.0-next.37",
14
+ "kind": "auto",
15
+ "level": "required",
16
+ "description": "Rewrite destructured provider tuples into the tree-shakeable definition shape.",
17
+ "generator": "@ethlete/core:migrate-provider-shape",
18
+ "docs": "/core/providers"
19
+ },
20
+ {
21
+ "name": "seo-directive-removed",
22
+ "version": "5.0.0-next.41",
23
+ "kind": "assisted",
24
+ "level": "required",
25
+ "description": "SeoDirective and SeoConfig are gone. Every call site moves to the apply*Binding function for its key.",
26
+ "instructions": "./migrations/seo-directive-removed.md",
27
+ "docs": "/core/seo"
28
+ },
29
+ {
30
+ "name": "surface-interaction-swatch",
31
+ "version": "5.0.0-next.46",
32
+ "kind": "auto",
33
+ "level": "required",
34
+ "description": "Rewrite surface theme interactionColor maps into the { color, onColor, inkColor } swatch shape.",
35
+ "generator": "@ethlete/core:migrate-surface-interaction-swatch",
36
+ "requires": ["ts-morph"],
37
+ "docs": "/core/theming"
38
+ },
39
+ {
40
+ "name": "surface-theme-regenerate",
41
+ "version": "5.0.0-next.46",
42
+ "kind": "manual",
43
+ "level": "required",
44
+ "description": "Regenerate the surface theme stylesheet, so the new swatch reaches the CSS variables.",
45
+ "instructions": "./migrations/surface-theme-regenerate.md",
46
+ "docs": "/core/theming"
47
+ },
48
+ {
49
+ "name": "legacy-color-themes",
50
+ "version": "5.0.0-next.62",
51
+ "kind": "assisted",
52
+ "level": "recommended",
53
+ "description": "Move the runtime color theming to Tailwind 4: provideColorThemes becomes provideColorThemesWithTailwind4, and the generated CSS and surface themes replace the runtime styles.",
54
+ "scan": "@ethlete/core:migrate-legacy-color-themes",
55
+ "instructions": "./migrations/legacy-color-themes.md",
56
+ "docs": "/core/theming#migrating-from-runtime-theming"
57
+ }
58
+ ]
59
+ }