meno-core 1.1.1 → 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.
@@ -0,0 +1,488 @@
1
+ /**
2
+ * theme.css codec — the single source of truth for a project's design tokens.
3
+ *
4
+ * A Meno project's colors AND typography/layout variables both live in one native
5
+ * Astro stylesheet, `src/styles/theme.css`, imported by every page. This module is the
6
+ * bidirectional bridge between that file and the in-memory editor model:
7
+ *
8
+ * parse: theme.css ──▶ { themes: ThemeConfig, variables: CSSVariable[], raw[] }
9
+ * serialize: that model ──▶ theme.css
10
+ *
11
+ * It replaces the old colors.json + variables.json pair. The CSS variable name IS a
12
+ * token's identity (no editor-only `name`/`prop_name` are persisted), grouping is the
13
+ * comment section a declaration sits under (`/* Font Size *​/`, `/* Colors: light *​/`),
14
+ * and the responsive `@media` blocks are an editor-managed, regenerated region — so the
15
+ * file stays readable and hand-editable while round-tripping losslessly.
16
+ *
17
+ * The parser is a small tolerant tokenizer (brace/string/comment aware) rather than a
18
+ * full CSS library: theme.css is a constrained grammar, and keeping this dependency-free
19
+ * matters because meno-core is published and bundled into the Astro play runtime (which
20
+ * never calls the codec — it consumes theme.css as a static file). Anything outside the
21
+ * known grammar (@font-face, @import, keyframes, foreign selectors) is captured verbatim
22
+ * into `raw` and re-emitted untouched, so user/AI additions are never dropped.
23
+ */
24
+
25
+ import type { ThemeConfig, Theme } from '../shared/types/colors';
26
+ import { resolvePaletteColor } from '../shared/types/colors';
27
+ import type { CSSVariable, VariableGroup } from '../shared/types/variables';
28
+ import { VARIABLE_GROUPS, getDefaultScalingType } from '../shared/types/variables';
29
+ import type { BreakpointConfig } from '../shared/breakpoints';
30
+ import type { ResponsiveScales, BreakpointScales } from '../shared/responsiveScaling';
31
+ import { scalePropertyValue } from '../shared/responsiveScaling';
32
+
33
+ /** Parsed/serializable representation of a project's `theme.css`. */
34
+ export interface ThemeModel {
35
+ /** Colors as a multi-theme config (default theme on `:root`, others on `[theme="x"]`). */
36
+ themes: ThemeConfig;
37
+ /** Design-token variables (group/type derived; `scales` = explicit per-breakpoint overrides). */
38
+ variables: CSSVariable[];
39
+ /** Verbatim CSS the codec does not model (@font-face, @import, foreign rules) — re-emitted as-is. */
40
+ raw: string[];
41
+ }
42
+
43
+ /** Responsive-scaling inputs the serializer needs (from `project.config.json`). */
44
+ export interface ScaleConfig {
45
+ breakpoints: BreakpointConfig;
46
+ responsiveScales: ResponsiveScales;
47
+ }
48
+
49
+ const COLORS_LABEL = 'Colors';
50
+ const OTHER_LABEL = 'Other';
51
+
52
+ const HEADER_BANNER = `/*
53
+ * Meno design tokens — theme.css is the source of truth for this project's colors and
54
+ * variables. Edit values here, or use the Studio color/variable panels. Variables are
55
+ * grouped by the comment sections below; the @media regions are regenerated on save
56
+ * (responsive scaling is configured in project.config.json).
57
+ */`;
58
+
59
+ const AUTO_BANNER = '/* === Responsive scaling — auto-generated from project.config.json; regenerated on save === */';
60
+ const OVERRIDES_BANNER = '/* === Responsive overrides — per-variable; preserved across regen === */';
61
+
62
+ // ───────────────────────────── group ⇄ comment-section ─────────────────────────────
63
+
64
+ /** Comment-section header for a variable's group (e.g. 'font-size' → 'Font Size'). */
65
+ function sectionLabelForGroup(group?: VariableGroup): string {
66
+ if (!group || group === 'other') return OTHER_LABEL;
67
+ return VARIABLE_GROUPS.find((g) => g.value === group)?.label ?? OTHER_LABEL;
68
+ }
69
+
70
+ const LABEL_TO_GROUP = new Map<string, VariableGroup>(VARIABLE_GROUPS.map((g) => [g.label.toLowerCase(), g.value]));
71
+
72
+ /** Reverse of {@link sectionLabelForGroup}: a section header back to its group, or null. */
73
+ export function groupForSectionLabel(label: string): VariableGroup | null {
74
+ return LABEL_TO_GROUP.get(label.trim().toLowerCase()) ?? null;
75
+ }
76
+
77
+ /** True if a CSS value reads as a color (used to classify un-sectioned `:root` decls). */
78
+ export function isColorValue(value: string): boolean {
79
+ const v = value.trim().toLowerCase();
80
+ if (/^#[0-9a-f]{3,8}$/.test(v)) return true;
81
+ if (/^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(/.test(v)) return true;
82
+ if (v === 'transparent' || v === 'currentcolor') return true;
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * Best-effort group for a variable that carries no comment section (hand-written or
88
+ * legacy theme.css). px/rem tokens are inherently ambiguous (font-size vs padding vs
89
+ * width) and fall back to 'other'; the section comment is the reliable signal.
90
+ */
91
+ export function inferGroup(cssVar: string, value: string): VariableGroup {
92
+ const v = value.trim().toLowerCase();
93
+ if (/^\d+(\.\d+)?m?s$/.test(v)) return 'duration';
94
+ if (/^(normal|bold|lighter|bolder)$/.test(v)) return 'font-weight';
95
+ if (/^\d+$/.test(v)) {
96
+ const n = Number(v);
97
+ if (n >= 100 && n <= 900 && n % 100 === 0) return 'font-weight';
98
+ }
99
+ if (/^\d*\.\d+$/.test(v) || /^[1-9](\.\d+)?$/.test(v)) return 'line-height';
100
+ // Last-ditch: a hint embedded in the variable name.
101
+ const name = cssVar.replace(/^--/, '').toLowerCase();
102
+ for (const g of VARIABLE_GROUPS) {
103
+ if (g.value !== 'other' && name.includes(g.value)) return g.value;
104
+ }
105
+ return 'other';
106
+ }
107
+
108
+ // ───────────────────────────────── serialize ─────────────────────────────────
109
+
110
+ /** Group variables into ordered comment sections (VARIABLE_GROUPS order, 'Other' last). */
111
+ function groupVariables(variables: CSSVariable[]): { label: string; vars: CSSVariable[] }[] {
112
+ const buckets = new Map<string, CSSVariable[]>();
113
+ for (const v of variables) {
114
+ const label = sectionLabelForGroup(v.group);
115
+ (buckets.get(label) ?? buckets.set(label, []).get(label)!).push(v);
116
+ }
117
+ const ordered: { label: string; vars: CSSVariable[] }[] = [];
118
+ for (const g of VARIABLE_GROUPS) {
119
+ if (g.value === 'other') continue;
120
+ const vars = buckets.get(g.label);
121
+ if (vars?.length) {
122
+ ordered.push({ label: g.label, vars });
123
+ buckets.delete(g.label);
124
+ }
125
+ }
126
+ for (const [label, vars] of buckets) if (vars.length) ordered.push({ label, vars });
127
+ return ordered;
128
+ }
129
+
130
+ /** `@media` blocks for the globally-scaled values (vars without an explicit override). */
131
+ function buildAutoMediaBlocks(scalable: CSSVariable[], cfg: ScaleConfig): string {
132
+ const { breakpoints, responsiveScales } = cfg;
133
+ const baseRef = responsiveScales.baseReference || 16;
134
+ const sorted = Object.entries(breakpoints).sort((a, b) => b[1].breakpoint - a[1].breakpoint);
135
+ const blocks: string[] = [];
136
+ for (const [bpName, bp] of sorted) {
137
+ const lines: string[] = [];
138
+ for (const v of scalable) {
139
+ if (v.scales?.[bpName] !== undefined) continue; // explicit override → handled in overrides region
140
+ const categoryScales = responsiveScales[v.type] as BreakpointScales | undefined;
141
+ const scale = categoryScales?.[bpName];
142
+ if (scale == null) continue;
143
+ const scaled = scalePropertyValue(v.value, baseRef, scale);
144
+ if (scaled !== null && scaled !== v.value) lines.push(` ${v.cssVar}: ${scaled};`);
145
+ }
146
+ if (lines.length) blocks.push(`@media (max-width: ${bp.breakpoint}px) {\n :root {\n${lines.join('\n')}\n }\n}`);
147
+ }
148
+ return blocks.join('\n\n');
149
+ }
150
+
151
+ /** `@media` blocks for explicit per-variable overrides (always emitted; authored data). */
152
+ function buildOverrideMediaBlocks(variables: CSSVariable[], breakpoints: BreakpointConfig): string {
153
+ const sorted = Object.entries(breakpoints).sort((a, b) => b[1].breakpoint - a[1].breakpoint);
154
+ const blocks: string[] = [];
155
+ for (const [bpName, bp] of sorted) {
156
+ const lines: string[] = [];
157
+ for (const v of variables) {
158
+ const override = v.scales?.[bpName];
159
+ if (override !== undefined && override !== v.value) lines.push(` ${v.cssVar}: ${override};`);
160
+ }
161
+ if (lines.length) blocks.push(`@media (max-width: ${bp.breakpoint}px) {\n :root {\n${lines.join('\n')}\n }\n}`);
162
+ }
163
+ return blocks.join('\n\n');
164
+ }
165
+
166
+ /**
167
+ * Serialize a {@link ThemeModel} to a canonical `theme.css`. The base `:root` carries the
168
+ * authored values (colors under `/* Colors *​/`, variables under their group sections); the
169
+ * AUTO `@media` region is regenerated from `scaleConfig` and only emitted when responsive
170
+ * scaling is enabled; the OVERRIDES region is always emitted; `raw` is appended verbatim.
171
+ */
172
+ export function serializeThemeCss(model: ThemeModel, scaleConfig: ScaleConfig): string {
173
+ const { themes, variables, raw = [] } = model;
174
+ const palette = themes?.palette;
175
+ const defaultName = themes?.default ?? 'default';
176
+ const defaultTheme = themes?.themes?.[defaultName];
177
+
178
+ const sections: string[] = [];
179
+ const colorEntries = Object.entries(defaultTheme?.colors ?? {});
180
+ if (colorEntries.length) {
181
+ sections.push(
182
+ [
183
+ ` /* ${COLORS_LABEL}: ${defaultName} */`,
184
+ ...colorEntries.map(([k, val]) => ` --${k}: ${resolvePaletteColor(val, palette)};`),
185
+ ].join('\n'),
186
+ );
187
+ }
188
+ for (const { label, vars } of groupVariables(variables)) {
189
+ sections.push([` /* ${label} */`, ...vars.map((v) => ` ${v.cssVar}: ${v.value};`)].join('\n'));
190
+ }
191
+
192
+ const blocks: string[] = [HEADER_BANNER];
193
+ blocks.push(sections.length ? `:root {\n${sections.join('\n\n')}\n}` : ':root {}');
194
+
195
+ for (const [name, theme] of Object.entries(themes?.themes ?? {})) {
196
+ if (name === defaultName) continue; // default lives on :root
197
+ const lines = Object.entries(theme.colors ?? {}).map(
198
+ ([k, val]) => ` --${k}: ${resolvePaletteColor(val, palette)};`,
199
+ );
200
+ if (lines.length) blocks.push(`[theme="${name}"] {\n${lines.join('\n')}\n}`);
201
+ }
202
+
203
+ if (scaleConfig.responsiveScales?.enabled) {
204
+ const auto = buildAutoMediaBlocks(
205
+ variables.filter((v) => v.type && v.type !== 'none'),
206
+ scaleConfig,
207
+ );
208
+ if (auto) blocks.push(AUTO_BANNER, auto);
209
+ }
210
+ const overrides = buildOverrideMediaBlocks(variables, scaleConfig.breakpoints);
211
+ if (overrides) blocks.push(OVERRIDES_BANNER, overrides);
212
+
213
+ for (const r of raw) {
214
+ const t = r.trim();
215
+ if (t) blocks.push(t);
216
+ }
217
+
218
+ return `${blocks.join('\n\n')}\n`;
219
+ }
220
+
221
+ // ───────────────────────────────── tokenizer ─────────────────────────────────
222
+
223
+ type TopNode =
224
+ | { kind: 'comment'; text: string; raw: string }
225
+ | { kind: 'block'; prelude: string; body: string; raw: string }
226
+ | { kind: 'statement'; raw: string };
227
+
228
+ type DeclItem = { kind: 'comment'; text: string } | { kind: 'decl'; prop: string; value: string };
229
+
230
+ /** Advance past a quoted string starting at `i` (handles backslash escapes). */
231
+ function skipString(s: string, i: number): number {
232
+ const q = s[i];
233
+ i++;
234
+ while (i < s.length) {
235
+ if (s[i] === '\\') {
236
+ i += 2;
237
+ continue;
238
+ }
239
+ if (s[i] === q) return i + 1;
240
+ i++;
241
+ }
242
+ return i;
243
+ }
244
+
245
+ /** Read a balanced `{ … }` block starting at the `{` index; returns inner body + end index. */
246
+ function readBlock(s: string, open: number): { body: string; end: number } {
247
+ let i = open + 1;
248
+ let depth = 1;
249
+ const bodyStart = i;
250
+ while (i < s.length && depth > 0) {
251
+ if (s[i] === '/' && s[i + 1] === '*') {
252
+ const e = s.indexOf('*/', i + 2);
253
+ i = e === -1 ? s.length : e + 2;
254
+ continue;
255
+ }
256
+ if (s[i] === '"' || s[i] === "'") {
257
+ i = skipString(s, i);
258
+ continue;
259
+ }
260
+ if (s[i] === '{') depth++;
261
+ else if (s[i] === '}') {
262
+ depth--;
263
+ if (depth === 0) break;
264
+ }
265
+ i++;
266
+ }
267
+ return { body: s.slice(bodyStart, i), end: i < s.length ? i + 1 : s.length };
268
+ }
269
+
270
+ /** Split CSS into ordered top-level nodes (comments, `selector { … }` blocks, statements). */
271
+ function scanTopLevel(css: string): TopNode[] {
272
+ const nodes: TopNode[] = [];
273
+ let i = 0;
274
+ while (i < css.length) {
275
+ while (i < css.length && /\s/.test(css[i]!)) i++;
276
+ if (i >= css.length) break;
277
+ if (css[i] === '/' && css[i + 1] === '*') {
278
+ const e = css.indexOf('*/', i + 2);
279
+ const stop = e === -1 ? css.length : e + 2;
280
+ const text = css.slice(i, stop);
281
+ nodes.push({ kind: 'comment', text, raw: text });
282
+ i = stop;
283
+ continue;
284
+ }
285
+ const start = i;
286
+ while (i < css.length) {
287
+ const c = css[i]!;
288
+ if (c === '/' && css[i + 1] === '*') {
289
+ const e = css.indexOf('*/', i + 2);
290
+ i = e === -1 ? css.length : e + 2;
291
+ continue;
292
+ }
293
+ if (c === '"' || c === "'") {
294
+ i = skipString(css, i);
295
+ continue;
296
+ }
297
+ if (c === '{' || c === ';') break;
298
+ i++;
299
+ }
300
+ if (i < css.length && css[i] === '{') {
301
+ const prelude = css.slice(start, i).trim();
302
+ const { body, end } = readBlock(css, i);
303
+ nodes.push({ kind: 'block', prelude, body, raw: css.slice(start, end) });
304
+ i = end;
305
+ } else {
306
+ const stop = i < css.length ? i + 1 : css.length;
307
+ const raw = css.slice(start, stop).trim();
308
+ if (raw) nodes.push({ kind: 'statement', raw });
309
+ i = stop;
310
+ }
311
+ }
312
+ return nodes;
313
+ }
314
+
315
+ /** Parse a rule body into ordered declarations + inline comments (for section detection). */
316
+ function scanDeclarations(body: string): DeclItem[] {
317
+ const items: DeclItem[] = [];
318
+ let i = 0;
319
+ while (i < body.length) {
320
+ while (i < body.length && /\s/.test(body[i]!)) i++;
321
+ if (i >= body.length) break;
322
+ if (body[i] === '/' && body[i + 1] === '*') {
323
+ const e = body.indexOf('*/', i + 2);
324
+ const stop = e === -1 ? body.length : e + 2;
325
+ items.push({ kind: 'comment', text: body.slice(i, stop) });
326
+ i = stop;
327
+ continue;
328
+ }
329
+ const start = i;
330
+ while (i < body.length) {
331
+ const c = body[i]!;
332
+ if (c === '/' && body[i + 1] === '*') {
333
+ const e = body.indexOf('*/', i + 2);
334
+ i = e === -1 ? body.length : e + 2;
335
+ continue;
336
+ }
337
+ if (c === '"' || c === "'") {
338
+ i = skipString(body, i);
339
+ continue;
340
+ }
341
+ if (c === ';') break;
342
+ i++;
343
+ }
344
+ const text = body.slice(start, i).trim();
345
+ i = i < body.length ? i + 1 : body.length;
346
+ const colon = text.indexOf(':');
347
+ if (colon > 0) {
348
+ items.push({ kind: 'decl', prop: text.slice(0, colon).trim(), value: text.slice(colon + 1).trim() });
349
+ }
350
+ }
351
+ return items;
352
+ }
353
+
354
+ // ───────────────────────────────── parse ─────────────────────────────────
355
+
356
+ /** Inner text of a `/* … *​/` comment, trimmed. */
357
+ function commentInner(text: string): string {
358
+ return text
359
+ .replace(/^\/\*+/, '')
360
+ .replace(/\*+\/$/, '')
361
+ .trim();
362
+ }
363
+
364
+ function isHeaderBanner(text: string): boolean {
365
+ const t = commentInner(text);
366
+ return /meno design tokens/i.test(t) || /AUTO-GENERATED by meno-astro/i.test(t);
367
+ }
368
+
369
+ function prettifyName(name: string): string {
370
+ return name
371
+ .replace(/[-_]/g, ' ')
372
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
373
+ .replace(/\b\w/g, (c) => c.toUpperCase());
374
+ }
375
+
376
+ function maxWidthPx(prelude: string): number | null {
377
+ const m = prelude.match(/max-width:\s*(\d+)px/i);
378
+ return m ? Number(m[1]) : null;
379
+ }
380
+
381
+ function bpNameForPx(px: number, breakpoints: BreakpointConfig): string | null {
382
+ for (const [name, entry] of Object.entries(breakpoints)) if (entry.breakpoint === px) return name;
383
+ return null;
384
+ }
385
+
386
+ /**
387
+ * Parse a `theme.css` into a {@link ThemeModel}. `breakpoints` (from project.config.json)
388
+ * maps the OVERRIDES `@media (max-width: Npx)` blocks back to breakpoint names; the AUTO
389
+ * region is discarded (it is regenerated on serialize) and any unmodeled CSS is preserved
390
+ * in `raw`.
391
+ */
392
+ export function parseThemeCss(css: string, breakpoints?: BreakpointConfig): ThemeModel {
393
+ const themesMap: Record<string, Theme> = {};
394
+ const variables: CSSVariable[] = [];
395
+ const varByCssVar = new Map<string, CSSVariable>();
396
+ const raw: string[] = [];
397
+ let defaultName: string | null = null;
398
+ let region: 'auto' | 'overrides' | null = null;
399
+
400
+ for (const node of scanTopLevel(css)) {
401
+ if (node.kind === 'comment') {
402
+ if (isHeaderBanner(node.text)) continue;
403
+ const inner = commentInner(node.text);
404
+ if (/responsive overrides/i.test(inner)) {
405
+ region = 'overrides';
406
+ continue;
407
+ }
408
+ if (/responsive scaling|auto-generated from project\.config/i.test(inner)) {
409
+ region = 'auto';
410
+ continue;
411
+ }
412
+ raw.push(node.raw);
413
+ continue;
414
+ }
415
+ if (node.kind === 'statement') {
416
+ raw.push(node.raw);
417
+ continue;
418
+ }
419
+
420
+ const sel = node.prelude;
421
+
422
+ if (sel === ':root') {
423
+ let section: VariableGroup | 'colors' | null = null;
424
+ for (const it of scanDeclarations(node.body)) {
425
+ if (it.kind === 'comment') {
426
+ const inner = commentInner(it.text);
427
+ if (/^colors\b/i.test(inner)) {
428
+ section = 'colors';
429
+ const dn = inner.match(/^colors\s*:\s*([\w-]+)/i);
430
+ if (dn) defaultName = dn[1]!;
431
+ } else {
432
+ const g = groupForSectionLabel(inner);
433
+ if (g) section = g;
434
+ }
435
+ continue;
436
+ }
437
+ if (!it.prop.startsWith('--')) continue;
438
+ const name = it.prop.slice(2);
439
+ if (section === 'colors' || (section === null && isColorValue(it.value))) {
440
+ const dn = (defaultName ??= 'default');
441
+ (themesMap[dn] ??= { colors: {} }).colors[name] = it.value;
442
+ } else {
443
+ const group = section ?? inferGroup(it.prop, it.value);
444
+ const v: CSSVariable = { cssVar: it.prop, value: it.value, type: getDefaultScalingType(group), group };
445
+ variables.push(v);
446
+ varByCssVar.set(it.prop, v);
447
+ }
448
+ }
449
+ continue;
450
+ }
451
+
452
+ const themeMatch = sel.match(/^\[theme=["']?([^"'\]]+)["']?\]$/);
453
+ if (themeMatch) {
454
+ const theme = (themesMap[themeMatch[1]!] ??= { colors: {} });
455
+ for (const it of scanDeclarations(node.body)) {
456
+ if (it.kind === 'decl' && it.prop.startsWith('--')) theme.colors[it.prop.slice(2)] = it.value;
457
+ }
458
+ continue;
459
+ }
460
+
461
+ if (/^@media\b/i.test(sel)) {
462
+ const px = maxWidthPx(sel);
463
+ if (region === 'auto') continue; // regenerated on serialize
464
+ const bpName = px != null && breakpoints ? bpNameForPx(px, breakpoints) : null;
465
+ if (!bpName) {
466
+ raw.push(node.raw); // can't map to a breakpoint → preserve verbatim
467
+ continue;
468
+ }
469
+ for (const inner of scanTopLevel(node.body)) {
470
+ if (inner.kind !== 'block' || inner.prelude !== ':root') continue;
471
+ for (const it of scanDeclarations(inner.body)) {
472
+ if (it.kind !== 'decl' || !it.prop.startsWith('--')) continue;
473
+ const v = varByCssVar.get(it.prop);
474
+ if (v) (v.scales ??= {})[bpName] = it.value;
475
+ }
476
+ }
477
+ continue;
478
+ }
479
+
480
+ raw.push(node.raw); // foreign rule (@font-face, @keyframes, other selectors) — keep as-is
481
+ }
482
+
483
+ if (!defaultName) defaultName = Object.keys(themesMap)[0] ?? 'default';
484
+ (themesMap[defaultName] ??= { colors: {} }).label ??= prettifyName(defaultName);
485
+ for (const [name, t] of Object.entries(themesMap)) t.label ??= prettifyName(name);
486
+
487
+ return { themes: { default: defaultName, themes: themesMap }, variables, raw };
488
+ }
@@ -0,0 +1,139 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { setProjectRoot } from './projectContext';
7
+ import { loadThemeModel, saveColors, saveVariables, themeCssPath, isThemeCssProject } from './themeCssStore';
8
+ import type { ThemeConfig } from '../shared/types/colors';
9
+
10
+ const tmps: string[] = [];
11
+
12
+ async function makeProject(opts?: { colors?: unknown; variables?: unknown }): Promise<string> {
13
+ const root = await mkdtemp(join(tmpdir(), 'meno-theme-'));
14
+ tmps.push(root);
15
+ await mkdir(join(root, 'src', 'pages'), { recursive: true });
16
+ await writeFile(
17
+ join(root, 'project.config.json'),
18
+ JSON.stringify({
19
+ format: 'astro',
20
+ breakpoints: { tablet: { breakpoint: 1024, previewPoint: 768 }, mobile: { breakpoint: 540, previewPoint: 375 } },
21
+ }),
22
+ );
23
+ if (opts?.colors) await writeFile(join(root, 'colors.json'), JSON.stringify(opts.colors));
24
+ if (opts?.variables) await writeFile(join(root, 'variables.json'), JSON.stringify(opts.variables));
25
+ setProjectRoot(root);
26
+ return root;
27
+ }
28
+
29
+ afterEach(async () => {
30
+ while (tmps.length) await rm(tmps.pop()!, { recursive: true, force: true });
31
+ });
32
+
33
+ describe('themeCssStore', () => {
34
+ test('detects astro projects', async () => {
35
+ const root = await makeProject();
36
+ expect(isThemeCssProject(root)).toBe(true);
37
+ });
38
+
39
+ test('migrates legacy colors.json + variables.json into theme.css, then deletes them', async () => {
40
+ const root = await makeProject({
41
+ colors: { default: 'light', themes: { light: { label: 'Light', colors: { text: '#111' } } } },
42
+ variables: { variables: [{ cssVar: '--h1', value: '48px', type: 'fontSize', group: 'font-size' }] },
43
+ });
44
+
45
+ const model = await loadThemeModel();
46
+ expect(model.themes.themes.light?.colors.text).toBe('#111');
47
+ expect(model.variables.find((v) => v.cssVar === '--h1')?.value).toBe('48px');
48
+
49
+ // theme.css written; JSON removed
50
+ expect(existsSync(themeCssPath(root))).toBe(true);
51
+ expect(existsSync(join(root, 'colors.json'))).toBe(false);
52
+ expect(existsSync(join(root, 'variables.json'))).toBe(false);
53
+
54
+ const css = await readFile(themeCssPath(root), 'utf8');
55
+ expect(css).toContain('--text: #111;');
56
+ expect(css).toContain('--h1: 48px;');
57
+ });
58
+
59
+ test('saveColors preserves variables; saveVariables preserves colors', async () => {
60
+ await makeProject({
61
+ colors: { default: 'light', themes: { light: { colors: { text: '#111' } } } },
62
+ variables: { variables: [{ cssVar: '--h1', value: '48px', type: 'fontSize', group: 'font-size' }] },
63
+ });
64
+ await loadThemeModel(); // trigger migration
65
+
66
+ const newColors: ThemeConfig = {
67
+ default: 'light',
68
+ themes: { light: { colors: { text: '#222', accent: '#f00' } } },
69
+ };
70
+ await saveColors(newColors);
71
+ let model = await loadThemeModel();
72
+ expect(model.themes.themes.light?.colors).toEqual({ text: '#222', accent: '#f00' });
73
+ expect(model.variables.find((v) => v.cssVar === '--h1')?.value).toBe('48px'); // variables preserved
74
+
75
+ await saveVariables([{ cssVar: '--h1', value: '40px', type: 'fontSize', group: 'font-size' }]);
76
+ model = await loadThemeModel();
77
+ expect(model.variables.find((v) => v.cssVar === '--h1')?.value).toBe('40px');
78
+ expect(model.themes.themes.light?.colors.accent).toBe('#f00'); // colors preserved
79
+ });
80
+
81
+ test('existing astro project (old DERIVED theme.css + JSON) migrates from JSON authoritatively', async () => {
82
+ const root = await makeProject({
83
+ colors: { default: 'light', themes: { light: { colors: { text: '#111' } }, dark: { colors: { text: '#fff' } } } },
84
+ variables: { variables: [{ cssVar: '--h1', value: '48px', type: 'fontSize', group: 'font-size' }] },
85
+ });
86
+ // Simulate the previous converter's old-format derived theme.css: no header, unsectioned
87
+ // :root default colors + a duplicate [theme="light"] block. This must NOT leak a bogus theme.
88
+ await mkdir(join(root, 'src', 'styles'), { recursive: true });
89
+ await writeFile(
90
+ themeCssPath(root),
91
+ ':root {\n --text: #111;\n}\n\n[theme="light"] {\n --text: #111;\n}\n\n[theme="dark"] {\n --text: #fff;\n}\n',
92
+ );
93
+
94
+ const model = await loadThemeModel();
95
+ expect(model.themes.default).toBe('light');
96
+ expect(Object.keys(model.themes.themes).sort()).toEqual(['dark', 'light']); // no spurious 'default'
97
+ expect(model.variables.find((v) => v.cssVar === '--h1')?.value).toBe('48px');
98
+
99
+ const css = await readFile(themeCssPath(root), 'utf8');
100
+ expect(css).toContain('Meno design tokens'); // rebuilt in the new authored format
101
+ expect(css).toContain('/* Colors: light */');
102
+ });
103
+
104
+ test('a JSON overlay written after theme.css exists is folded in (union, theme.css wins)', async () => {
105
+ const root = await makeProject({
106
+ colors: { default: 'light', themes: { light: { colors: { text: '#111' } } } },
107
+ variables: { variables: [{ cssVar: '--h1', value: '48px', type: 'fontSize', group: 'font-size' }] },
108
+ });
109
+ await loadThemeModel(); // migrate → theme.css only
110
+
111
+ // Simulate an external tool (e.g. the style-guide applier) dropping JSON overlays.
112
+ await writeFile(
113
+ join(root, 'colors.json'),
114
+ JSON.stringify({ default: 'light', themes: { light: { colors: { text: '#999', accent: '#0f0' } } } }),
115
+ );
116
+ await writeFile(
117
+ join(root, 'variables.json'),
118
+ JSON.stringify({ variables: [{ cssVar: '--h2', value: '32px', type: 'fontSize', group: 'font-size' }] }),
119
+ );
120
+
121
+ const model = await loadThemeModel();
122
+ // existing theme.css value wins on conflict; overlay adds the new key
123
+ expect(model.themes.themes.light?.colors).toEqual({ text: '#111', accent: '#0f0' });
124
+ // existing variable kept; overlay variable appended
125
+ expect(model.variables.map((v) => v.cssVar).sort()).toEqual(['--h1', '--h2']);
126
+ // overlay JSON consumed
127
+ expect(existsSync(join(root, 'colors.json'))).toBe(false);
128
+ expect(existsSync(join(root, 'variables.json'))).toBe(false);
129
+ });
130
+
131
+ test('a project with no JSON loads as empty and accepts saves', async () => {
132
+ await makeProject();
133
+ const model = await loadThemeModel();
134
+ expect(model.variables).toEqual([]);
135
+ await saveVariables([{ cssVar: '--gap', value: '16px', type: 'gap', group: 'gap' }]);
136
+ const back = await loadThemeModel();
137
+ expect(back.variables.find((v) => v.cssVar === '--gap')?.value).toBe('16px');
138
+ });
139
+ });