meno-core 1.1.0 → 1.1.2
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/dist/chunks/{chunk-NUP7H7D3.js → chunk-7ZLF4NE5.js} +2 -2
- package/dist/chunks/{chunk-QWTQZHG3.js → chunk-FQBIC2OB.js} +1 -1
- package/dist/chunks/chunk-FQBIC2OB.js.map +7 -0
- package/dist/chunks/{chunk-2IIQK7T3.js → chunk-J4IPTP5X.js} +393 -3
- package/dist/chunks/{chunk-2IIQK7T3.js.map → chunk-J4IPTP5X.js.map} +3 -3
- package/dist/lib/client/index.js +2 -2
- package/dist/lib/server/index.js +804 -218
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +6 -2
- package/dist/lib/shared/index.js.map +2 -2
- package/lib/server/index.ts +19 -0
- package/lib/server/services/ColorService.ts +38 -7
- package/lib/server/services/VariableService.ts +34 -5
- package/lib/server/ssr/htmlGenerator.ts +18 -2
- package/lib/server/ssr/ssrRenderer.test.ts +25 -0
- package/lib/server/ssr/ssrRenderer.ts +12 -2
- package/lib/server/themeCssCodec.test.ts +154 -0
- package/lib/server/themeCssCodec.ts +488 -0
- package/lib/server/themeCssStore.test.ts +139 -0
- package/lib/server/themeCssStore.ts +210 -0
- package/lib/shared/cssGeneration.test.ts +10 -0
- package/lib/shared/cssGeneration.ts +10 -8
- package/lib/shared/index.ts +6 -0
- package/lib/shared/interactiveStyles.test.ts +3 -1
- package/lib/shared/tailwindThemeScale.ts +256 -0
- package/lib/shared/types/colors.ts +6 -2
- package/lib/shared/types/variables.ts +14 -7
- package/lib/shared/utilityClassConfig.ts +116 -0
- package/lib/shared/utilityClassMapper.test.ts +148 -0
- package/lib/shared/utilityClassMapper.ts +33 -0
- package/lib/shared/utilityClassNames.ts +146 -0
- package/package.json +1 -1
- package/dist/chunks/chunk-QWTQZHG3.js.map +0 -7
- /package/dist/chunks/{chunk-NUP7H7D3.js.map → chunk-7ZLF4NE5.js.map} +0 -0
package/lib/server/index.ts
CHANGED
|
@@ -98,5 +98,24 @@ export { SERVE_PORT, SERVER_PORT, HMR_ROUTE, MAX_PORT_ATTEMPTS } from '../shared
|
|
|
98
98
|
// CSS generators
|
|
99
99
|
export { generateVariablesCSS } from './cssGenerator';
|
|
100
100
|
|
|
101
|
+
// theme.css codec + store (design-token source of truth: src/styles/theme.css)
|
|
102
|
+
export {
|
|
103
|
+
parseThemeCss,
|
|
104
|
+
serializeThemeCss,
|
|
105
|
+
groupForSectionLabel,
|
|
106
|
+
inferGroup,
|
|
107
|
+
isColorValue,
|
|
108
|
+
type ThemeModel,
|
|
109
|
+
type ScaleConfig,
|
|
110
|
+
} from './themeCssCodec';
|
|
111
|
+
export {
|
|
112
|
+
themeCssPath,
|
|
113
|
+
isThemeCssProject,
|
|
114
|
+
loadThemeModel,
|
|
115
|
+
saveColors,
|
|
116
|
+
saveVariables,
|
|
117
|
+
regenerateThemeCss,
|
|
118
|
+
} from './themeCssStore';
|
|
119
|
+
|
|
101
120
|
// Runtime abstraction
|
|
102
121
|
export * from './runtime';
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Manages loading and caching of color variables and themes from colors.json
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
6
7
|
import { loadJSONFile, parseJSON } from '../jsonLoader';
|
|
7
8
|
import { projectPaths } from '../projectContext';
|
|
8
9
|
import type { ColorVariables, ColorVariableEntry, ThemeConfig, ThemeEntry } from '../../shared/types';
|
|
@@ -10,6 +11,7 @@ import { resolvePaletteColor } from '../../shared/types';
|
|
|
10
11
|
import { CachedConfigLoader } from './CachedConfigLoader';
|
|
11
12
|
import { writeFile } from '../runtime';
|
|
12
13
|
import { createLogger } from '../../shared/logger';
|
|
14
|
+
import { isThemeCssProject, loadThemeModel, saveColors, themeCssPath } from '../themeCssStore';
|
|
13
15
|
|
|
14
16
|
const log = createLogger('ColorService');
|
|
15
17
|
|
|
@@ -22,9 +24,19 @@ export class ColorService extends CachedConfigLoader<ThemeConfig> {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
* Perform the actual loading from file
|
|
27
|
+
* Perform the actual loading from file.
|
|
28
|
+
* Astro projects read colors from `src/styles/theme.css` (the token source of truth);
|
|
29
|
+
* legacy JSON projects read `colors.json`.
|
|
26
30
|
*/
|
|
27
31
|
protected async performLoad(): Promise<ThemeConfig> {
|
|
32
|
+
if (isThemeCssProject()) {
|
|
33
|
+
try {
|
|
34
|
+
return (await loadThemeModel()).themes;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
log.warn('Failed to load colors from theme.css:', error);
|
|
37
|
+
return this.getDefaultThemeConfig();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
28
40
|
try {
|
|
29
41
|
const content = await loadJSONFile(projectPaths.colors());
|
|
30
42
|
if (content) {
|
|
@@ -124,6 +136,23 @@ export class ColorService extends CachedConfigLoader<ThemeConfig> {
|
|
|
124
136
|
error?: string;
|
|
125
137
|
filePath: string;
|
|
126
138
|
}> {
|
|
139
|
+
if (isThemeCssProject()) {
|
|
140
|
+
const filePath = themeCssPath();
|
|
141
|
+
if (!existsSync(filePath)) {
|
|
142
|
+
return { status: 'missing', config: this.getMinimalConfig(), filePath };
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const model = await loadThemeModel();
|
|
146
|
+
return { status: 'valid', config: model.themes, filePath };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return {
|
|
149
|
+
status: 'invalid',
|
|
150
|
+
config: this.getMinimalConfig(),
|
|
151
|
+
error: error instanceof Error ? error.message : 'Parse error',
|
|
152
|
+
filePath,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
127
156
|
const filePath = projectPaths.colors();
|
|
128
157
|
try {
|
|
129
158
|
const content = await loadJSONFile(filePath);
|
|
@@ -177,7 +206,7 @@ export class ColorService extends CachedConfigLoader<ThemeConfig> {
|
|
|
177
206
|
const config = await this.loadThemeConfig();
|
|
178
207
|
return Object.entries(config.themes).map(([name, theme]) => ({
|
|
179
208
|
name,
|
|
180
|
-
label: theme.label,
|
|
209
|
+
label: theme.label ?? name,
|
|
181
210
|
}));
|
|
182
211
|
}
|
|
183
212
|
|
|
@@ -216,13 +245,15 @@ export class ColorService extends CachedConfigLoader<ThemeConfig> {
|
|
|
216
245
|
}
|
|
217
246
|
|
|
218
247
|
/**
|
|
219
|
-
* Save theme configuration
|
|
248
|
+
* Save theme configuration. Astro projects write the colors half of
|
|
249
|
+
* `src/styles/theme.css` (preserving variables); legacy JSON projects write `colors.json`.
|
|
220
250
|
*/
|
|
221
251
|
async saveThemeConfig(config: ThemeConfig): Promise<void> {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
252
|
+
if (isThemeCssProject()) {
|
|
253
|
+
await saveColors(config);
|
|
254
|
+
} else {
|
|
255
|
+
await writeFile(projectPaths.colors(), JSON.stringify(config, null, 2));
|
|
256
|
+
}
|
|
226
257
|
// Update cache with new config
|
|
227
258
|
this.setCache(config);
|
|
228
259
|
}
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
* Manages loading and caching of CSS variables from variables.json
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
6
7
|
import { loadJSONFile, parseJSON } from '../jsonLoader';
|
|
7
8
|
import { projectPaths } from '../projectContext';
|
|
8
9
|
import type { VariablesConfig } from '../../shared/types';
|
|
9
10
|
import { CachedConfigLoader } from './CachedConfigLoader';
|
|
10
11
|
import { writeFile } from '../runtime';
|
|
11
12
|
import { createLogger } from '../../shared/logger';
|
|
13
|
+
import { isThemeCssProject, loadThemeModel, saveVariables, themeCssPath } from '../themeCssStore';
|
|
12
14
|
|
|
13
15
|
const log = createLogger('VariableService');
|
|
14
16
|
|
|
@@ -21,6 +23,14 @@ export class VariableService extends CachedConfigLoader<VariablesConfig> {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
protected async performLoad(): Promise<VariablesConfig> {
|
|
26
|
+
if (isThemeCssProject()) {
|
|
27
|
+
try {
|
|
28
|
+
return { variables: (await loadThemeModel()).variables };
|
|
29
|
+
} catch (error) {
|
|
30
|
+
log.warn('Failed to load variables from theme.css:', error);
|
|
31
|
+
return this.getDefaultConfig();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
24
34
|
try {
|
|
25
35
|
const content = await loadJSONFile(projectPaths.variables());
|
|
26
36
|
if (content) {
|
|
@@ -46,7 +56,7 @@ export class VariableService extends CachedConfigLoader<VariablesConfig> {
|
|
|
46
56
|
private migrateConfig(config: VariablesConfig): VariablesConfig {
|
|
47
57
|
for (const variable of config.variables) {
|
|
48
58
|
if ((variable.group as string) === 'spacing') {
|
|
49
|
-
const lower = `${variable.cssVar} ${variable.name}`.toLowerCase();
|
|
59
|
+
const lower = `${variable.cssVar} ${variable.name ?? ''}`.toLowerCase();
|
|
50
60
|
if (lower.includes('margin')) {
|
|
51
61
|
variable.group = 'margin';
|
|
52
62
|
} else if (lower.includes('gap')) {
|
|
@@ -68,6 +78,22 @@ export class VariableService extends CachedConfigLoader<VariablesConfig> {
|
|
|
68
78
|
error?: string;
|
|
69
79
|
filePath: string;
|
|
70
80
|
}> {
|
|
81
|
+
if (isThemeCssProject()) {
|
|
82
|
+
const filePath = themeCssPath();
|
|
83
|
+
if (!existsSync(filePath)) {
|
|
84
|
+
return { status: 'missing', config: this.getDefaultConfig(), filePath };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return { status: 'valid', config: { variables: (await loadThemeModel()).variables }, filePath };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return {
|
|
90
|
+
status: 'invalid',
|
|
91
|
+
config: this.getDefaultConfig(),
|
|
92
|
+
error: error instanceof Error ? error.message : 'Parse error',
|
|
93
|
+
filePath,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
71
97
|
const filePath = projectPaths.variables();
|
|
72
98
|
try {
|
|
73
99
|
const content = await loadJSONFile(filePath);
|
|
@@ -95,12 +121,15 @@ export class VariableService extends CachedConfigLoader<VariablesConfig> {
|
|
|
95
121
|
}
|
|
96
122
|
|
|
97
123
|
/**
|
|
98
|
-
* Save variables configuration
|
|
124
|
+
* Save variables configuration. Astro projects write the variables half of
|
|
125
|
+
* `src/styles/theme.css` (preserving colors); legacy JSON projects write `variables.json`.
|
|
99
126
|
*/
|
|
100
127
|
async saveConfig(config: VariablesConfig): Promise<void> {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
128
|
+
if (isThemeCssProject()) {
|
|
129
|
+
await saveVariables(config.variables);
|
|
130
|
+
} else {
|
|
131
|
+
await writeFile(projectPaths.variables(), JSON.stringify(config, null, 2));
|
|
132
|
+
}
|
|
104
133
|
this.setCache(config);
|
|
105
134
|
}
|
|
106
135
|
}
|
|
@@ -442,8 +442,6 @@ export async function generateSSRHTML(
|
|
|
442
442
|
// Include component CSS (no longer generating inline style classes)
|
|
443
443
|
const componentCSS = rendered.componentCSS || '';
|
|
444
444
|
|
|
445
|
-
// Extract and generate utility CSS from rendered HTML
|
|
446
|
-
const usedUtilityClasses = extractUtilityClassesFromHTML(rendered.html);
|
|
447
445
|
const breakpointConfig = await loadBreakpointConfig();
|
|
448
446
|
const responsiveScalesConfig = configService.getResponsiveScales();
|
|
449
447
|
|
|
@@ -451,11 +449,29 @@ export async function generateSSRHTML(
|
|
|
451
449
|
const variablesConfig = await variableService.loadConfig();
|
|
452
450
|
const variablesCSS = generateVariablesCSS(variablesConfig, breakpointConfig, responsiveScalesConfig);
|
|
453
451
|
const remConversionConfig = configService.getRemConversion();
|
|
452
|
+
|
|
453
|
+
// Named design-token classes (`text-muted` → color: var(--muted), `bg-<token>`, …) only resolve to a
|
|
454
|
+
// CSS rule when the generator knows which token names are DEFINED — without that set, `text-muted` is
|
|
455
|
+
// indistinguishable from a typo and is dropped, both at extraction (filtered by parseUtilityClass) and
|
|
456
|
+
// at generation. Build it from the project's theme colors + variables — mirroring the Astro build's
|
|
457
|
+
// readKnownTokensSync and the canvas client's loadKnownTokens — so an instance-level color class that
|
|
458
|
+
// renders in a real Astro build also renders in the SSR design canvas.
|
|
459
|
+
const knownTokens = new Set<string>();
|
|
460
|
+
for (const theme of Object.values(themeConfig.themes)) {
|
|
461
|
+
for (const name of Object.keys(theme.colors)) knownTokens.add(name);
|
|
462
|
+
}
|
|
463
|
+
for (const variable of variablesConfig.variables) {
|
|
464
|
+
if (variable.cssVar) knownTokens.add(variable.cssVar.replace(/^--/, ''));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Extract and generate utility CSS from rendered HTML
|
|
468
|
+
const usedUtilityClasses = extractUtilityClassesFromHTML(rendered.html, knownTokens);
|
|
454
469
|
const utilityCSS = generateUtilityCSS(
|
|
455
470
|
usedUtilityClasses,
|
|
456
471
|
breakpointConfig,
|
|
457
472
|
responsiveScalesConfig,
|
|
458
473
|
remConversionConfig,
|
|
474
|
+
knownTokens,
|
|
459
475
|
);
|
|
460
476
|
|
|
461
477
|
// Generate interactive styles CSS from map collected during render
|
|
@@ -1115,6 +1115,31 @@ describe('ssrRenderer', () => {
|
|
|
1115
1115
|
expect(html).toContain('<div');
|
|
1116
1116
|
});
|
|
1117
1117
|
|
|
1118
|
+
test('component instance class-STRING override lands on the root element', async () => {
|
|
1119
|
+
// The class-based instance seam (`<Heading class="text-muted text-[#ff0000]" />`): the instance's
|
|
1120
|
+
// `class` prop must be applied onto the component root, mirroring real Astro's
|
|
1121
|
+
// cx(style(...), variants(...), className). Without it, instance-level colors render in a real
|
|
1122
|
+
// build but vanish in the SSR design canvas.
|
|
1123
|
+
const componentDef: ComponentDefinition = {
|
|
1124
|
+
component: {
|
|
1125
|
+
structure: { type: 'node', tag: 'h1', children: '{{text}}' },
|
|
1126
|
+
interface: { text: { type: 'string', default: 'Hi' } },
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
const node = {
|
|
1131
|
+
type: 'component',
|
|
1132
|
+
component: 'Heading',
|
|
1133
|
+
props: { text: 'Hi', class: 'text-muted text-[#ff0000]' },
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
const html = await render(node, { globalComponents: { Heading: componentDef } });
|
|
1137
|
+
const classMatch = html.match(/<h1[^>]*\sclass="([^"]*)"/);
|
|
1138
|
+
expect(classMatch).not.toBeNull();
|
|
1139
|
+
expect(classMatch?.[1]).toContain('text-muted');
|
|
1140
|
+
expect(classMatch?.[1]).toContain('text-[#ff0000]');
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1118
1143
|
test('component with defineVars: true adds data attributes', async () => {
|
|
1119
1144
|
const componentDef: ComponentDefinition = {
|
|
1120
1145
|
component: {
|
|
@@ -1850,8 +1850,18 @@ async function renderNode(
|
|
|
1850
1850
|
delete nodeAttributesWithoutClass.className;
|
|
1851
1851
|
delete nodeAttributesWithoutClass.class;
|
|
1852
1852
|
|
|
1853
|
-
//
|
|
1854
|
-
|
|
1853
|
+
// A component INSTANCE carries its class-string override as a prop (`<Heading class="text-muted" />`
|
|
1854
|
+
// → nodeProps.class) — the class-based equivalent of the legacy instance style object. Real Astro
|
|
1855
|
+
// applies it onto the component root via cx(style(...), variants(...), className); the SSR canvas
|
|
1856
|
+
// must too, or instance-level classes (named colors like `text-muted`/`text-text`, arbitrary
|
|
1857
|
+
// `text-[#ff0000]`) silently vanish in design mode while rendering correctly in a real build. Last in
|
|
1858
|
+
// the list so it wins source order, matching cx's instance-last precedence. Empty for HTML nodes
|
|
1859
|
+
// (nodeProps is {} there), so this only affects component instances.
|
|
1860
|
+
const instanceClassName = typeof nodeProps.class === 'string' ? nodeProps.class : '';
|
|
1861
|
+
|
|
1862
|
+
// Merge classNames: element class (first for specificity) + attribute classes + utility classes +
|
|
1863
|
+
// the component instance's class-string override (last → wins)
|
|
1864
|
+
const allClassNames = [elementClass, attrClassName, ...utilityClasses, instanceClassName].filter(Boolean);
|
|
1855
1865
|
const mergedClassName = allClassNames.length > 0 ? allClassNames.join(' ') : '';
|
|
1856
1866
|
|
|
1857
1867
|
const propsWithStyleAndAttrs = {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { parseThemeCss, serializeThemeCss, type ScaleConfig, type ThemeModel } from './themeCssCodec';
|
|
3
|
+
import type { BreakpointConfig } from '../shared/breakpoints';
|
|
4
|
+
import { DEFAULT_RESPONSIVE_SCALES } from '../shared/responsiveScaling';
|
|
5
|
+
|
|
6
|
+
const BREAKPOINTS: BreakpointConfig = {
|
|
7
|
+
tablet: { breakpoint: 1024, previewPoint: 768 },
|
|
8
|
+
mobile: { breakpoint: 540, previewPoint: 375 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const NO_SCALE: ScaleConfig = {
|
|
12
|
+
breakpoints: BREAKPOINTS,
|
|
13
|
+
responsiveScales: { ...DEFAULT_RESPONSIVE_SCALES, enabled: false },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const SCALE_ON: ScaleConfig = {
|
|
17
|
+
breakpoints: BREAKPOINTS,
|
|
18
|
+
responsiveScales: { ...DEFAULT_RESPONSIVE_SCALES, enabled: true, baseReference: 16 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('parseThemeCss', () => {
|
|
22
|
+
test('reads colors (default on :root, others on [theme]) and variables by section', () => {
|
|
23
|
+
const css = `:root {
|
|
24
|
+
/* Colors: light */
|
|
25
|
+
--text: #050505;
|
|
26
|
+
--background: #ffffff;
|
|
27
|
+
|
|
28
|
+
/* Font Size */
|
|
29
|
+
--h1-size-1: 48px;
|
|
30
|
+
|
|
31
|
+
/* Padding */
|
|
32
|
+
--section-pad: 64px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
[theme="dark"] {
|
|
36
|
+
--text: #ffffff;
|
|
37
|
+
--background: #111111;
|
|
38
|
+
}`;
|
|
39
|
+
const model = parseThemeCss(css, BREAKPOINTS);
|
|
40
|
+
expect(model.themes.default).toBe('light');
|
|
41
|
+
expect(model.themes.themes.light?.colors).toEqual({ text: '#050505', background: '#ffffff' });
|
|
42
|
+
expect(model.themes.themes.dark?.colors).toEqual({ text: '#ffffff', background: '#111111' });
|
|
43
|
+
|
|
44
|
+
const fs = model.variables.find((v) => v.cssVar === '--h1-size-1');
|
|
45
|
+
expect(fs).toMatchObject({ value: '48px', group: 'font-size', type: 'fontSize' });
|
|
46
|
+
const pad = model.variables.find((v) => v.cssVar === '--section-pad');
|
|
47
|
+
expect(pad).toMatchObject({ value: '64px', group: 'padding', type: 'padding' });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('override @media maps to per-variable scales; auto region is discarded', () => {
|
|
51
|
+
const css = `:root {
|
|
52
|
+
/* Font Size */
|
|
53
|
+
--h1: 48px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* === Responsive scaling — auto-generated from project.config.json; regenerated on save === */
|
|
57
|
+
@media (max-width: 1024px) {
|
|
58
|
+
:root {
|
|
59
|
+
--h1: 40px;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* === Responsive overrides — per-variable; preserved across regen === */
|
|
64
|
+
@media (max-width: 540px) {
|
|
65
|
+
:root {
|
|
66
|
+
--h1: 28px;
|
|
67
|
+
}
|
|
68
|
+
}`;
|
|
69
|
+
const model = parseThemeCss(css, BREAKPOINTS);
|
|
70
|
+
const h1 = model.variables.find((v) => v.cssVar === '--h1');
|
|
71
|
+
// auto (tablet 40px) discarded; override (mobile 28px) preserved
|
|
72
|
+
expect(h1?.scales).toEqual({ mobile: '28px' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('preserves unmodeled CSS verbatim in raw', () => {
|
|
76
|
+
const css = `:root {
|
|
77
|
+
/* Colors: light */
|
|
78
|
+
--text: #000;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@font-face {
|
|
82
|
+
font-family: "Inter";
|
|
83
|
+
src: url("/fonts/inter.woff2") format("woff2");
|
|
84
|
+
}`;
|
|
85
|
+
const model = parseThemeCss(css, BREAKPOINTS);
|
|
86
|
+
expect(model.raw.join('\n')).toContain('@font-face');
|
|
87
|
+
expect(model.raw.join('\n')).toContain('inter.woff2');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('serializeThemeCss', () => {
|
|
92
|
+
test('emits grouped :root with colors + variable sections', () => {
|
|
93
|
+
const model: ThemeModel = {
|
|
94
|
+
themes: { default: 'light', themes: { light: { label: 'Light', colors: { text: '#050505' } } } },
|
|
95
|
+
variables: [{ cssVar: '--h1', value: '48px', type: 'fontSize', group: 'font-size' }],
|
|
96
|
+
raw: [],
|
|
97
|
+
};
|
|
98
|
+
const css = serializeThemeCss(model, NO_SCALE);
|
|
99
|
+
expect(css).toContain('/* Colors: light */');
|
|
100
|
+
expect(css).toContain('--text: #050505;');
|
|
101
|
+
expect(css).toContain('/* Font Size */');
|
|
102
|
+
expect(css).toContain('--h1: 48px;');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('regenerates auto @media from scale config when enabled', () => {
|
|
106
|
+
const model: ThemeModel = {
|
|
107
|
+
themes: { default: 'light', themes: { light: { colors: {} } } },
|
|
108
|
+
variables: [{ cssVar: '--h1', value: '48px', type: 'fontSize', group: 'font-size' }],
|
|
109
|
+
raw: [],
|
|
110
|
+
};
|
|
111
|
+
const css = serializeThemeCss(model, SCALE_ON);
|
|
112
|
+
expect(css).toContain('Responsive scaling');
|
|
113
|
+
expect(css).toMatch(/@media \(max-width: 1024px\)/);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('round-trip', () => {
|
|
118
|
+
const model: ThemeModel = {
|
|
119
|
+
themes: {
|
|
120
|
+
default: 'light',
|
|
121
|
+
themes: {
|
|
122
|
+
light: { label: 'Light', colors: { text: '#050505', background: '#ffffff' } },
|
|
123
|
+
dark: { label: 'Dark', colors: { text: '#ffffff', background: '#111111' } },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
variables: [
|
|
127
|
+
{ cssVar: '--h1-size-1', value: '48px', type: 'fontSize', group: 'font-size', scales: { mobile: '28px' } },
|
|
128
|
+
{ cssVar: '--section-pad', value: '64px', type: 'padding', group: 'padding' },
|
|
129
|
+
{ cssVar: '--brand-font', value: 'Inter, sans-serif', type: 'none', group: 'font-family' },
|
|
130
|
+
],
|
|
131
|
+
raw: ['@font-face {\n font-family: "Inter";\n src: url("/x.woff2");\n}'],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
test('css → model → css is stable', () => {
|
|
135
|
+
const css1 = serializeThemeCss(model, SCALE_ON);
|
|
136
|
+
const model2 = parseThemeCss(css1, BREAKPOINTS);
|
|
137
|
+
const css2 = serializeThemeCss(model2, SCALE_ON);
|
|
138
|
+
expect(css2).toBe(css1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('persisted model fields survive a round-trip', () => {
|
|
142
|
+
const css = serializeThemeCss(model, SCALE_ON);
|
|
143
|
+
const back = parseThemeCss(css, BREAKPOINTS);
|
|
144
|
+
expect(back.themes.default).toBe('light');
|
|
145
|
+
expect(Object.keys(back.themes.themes).sort()).toEqual(['dark', 'light']);
|
|
146
|
+
expect(back.themes.themes.dark?.colors.text).toBe('#ffffff');
|
|
147
|
+
|
|
148
|
+
const h1 = back.variables.find((v) => v.cssVar === '--h1-size-1');
|
|
149
|
+
expect(h1).toMatchObject({ value: '48px', group: 'font-size', scales: { mobile: '28px' } });
|
|
150
|
+
const font = back.variables.find((v) => v.cssVar === '--brand-font');
|
|
151
|
+
expect(font).toMatchObject({ value: 'Inter, sans-serif', group: 'font-family', type: 'none' });
|
|
152
|
+
expect(back.raw.join('\n')).toContain('@font-face');
|
|
153
|
+
});
|
|
154
|
+
});
|