@ethlete/core 5.0.0-next.6 ā 5.0.0-next.60
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.
- package/CHANGELOG.md +686 -0
- package/README.md +28 -4
- package/fesm2022/ethlete-core.mjs +8020 -3527
- package/fesm2022/ethlete-core.mjs.map +1 -1
- package/generators/devtools-about/generator.js +122 -0
- package/generators/devtools-about/schema.json +25 -0
- package/generators/generators.json +26 -5
- package/generators/migrate-provider-shape/migration-scope.js +35 -0
- package/generators/migrate-provider-shape/migration.js +50 -0
- package/generators/migrate-provider-shape/provider-shape.js +284 -0
- package/generators/migrate-provider-shape/schema.json +26 -0
- package/generators/migrate-surface-interaction-swatch/interaction-swatch.js +63 -0
- package/generators/migrate-surface-interaction-swatch/migration.js +36 -0
- package/generators/migrate-surface-interaction-swatch/schema.json +26 -0
- package/generators/migrate-to-v5/color-naming.js +170 -0
- package/generators/migrate-to-v5/create-provider.js +14 -14
- package/generators/migrate-to-v5/migration.js +11 -1
- package/generators/migrate-to-v5/removed-exports.js +93 -0
- package/generators/migrate-to-v5/router-state-service.js +33 -8
- package/generators/migrate-to-v5/schema.json +10 -0
- package/generators/migrate-to-v5/viewport-service.js +41 -60
- package/generators/tailwind-4-color-theme/generator.js +608 -0
- package/generators/tailwind-4-color-theme/schema.json +44 -0
- package/generators/tailwind-4-surface-theme/generator.js +599 -0
- package/generators/tailwind-4-surface-theme/schema.json +48 -0
- package/migrations/seo-directive-removed.md +80 -0
- package/migrations/surface-theme-regenerate.md +20 -0
- package/migrations.json +43 -0
- package/package.json +25 -14
- package/types/ethlete-core.d.ts +3329 -1774
- package/generators/tailwind-4-theme/generator.js +0 -490
- package/generators/tailwind-4-theme/schema.json +0 -32
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { formatFiles, logger, visitNotIgnoredFiles } from '@nx/devkit';
|
|
2
|
+
import { Project, SyntaxKind } from 'ts-morph';
|
|
3
|
+
export default async function generate(tree, schema) {
|
|
4
|
+
logger.log('\nš Starting Tailwind 4 surface theme generator...\n');
|
|
5
|
+
const themesPath = schema.themesPath || 'src/surface-themes.ts';
|
|
6
|
+
const outputPath = schema.outputPath || 'src/styles/generated-tailwind-surface-themes.css';
|
|
7
|
+
const prefix = schema.prefix || 'et';
|
|
8
|
+
// The runtime prefix drives the theme-swap CSS variables (--<runtimePrefix>-surface-*)
|
|
9
|
+
// and selectors (.<runtimePrefix>-surface--<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;
|
|
12
|
+
if (!tree.exists(themesPath)) {
|
|
13
|
+
logger.error(`ā Surface themes file not found at: ${themesPath}`);
|
|
14
|
+
logger.log(`\nPlease specify the correct path using --themesPath option.`);
|
|
15
|
+
logger.log(`Example: nx g @ethlete/core:tailwind-4-surface-theme --themesPath=src/app/surface-themes.ts\n`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
logger.log(`š Reading surface themes from: ${themesPath}`);
|
|
19
|
+
const themesContent = tree.read(themesPath, 'utf-8');
|
|
20
|
+
if (!themesContent) {
|
|
21
|
+
logger.error('ā Failed to read surface themes file');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let themes;
|
|
25
|
+
try {
|
|
26
|
+
themes = extractSurfaceThemesFromContent(themesContent, themesPath);
|
|
27
|
+
logger.log(`ā
Found ${themes.length} surface theme(s)`);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
logger.error('ā Failed to parse surface themes file');
|
|
31
|
+
logger.error(` ${error instanceof Error ? error.message : String(error)}`);
|
|
32
|
+
logger.log('\nThe surface themes file must export themes as:');
|
|
33
|
+
logger.log(' export const SURFACE_THEMES = [...] satisfies SurfaceTheme[];\n');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Apply the generation-time default overrides, if any. The shared theme definitions may
|
|
37
|
+
// mark defaults via isDefault, but in a monorepo each app picks its own defaults at its
|
|
38
|
+
// generation invocation - the options win over the definitions (per surface type).
|
|
39
|
+
try {
|
|
40
|
+
if (schema.defaultLightTheme) {
|
|
41
|
+
const name = applyDefaultSurfaceThemeOverride(themes, schema.defaultLightTheme, 'light');
|
|
42
|
+
logger.log(`šÆ Default light surface theme set at generation time: ${name}`);
|
|
43
|
+
}
|
|
44
|
+
if (schema.defaultDarkTheme) {
|
|
45
|
+
const name = applyDefaultSurfaceThemeOverride(themes, schema.defaultDarkTheme, 'dark');
|
|
46
|
+
logger.log(`šÆ Default dark surface theme set at generation time: ${name}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
logger.error('ā Invalid default theme option');
|
|
51
|
+
logger.error(` ${error instanceof Error ? error.message : String(error)}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
validateSurfaceThemeConfiguration(themes);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
logger.error('ā Surface theme configuration error');
|
|
59
|
+
logger.error(` ${error instanceof Error ? error.message : String(error)}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
logger.log('\nšØ Generating Tailwind surface theme CSS...');
|
|
63
|
+
const css = generateSurfaceThemeCss(themes, prefix, runtimePrefix, schema);
|
|
64
|
+
const typesOutputPath = schema.typesOutputPath ?? outputPath.replace(/\.css$/, '.d.ts');
|
|
65
|
+
if (typesOutputPath === outputPath) {
|
|
66
|
+
throw new Error('A custom outputPath must end in .css or be paired with a distinct typesOutputPath.');
|
|
67
|
+
}
|
|
68
|
+
tree.write(outputPath, css);
|
|
69
|
+
logger.log(`ā
Generated Tailwind surface themes at: ${outputPath}`);
|
|
70
|
+
// Generate the `EthleteSurfaceThemeNameRegistry` augmentation, so `etProvideSurface` (and
|
|
71
|
+
// anything else that accepts a `RegisteredSurfaceThemeName`) is checked/autocompleted
|
|
72
|
+
// against this app's actual surface theme names, instead of a plain `string`.
|
|
73
|
+
const typesDts = generateSurfaceThemeNameTypes(themes, schema);
|
|
74
|
+
tree.write(typesOutputPath, typesDts);
|
|
75
|
+
logger.log(`ā
Generated surface theme name types at: ${typesOutputPath}`);
|
|
76
|
+
const mainStylesFiles = findMainStylesFile(tree);
|
|
77
|
+
if (mainStylesFiles.length > 0) {
|
|
78
|
+
logger.log('\nš Found potential main styles files:');
|
|
79
|
+
mainStylesFiles.forEach((file) => logger.log(` - ${file}`));
|
|
80
|
+
logger.log('\nā ļø Please manually import the generated themes:');
|
|
81
|
+
logger.log(` @import './${outputPath.replace('src/styles/', '')}';`);
|
|
82
|
+
}
|
|
83
|
+
if (!schema.skipFormat) {
|
|
84
|
+
await formatFiles(tree);
|
|
85
|
+
}
|
|
86
|
+
logger.log('\nā
Generation completed successfully!\n');
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region Parsing
|
|
90
|
+
function extractSurfaceThemesFromContent(content, filePath) {
|
|
91
|
+
const project = new Project({
|
|
92
|
+
useInMemoryFileSystem: true,
|
|
93
|
+
compilerOptions: {
|
|
94
|
+
target: 99,
|
|
95
|
+
module: 99,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const sourceFile = project.createSourceFile(filePath, content);
|
|
99
|
+
const themes = [];
|
|
100
|
+
const exportedDeclarations = sourceFile.getVariableDeclarations().filter((decl) => {
|
|
101
|
+
const statement = decl.getVariableStatement();
|
|
102
|
+
return statement?.isExported();
|
|
103
|
+
});
|
|
104
|
+
const themesArray = exportedDeclarations.find((decl) => {
|
|
105
|
+
const name = decl.getName();
|
|
106
|
+
return name === 'SURFACE_THEMES' || name === 'surfaceThemes';
|
|
107
|
+
});
|
|
108
|
+
if (!themesArray) {
|
|
109
|
+
throw new Error('Could not find SURFACE_THEMES or surfaceThemes export');
|
|
110
|
+
}
|
|
111
|
+
let initializer = themesArray.getInitializer();
|
|
112
|
+
if (!initializer) {
|
|
113
|
+
throw new Error('SURFACE_THEMES export has no initializer');
|
|
114
|
+
}
|
|
115
|
+
// Handle 'satisfies X'/'as const' wrappers, in either order: [array] satisfies Type[],
|
|
116
|
+
// [array] as const, or both combined.
|
|
117
|
+
while (initializer.isKind(SyntaxKind.SatisfiesExpression) || initializer.isKind(SyntaxKind.AsExpression)) {
|
|
118
|
+
initializer = initializer.getExpression();
|
|
119
|
+
}
|
|
120
|
+
if (!initializer.isKind(SyntaxKind.ArrayLiteralExpression)) {
|
|
121
|
+
throw new Error('SURFACE_THEMES export must be an array literal');
|
|
122
|
+
}
|
|
123
|
+
const elements = initializer.getElements();
|
|
124
|
+
for (const element of elements) {
|
|
125
|
+
let themeObj;
|
|
126
|
+
let name = '<inline>';
|
|
127
|
+
if (element.isKind(SyntaxKind.Identifier)) {
|
|
128
|
+
name = element.getText();
|
|
129
|
+
const themeDecl = exportedDeclarations.find((decl) => decl.getName() === name);
|
|
130
|
+
if (!themeDecl) {
|
|
131
|
+
logger.warn(`ā ļø Could not find declaration for surface theme: ${name}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
themeObj = themeDecl.getInitializer();
|
|
135
|
+
if (!themeObj) {
|
|
136
|
+
logger.warn(`ā ļø Surface theme ${name} has no initializer`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
themeObj = element;
|
|
142
|
+
}
|
|
143
|
+
while (themeObj.isKind(SyntaxKind.AsExpression) || themeObj.isKind(SyntaxKind.SatisfiesExpression)) {
|
|
144
|
+
themeObj = themeObj.getExpression();
|
|
145
|
+
}
|
|
146
|
+
if (!themeObj.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
147
|
+
logger.warn(`ā ļø Surface theme ${name} is not an object literal`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const theme = parseSurfaceThemeObject(themeObj, sourceFile);
|
|
152
|
+
themes.push(theme);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
logger.warn(`ā ļø Failed to parse surface theme ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (themes.length === 0) {
|
|
159
|
+
throw new Error('No valid surface themes found in SURFACE_THEMES array');
|
|
160
|
+
}
|
|
161
|
+
return themes;
|
|
162
|
+
}
|
|
163
|
+
const COLOR_PROPS = ['background', 'color', 'colorMuted', 'colorSubtle', 'border'];
|
|
164
|
+
const isColorProp = (name) => COLOR_PROPS.some((p) => p === name);
|
|
165
|
+
const INTERACTION_KEYS = ['default', 'hover', 'focus', 'active', 'disabled'];
|
|
166
|
+
const isInteractionKey = (name) => INTERACTION_KEYS.some((key) => key === name);
|
|
167
|
+
const SWATCH_KEYS = ['color', 'onColor', 'inkColor'];
|
|
168
|
+
const isSwatchKey = (name) => SWATCH_KEYS.some((key) => key === name);
|
|
169
|
+
const resolveObjectLiteral = (expression, sourceFile) => {
|
|
170
|
+
let resolved = expression;
|
|
171
|
+
while (resolved.isKind(SyntaxKind.AsExpression) || resolved.isKind(SyntaxKind.SatisfiesExpression)) {
|
|
172
|
+
resolved = resolved.getExpression();
|
|
173
|
+
}
|
|
174
|
+
if (resolved.isKind(SyntaxKind.Identifier)) {
|
|
175
|
+
const declaration = sourceFile
|
|
176
|
+
.getVariableDeclarations()
|
|
177
|
+
.find((candidate) => candidate.getName() === resolved.getText());
|
|
178
|
+
const initializer = declaration?.getInitializer();
|
|
179
|
+
return initializer ? resolveObjectLiteral(initializer, sourceFile) : null;
|
|
180
|
+
}
|
|
181
|
+
return resolved.isKind(SyntaxKind.ObjectLiteralExpression) ? resolved : null;
|
|
182
|
+
};
|
|
183
|
+
function parseInteractionColorMap(obj) {
|
|
184
|
+
const map = {};
|
|
185
|
+
for (const prop of obj.getProperties()) {
|
|
186
|
+
if (!prop.isKind(SyntaxKind.PropertyAssignment))
|
|
187
|
+
continue;
|
|
188
|
+
const name = prop.getName();
|
|
189
|
+
const initializer = prop.getInitializer();
|
|
190
|
+
if (initializer?.isKind(SyntaxKind.StringLiteral) && isInteractionKey(name)) {
|
|
191
|
+
map[name] = initializer.getLiteralValue();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return map;
|
|
195
|
+
}
|
|
196
|
+
function parseInteractionColor(obj, themeName, sourceFile) {
|
|
197
|
+
const swatch = {};
|
|
198
|
+
for (const prop of obj.getProperties()) {
|
|
199
|
+
if (!prop.isKind(SyntaxKind.PropertyAssignment))
|
|
200
|
+
continue;
|
|
201
|
+
const name = prop.getName();
|
|
202
|
+
const initializer = prop.getInitializer();
|
|
203
|
+
if (isInteractionKey(name)) {
|
|
204
|
+
throw new Error(`Surface theme "${themeName}" uses the old flat \`interactionColor\` shape. It now takes a swatch: ` +
|
|
205
|
+
`{ color: { default, hover, focus, active, disabled }, onColor?, inkColor? }. ` +
|
|
206
|
+
`Run \`nx g @ethlete/core:migrate-surface-interaction-swatch\` to convert your themes.`);
|
|
207
|
+
}
|
|
208
|
+
if (!initializer || !isSwatchKey(name))
|
|
209
|
+
continue;
|
|
210
|
+
const mapObject = resolveObjectLiteral(initializer, sourceFile);
|
|
211
|
+
if (!mapObject) {
|
|
212
|
+
throw new Error(`Surface theme "${themeName}" has an interactionColor.${name} value that cannot be resolved.`);
|
|
213
|
+
}
|
|
214
|
+
const map = parseInteractionColorMap(mapObject);
|
|
215
|
+
if (name === 'color') {
|
|
216
|
+
if (!map.default || !map.hover || !map.focus || !map.active || !map.disabled) {
|
|
217
|
+
throw new Error(`Surface theme "${themeName}" must define default, hover, focus, active and disabled interaction colors.`);
|
|
218
|
+
}
|
|
219
|
+
swatch.color = map;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (map.default) {
|
|
223
|
+
swatch[name] = map;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!swatch.color) {
|
|
227
|
+
throw new Error(`Surface theme "${themeName}" interactionColor must define a color swatch.`);
|
|
228
|
+
}
|
|
229
|
+
return swatch;
|
|
230
|
+
}
|
|
231
|
+
function parseSurfaceThemeObject(obj, sourceFile) {
|
|
232
|
+
const properties = obj.getProperties();
|
|
233
|
+
const theme = {};
|
|
234
|
+
let interactionColorObj = null;
|
|
235
|
+
for (const prop of properties) {
|
|
236
|
+
if (!prop.isKind(SyntaxKind.PropertyAssignment)) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const propName = prop.getName();
|
|
240
|
+
const initializer = prop.getInitializer();
|
|
241
|
+
if (!initializer) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (propName === 'isDefault') {
|
|
245
|
+
if (initializer.isKind(SyntaxKind.TrueKeyword)) {
|
|
246
|
+
theme.isDefault = true;
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (propName === 'interactionColor') {
|
|
251
|
+
interactionColorObj = resolveObjectLiteral(initializer, sourceFile);
|
|
252
|
+
if (!interactionColorObj) {
|
|
253
|
+
throw new Error('Surface theme interactionColor must be an inline or same-file object literal.');
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (propName === 'elevation') {
|
|
258
|
+
if (initializer.isKind(SyntaxKind.NumericLiteral)) {
|
|
259
|
+
theme.elevation = Number(initializer.getLiteralValue());
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (propName === 'type' && initializer.isKind(SyntaxKind.StringLiteral)) {
|
|
264
|
+
theme.type = initializer.getLiteralValue();
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (!initializer.isKind(SyntaxKind.StringLiteral)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (propName === 'name') {
|
|
271
|
+
theme.name = initializer.getLiteralValue();
|
|
272
|
+
}
|
|
273
|
+
else if (isColorProp(propName)) {
|
|
274
|
+
theme[propName] = initializer.getLiteralValue();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!theme.name ||
|
|
278
|
+
!theme.type ||
|
|
279
|
+
theme.elevation === undefined ||
|
|
280
|
+
!theme.background ||
|
|
281
|
+
!theme.color ||
|
|
282
|
+
!theme.colorMuted ||
|
|
283
|
+
!theme.colorSubtle ||
|
|
284
|
+
!theme.border) {
|
|
285
|
+
throw new Error('SurfaceTheme must have name, type, elevation, background, color, colorMuted, colorSubtle, and border properties');
|
|
286
|
+
}
|
|
287
|
+
if (interactionColorObj) {
|
|
288
|
+
theme.interactionColor = parseInteractionColor(interactionColorObj, theme.name, sourceFile);
|
|
289
|
+
}
|
|
290
|
+
return theme;
|
|
291
|
+
}
|
|
292
|
+
// Makes the named theme the sole default of its surface type, overriding any isDefault
|
|
293
|
+
// flags on themes of that type. Matches the theme's name or its CSS-safe form. Themes of
|
|
294
|
+
// the other type keep their flags. Returns the resolved name.
|
|
295
|
+
function applyDefaultSurfaceThemeOverride(themes, defaultTheme, type) {
|
|
296
|
+
const wanted = createCssSurfaceName(defaultTheme);
|
|
297
|
+
const matches = themes.filter((t) => t.name === defaultTheme || createCssSurfaceName(t.name) === wanted);
|
|
298
|
+
const target = matches.find((t) => t.type === type);
|
|
299
|
+
if (!target) {
|
|
300
|
+
const [firstMatch] = matches;
|
|
301
|
+
if (firstMatch) {
|
|
302
|
+
throw new Error(`Theme "${defaultTheme}" has type '${firstMatch.type}', but was passed as the default '${type}' theme`);
|
|
303
|
+
}
|
|
304
|
+
const available = themes.filter((t) => t.type === type).map((t) => t.name);
|
|
305
|
+
throw new Error(`No surface theme named "${defaultTheme}" found. Available '${type}' themes: ${available.join(', ')}`);
|
|
306
|
+
}
|
|
307
|
+
for (const theme of themes) {
|
|
308
|
+
if (theme.type === type) {
|
|
309
|
+
theme.isDefault = theme === target;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return target.name;
|
|
313
|
+
}
|
|
314
|
+
function validateSurfaceThemeConfiguration(themes) {
|
|
315
|
+
const defaultThemes = themes.filter((t) => t.isDefault);
|
|
316
|
+
if (defaultThemes.length === 0) {
|
|
317
|
+
throw new Error('No default surface theme found. Each type (light/dark) must have exactly one theme with isDefault: true');
|
|
318
|
+
}
|
|
319
|
+
const types = new Set(themes.map((t) => t.type));
|
|
320
|
+
for (const type of types) {
|
|
321
|
+
const defaultsForType = defaultThemes.filter((t) => t.type === type);
|
|
322
|
+
if (defaultsForType.length === 0) {
|
|
323
|
+
throw new Error(`No default surface theme found for type '${type}'. Add isDefault: true to one '${type}' theme`);
|
|
324
|
+
}
|
|
325
|
+
if (defaultsForType.length > 1) {
|
|
326
|
+
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`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
//#endregion
|
|
331
|
+
//#region CSS Generation
|
|
332
|
+
function createCssSurfaceName(name) {
|
|
333
|
+
return name
|
|
334
|
+
.toLowerCase()
|
|
335
|
+
.replace(/\s+/g, '-')
|
|
336
|
+
.replace(/[^a-z0-9-]/g, '');
|
|
337
|
+
}
|
|
338
|
+
function generateSurfaceThemeNameTypes(themes, schema) {
|
|
339
|
+
const themesPath = schema.themesPath || 'src/surface-themes.ts';
|
|
340
|
+
const outputPath = schema.outputPath || 'src/styles/generated-tailwind-surface-themes.css';
|
|
341
|
+
const typesOutputPath = schema.typesOutputPath || outputPath.replace(/\.css$/, '.d.ts');
|
|
342
|
+
const names = themes.map((theme) => `'${theme.name}'`).join(' | ');
|
|
343
|
+
return `/*
|
|
344
|
+
* Auto-generated by @ethlete/core:tailwind-4-surface-theme
|
|
345
|
+
* DO NOT EDIT THIS FILE MANUALLY
|
|
346
|
+
*
|
|
347
|
+
* Regenerate by running:
|
|
348
|
+
* 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}` : ''}
|
|
349
|
+
*/
|
|
350
|
+
|
|
351
|
+
declare module '@ethlete/core' {
|
|
352
|
+
interface EthleteSurfaceThemeNameRegistry {
|
|
353
|
+
name: ${names};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export {};
|
|
358
|
+
`;
|
|
359
|
+
}
|
|
360
|
+
function generateSurfaceThemeCss(themes, utilityPrefix, runtimePrefix, schema) {
|
|
361
|
+
const tailwindVars = [];
|
|
362
|
+
const themeVars = [];
|
|
363
|
+
const themesPath = schema.themesPath || 'src/surface-themes.ts';
|
|
364
|
+
const outputPath = schema.outputPath || 'src/styles/generated-tailwind-surface-themes.css';
|
|
365
|
+
const header = `/*
|
|
366
|
+
* Auto-generated Tailwind 4 surface theme colors from @ethlete/core
|
|
367
|
+
* DO NOT EDIT THIS FILE MANUALLY
|
|
368
|
+
*
|
|
369
|
+
* Generated from your surface theme definitions
|
|
370
|
+
* This file can be regenerated by running:
|
|
371
|
+
* 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}` : ''}
|
|
372
|
+
*/
|
|
373
|
+
|
|
374
|
+
`;
|
|
375
|
+
// Static Tailwind @theme block - one color set per surface theme
|
|
376
|
+
for (const theme of themes) {
|
|
377
|
+
const name = createCssSurfaceName(theme.name);
|
|
378
|
+
tailwindVars.push(` /* ${theme.name} surface */`);
|
|
379
|
+
tailwindVars.push(` --color-${utilityPrefix}-surface-${name}-bg: rgb(${theme.background});`);
|
|
380
|
+
tailwindVars.push(` --color-${utilityPrefix}-surface-${name}: rgb(${theme.color});`);
|
|
381
|
+
tailwindVars.push(` --color-${utilityPrefix}-surface-${name}-muted: rgb(${theme.colorMuted});`);
|
|
382
|
+
tailwindVars.push(` --color-${utilityPrefix}-surface-${name}-subtle: rgb(${theme.colorSubtle});`);
|
|
383
|
+
tailwindVars.push(` --color-${utilityPrefix}-surface-${name}-border: rgb(${theme.border});`);
|
|
384
|
+
if (theme.interactionColor) {
|
|
385
|
+
const { color, onColor, inkColor } = theme.interactionColor;
|
|
386
|
+
pushStaticInteractionColors(tailwindVars, `--color-${utilityPrefix}-surface-${name}-interaction`, color);
|
|
387
|
+
if (onColor) {
|
|
388
|
+
pushStaticInteractionColors(tailwindVars, `--color-${utilityPrefix}-surface-${name}-on-interaction`, onColor);
|
|
389
|
+
}
|
|
390
|
+
if (inkColor) {
|
|
391
|
+
pushStaticInteractionColors(tailwindVars, `--color-${utilityPrefix}-surface-${name}-interaction-ink`, inkColor);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
tailwindVars.push('');
|
|
395
|
+
}
|
|
396
|
+
// Dynamic surface colors (reference runtime CSS variables).
|
|
397
|
+
// These are ALSO re-declared on every surface selector in the alias block below. A Tailwind
|
|
398
|
+
// `@theme` variable only lands on `:root`, so `rgb(var(--<runtime>-surface-*))` resolves once
|
|
399
|
+
// against the root surface and inherits that concrete color into descendants - which means
|
|
400
|
+
// `bg-<prefix>-surface-*` utilities would ignore nested surface scopes (e.g. an elevated
|
|
401
|
+
// surface). Re-declaring them per surface selector makes the utilities resolve against the
|
|
402
|
+
// nearest surface instead.
|
|
403
|
+
const dynamicSurfaceColors = buildDynamicSurfaceColors(utilityPrefix, runtimePrefix);
|
|
404
|
+
tailwindVars.push(' /* Dynamic surface colors (references runtime CSS variables) */');
|
|
405
|
+
tailwindVars.push(...dynamicSurfaceColors.map((decl) => ` ${decl}`));
|
|
406
|
+
// Runtime CSS - theme selector classes
|
|
407
|
+
for (const theme of themes) {
|
|
408
|
+
const name = createCssSurfaceName(theme.name);
|
|
409
|
+
const isDefault = theme.isDefault;
|
|
410
|
+
if (isDefault) {
|
|
411
|
+
const selectors = [`.${runtimePrefix}-surface--default-${theme.type}`, `.${runtimePrefix}-surface--${name}`];
|
|
412
|
+
themeVars.push(`${selectors.join(', ')} {`);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
themeVars.push(`.${runtimePrefix}-surface--${name} {`);
|
|
416
|
+
}
|
|
417
|
+
pushSurfaceVars(themeVars, runtimePrefix, theme, ' ');
|
|
418
|
+
themeVars.push('}\n');
|
|
419
|
+
}
|
|
420
|
+
// Root defaults - apply the default surface vars to :root
|
|
421
|
+
const defaultLight = themes.find((t) => t.isDefault && t.type === 'light');
|
|
422
|
+
const defaultDark = themes.find((t) => t.isDefault && t.type === 'dark');
|
|
423
|
+
if (defaultLight && defaultDark) {
|
|
424
|
+
pushSchemeSurfaceVars(themeVars, runtimePrefix, defaultLight);
|
|
425
|
+
pushSchemeSurfaceVars(themeVars, runtimePrefix, defaultDark);
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
// A single registered surface type has no other scheme to switch to, so its default must
|
|
429
|
+
// land on :root unconditionally - behind prefers-color-scheme it would leave every surface
|
|
430
|
+
// variable undefined for users whose OS asks for the scheme the app lacks.
|
|
431
|
+
pushRootSurfaceVars(themeVars, runtimePrefix, defaultLight ?? defaultDark);
|
|
432
|
+
}
|
|
433
|
+
// Convenience var aliases - available on any element with a surface class or :root (via media queries)
|
|
434
|
+
const aliasBlock = `/* Convenience aliases (rgb + solid variants) */
|
|
435
|
+
@layer base {
|
|
436
|
+
:root, :where([class*="${runtimePrefix}-surface--"]) {
|
|
437
|
+
--${runtimePrefix}-surface-background-rgb: var(--${runtimePrefix}-surface-background);
|
|
438
|
+
--${runtimePrefix}-surface-background-solid: rgb(var(--${runtimePrefix}-surface-background-rgb));
|
|
439
|
+
|
|
440
|
+
--${runtimePrefix}-surface-color-rgb: var(--${runtimePrefix}-surface-color);
|
|
441
|
+
--${runtimePrefix}-surface-color-solid: rgb(var(--${runtimePrefix}-surface-color-rgb));
|
|
442
|
+
|
|
443
|
+
--${runtimePrefix}-surface-color-muted-rgb: var(--${runtimePrefix}-surface-color-muted);
|
|
444
|
+
--${runtimePrefix}-surface-color-muted-solid: rgb(var(--${runtimePrefix}-surface-color-muted-rgb));
|
|
445
|
+
|
|
446
|
+
--${runtimePrefix}-surface-color-subtle-rgb: var(--${runtimePrefix}-surface-color-subtle);
|
|
447
|
+
--${runtimePrefix}-surface-color-subtle-solid: rgb(var(--${runtimePrefix}-surface-color-subtle-rgb));
|
|
448
|
+
|
|
449
|
+
--${runtimePrefix}-surface-border-rgb: var(--${runtimePrefix}-surface-border);
|
|
450
|
+
--${runtimePrefix}-surface-border-solid: rgb(var(--${runtimePrefix}-surface-border-rgb));
|
|
451
|
+
|
|
452
|
+
--${runtimePrefix}-surface-interaction-rgb: var(--${runtimePrefix}-surface-interaction);
|
|
453
|
+
--${runtimePrefix}-surface-interaction-solid: rgb(var(--${runtimePrefix}-surface-interaction-rgb));
|
|
454
|
+
|
|
455
|
+
--${runtimePrefix}-surface-interaction-hover-rgb: var(--${runtimePrefix}-surface-interaction-hover);
|
|
456
|
+
--${runtimePrefix}-surface-interaction-hover-solid: rgb(var(--${runtimePrefix}-surface-interaction-hover-rgb));
|
|
457
|
+
|
|
458
|
+
--${runtimePrefix}-surface-interaction-focus-rgb: var(--${runtimePrefix}-surface-interaction-focus);
|
|
459
|
+
--${runtimePrefix}-surface-interaction-focus-solid: rgb(var(--${runtimePrefix}-surface-interaction-focus-rgb));
|
|
460
|
+
|
|
461
|
+
--${runtimePrefix}-surface-interaction-active-rgb: var(--${runtimePrefix}-surface-interaction-active);
|
|
462
|
+
--${runtimePrefix}-surface-interaction-active-solid: rgb(var(--${runtimePrefix}-surface-interaction-active-rgb));
|
|
463
|
+
|
|
464
|
+
--${runtimePrefix}-surface-interaction-disabled-rgb: var(--${runtimePrefix}-surface-interaction-disabled);
|
|
465
|
+
--${runtimePrefix}-surface-interaction-disabled-solid: rgb(var(--${runtimePrefix}-surface-interaction-disabled-rgb));
|
|
466
|
+
|
|
467
|
+
/* Dynamic Tailwind surface colors - re-declared per surface scope so that
|
|
468
|
+
bg-${utilityPrefix}-surface-* utilities resolve against the nearest surface
|
|
469
|
+
(e.g. a nested elevated surface) instead of the value computed once at :root. */
|
|
470
|
+
${dynamicSurfaceColors.map((decl) => ` ${decl}`).join('\n')}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
`;
|
|
474
|
+
return `${header}@theme {
|
|
475
|
+
${tailwindVars.join('\n')}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
@layer base {
|
|
479
|
+
${themeVars.join('\n')}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
${aliasBlock}
|
|
483
|
+
${buildSurfaceColorScope(runtimePrefix)}`;
|
|
484
|
+
}
|
|
485
|
+
// The reserved `surface` color scope. It bridges the two theming systems - color variables out of
|
|
486
|
+
// surface variables - so `[etProvideColor]="'surface'"` renders a component in the neutral of
|
|
487
|
+
// whatever surface it sits on. It lives here rather than in the SDK's own CSS because a stylesheet
|
|
488
|
+
// shipped with `ProvideColorDirective` would land in every bundle that themes anything, and only
|
|
489
|
+
// apps that use the scope should pay for it.
|
|
490
|
+
function buildSurfaceColorScope(runtimePrefix) {
|
|
491
|
+
const tint = `var(--${runtimePrefix}-surface-interaction, var(--${runtimePrefix}-surface-color))`;
|
|
492
|
+
const on = `var(--${runtimePrefix}-surface-on-interaction, var(--${runtimePrefix}-surface-background))`;
|
|
493
|
+
const ink = `var(--${runtimePrefix}-surface-interaction-ink, var(--${runtimePrefix}-surface-color))`;
|
|
494
|
+
const state = (name, source, fallback) => INTERACTION_STATE_SUFFIXES.map((suffix) => ` --${runtimePrefix}-color-${name}${suffix}: ${suffix ? `var(--${runtimePrefix}-surface-${source}${suffix}, ${fallback})` : fallback};`).join('\n');
|
|
495
|
+
return `/* The reserved \`surface\` color: resolves the color tokens from the ambient surface's neutral swatch. */
|
|
496
|
+
@layer base {
|
|
497
|
+
.${runtimePrefix}-color--surface {
|
|
498
|
+
${state('primary', 'interaction', tint)}
|
|
499
|
+
|
|
500
|
+
${state('on-primary', 'on-interaction', on)}
|
|
501
|
+
|
|
502
|
+
${state('primary-ink', 'interaction-ink', ink)}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
`;
|
|
506
|
+
}
|
|
507
|
+
// Dynamic surface color declarations (unindented). Emitted in `@theme` (so Tailwind generates
|
|
508
|
+
// the utilities) and re-declared on every surface selector (so the utilities resolve per scope).
|
|
509
|
+
function buildDynamicSurfaceColors(utilityPrefix, runtimePrefix) {
|
|
510
|
+
// `on-interaction` and `interaction-ink` are optional per theme, so each falls back to the
|
|
511
|
+
// surface's own background/color - the same defaults the `.<runtimePrefix>-color--surface`
|
|
512
|
+
// bridge applies.
|
|
513
|
+
const onFallback = `var(--${runtimePrefix}-surface-background)`;
|
|
514
|
+
const inkFallback = `var(--${runtimePrefix}-surface-color)`;
|
|
515
|
+
return [
|
|
516
|
+
`--color-${utilityPrefix}-surface-bg: rgb(var(--${runtimePrefix}-surface-background));`,
|
|
517
|
+
`--color-${utilityPrefix}-surface: rgb(var(--${runtimePrefix}-surface-color));`,
|
|
518
|
+
`--color-${utilityPrefix}-surface-muted: rgb(var(--${runtimePrefix}-surface-color-muted));`,
|
|
519
|
+
`--color-${utilityPrefix}-surface-subtle: rgb(var(--${runtimePrefix}-surface-color-subtle));`,
|
|
520
|
+
`--color-${utilityPrefix}-surface-border: rgb(var(--${runtimePrefix}-surface-border));`,
|
|
521
|
+
`--color-${utilityPrefix}-surface-interaction: rgb(var(--${runtimePrefix}-surface-interaction));`,
|
|
522
|
+
`--color-${utilityPrefix}-surface-interaction-hover: rgb(var(--${runtimePrefix}-surface-interaction-hover));`,
|
|
523
|
+
`--color-${utilityPrefix}-surface-interaction-focus: rgb(var(--${runtimePrefix}-surface-interaction-focus));`,
|
|
524
|
+
`--color-${utilityPrefix}-surface-interaction-active: rgb(var(--${runtimePrefix}-surface-interaction-active));`,
|
|
525
|
+
`--color-${utilityPrefix}-surface-interaction-disabled: rgb(var(--${runtimePrefix}-surface-interaction-disabled));`,
|
|
526
|
+
...INTERACTION_STATE_SUFFIXES.map((suffix) => `--color-${utilityPrefix}-surface-on-interaction${suffix}: rgb(var(--${runtimePrefix}-surface-on-interaction${suffix}, ${onFallback}));`),
|
|
527
|
+
...INTERACTION_STATE_SUFFIXES.map((suffix) => `--color-${utilityPrefix}-surface-interaction-ink${suffix}: rgb(var(--${runtimePrefix}-surface-interaction-ink${suffix}, ${inkFallback}));`),
|
|
528
|
+
];
|
|
529
|
+
}
|
|
530
|
+
const INTERACTION_STATE_SUFFIXES = ['', '-hover', '-focus', '-active', '-disabled'];
|
|
531
|
+
// Fills the optional states from the ones that are set, matching how the color theme generator
|
|
532
|
+
// resolves a swatch: hover falls back to default, focus to hover, active and disabled to default.
|
|
533
|
+
function resolveInteractionStates(map) {
|
|
534
|
+
const hover = map.hover || map.default;
|
|
535
|
+
return {
|
|
536
|
+
'': map.default,
|
|
537
|
+
'-hover': hover,
|
|
538
|
+
'-focus': map.focus || hover,
|
|
539
|
+
'-active': map.active || map.default,
|
|
540
|
+
'-disabled': map.disabled || map.default,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
function pushStaticInteractionColors(vars, name, map) {
|
|
544
|
+
const states = resolveInteractionStates(map);
|
|
545
|
+
for (const suffix of INTERACTION_STATE_SUFFIXES) {
|
|
546
|
+
vars.push(` ${name}${suffix}: rgb(${states[suffix]});`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function pushRuntimeInteractionVars(vars, name, map, indent) {
|
|
550
|
+
const states = resolveInteractionStates(map);
|
|
551
|
+
for (const suffix of INTERACTION_STATE_SUFFIXES) {
|
|
552
|
+
vars.push(`${indent}${name}${suffix}: ${states[suffix]};`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function pushRootSurfaceVars(vars, runtimePrefix, theme) {
|
|
556
|
+
if (!theme)
|
|
557
|
+
return;
|
|
558
|
+
vars.push(`:root {`);
|
|
559
|
+
pushSurfaceVars(vars, runtimePrefix, theme, ' ');
|
|
560
|
+
vars.push(` color-scheme: ${theme.type};`);
|
|
561
|
+
vars.push(`}\n`);
|
|
562
|
+
}
|
|
563
|
+
function pushSchemeSurfaceVars(vars, runtimePrefix, theme) {
|
|
564
|
+
vars.push(`@media (prefers-color-scheme: ${theme.type}) {`);
|
|
565
|
+
vars.push(` :root {`);
|
|
566
|
+
pushSurfaceVars(vars, runtimePrefix, theme, ' ');
|
|
567
|
+
vars.push(` color-scheme: ${theme.type};`);
|
|
568
|
+
vars.push(` }`);
|
|
569
|
+
vars.push(`}\n`);
|
|
570
|
+
}
|
|
571
|
+
function pushSurfaceVars(vars, runtimePrefix, theme, indent) {
|
|
572
|
+
vars.push(`${indent}--${runtimePrefix}-surface-background: ${theme.background};`);
|
|
573
|
+
vars.push(`${indent}--${runtimePrefix}-surface-color: ${theme.color};`);
|
|
574
|
+
vars.push(`${indent}--${runtimePrefix}-surface-color-muted: ${theme.colorMuted};`);
|
|
575
|
+
vars.push(`${indent}--${runtimePrefix}-surface-color-subtle: ${theme.colorSubtle};`);
|
|
576
|
+
vars.push(`${indent}--${runtimePrefix}-surface-border: ${theme.border};`);
|
|
577
|
+
vars.push(`${indent}--${runtimePrefix}-surface-type: ${theme.type};`);
|
|
578
|
+
vars.push(`${indent}--${runtimePrefix}-surface-elevation: ${theme.elevation};`);
|
|
579
|
+
if (theme.interactionColor) {
|
|
580
|
+
const { color, onColor, inkColor } = theme.interactionColor;
|
|
581
|
+
pushRuntimeInteractionVars(vars, `--${runtimePrefix}-surface-interaction`, color, indent);
|
|
582
|
+
if (onColor) {
|
|
583
|
+
pushRuntimeInteractionVars(vars, `--${runtimePrefix}-surface-on-interaction`, onColor, indent);
|
|
584
|
+
}
|
|
585
|
+
if (inkColor) {
|
|
586
|
+
pushRuntimeInteractionVars(vars, `--${runtimePrefix}-surface-interaction-ink`, inkColor, indent);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function findMainStylesFile(tree) {
|
|
591
|
+
const potentialFiles = [];
|
|
592
|
+
visitNotIgnoredFiles(tree, '', (path) => {
|
|
593
|
+
if (path.match(/styles\.(css|scss)$/) && !path.includes('node_modules') && !path.includes('dist')) {
|
|
594
|
+
potentialFiles.push(path);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
return potentialFiles;
|
|
598
|
+
}
|
|
599
|
+
//#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
|
+
}
|