@astryxdesign/cli 0.4.2 → 0.4.3-canary.5939961

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 CHANGED
@@ -1,5 +1,28 @@
1
1
  # @xds/cli
2
2
 
3
+ # 0.4.3
4
+
5
+ #### Fixes
6
+
7
+ - The unloaded-font advisory is a notice, not a warning. A theme file cannot load a font — Astryx sets `--font-family-*` and loading is the app's job — so #5045's advisory fires on any theme naming a webfont, including a perfectly correct one. As a warning that made a clean build read as defective, and it put the shipped template permanently in violation of its own "compiles with no warnings" guard (#5079 had to allowlist the template's two font names in that assertion).
8
+ The `theme.build` receipt now separates the two: `warnings` are defects the author should fix, `notices` are advisories about a correct theme. The font advisory moves to `notices` and to stdout with the rest of the build's progress; stderr stays for defects. The template guard is back to `warnings` being empty, and no longer needs to know which fonts the template names.
9
+
10
+ Programmatic callers reading `data.warnings` for font advisories should read `data.notices`; the message text is unchanged.
11
+ - `extends` now reaches the CSS. A theme that extended another built a stylesheet holding only the declarations it stated itself: the base's tokens, component overrides and surface rules were all absent, and because each theme is `@scope`d to its own `data-astryx-theme` value, loading the base's stylesheet alongside could not fill the gap either. Every consumer of an inheritance chain silently got stock geometry, elevation and type with a new palette painted over it (#5067). Nothing warned; the loss only showed up by diffing two generated stylesheets token by token.
12
+ The cause was `theme build` shadowing its own inputs. It writes `<name>.js` next to `<name>.ts`, and the loader resolved a plain `./<name>` specifier to that generated artifact before the source — so the second build of a family read the artifact, which carries no `components` and exports `<name>Theme` rather than whatever the source exports. A named import that missed became `extends: undefined`, and `defineTheme` treated an absent base as no base at all. The loader now resolves source extensions first, which is also the resolution the author's TypeScript sees, so the CSS a build emits matches the theme that type-checked.
13
+
14
+ Three things behind it are fixed too, so the failure cannot come back by another route. `defineTheme` **throws** when `extends` is present but is not a theme, naming the likely cause, instead of inheriting nothing — the one behavior change here, and it turns a silent stylesheet into a build error. A theme's `onDark`/`onLight` surfaces and its `__inputTokens` are now inherited like its tokens and components were, so a child no longer reverts its base's inverted-surface customizations to the defaults or loses its `[light, dark]` tuples. And a built theme module now carries the resolved `components` and surfaces alongside its tokens, so extending one — the `./built` subpath every shipped theme exposes — is no longer lossy. `theme build` also stopped hand-picking fields when it re-resolves a plain object theme file, which dropped `extends`, `color` and `syntax` on the way in.
15
+
16
+ An extended theme is flat: everything it inherits is resolved into its own output, and its stylesheet stands alone. Measured on a 14-theme family (one base, 13 palettes extending it): each palette went from 25 custom properties and no component rules to the base's full 175 and 70, with its own colours still winning.
17
+
18
+ #### Contributors
19
+
20
+ Thanks to everyone who contributed to this release:
21
+
22
+ - @cixzhang
23
+
24
+ ---
25
+
3
26
  # 0.4.2
4
27
 
5
28
  #### New Features
@@ -1,13 +1,18 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  /**
4
- * API-contract tests for the font-loading warning in `themeBuild()` (#5015):
4
+ * API-contract tests for the font-loading advisory in `themeBuild()` (#5015):
5
5
  * a theme that names font families it does not load gets one entry per family
6
- * in the `theme.build` receipt's `warnings` array, on BOTH load paths — a raw
6
+ * in the `theme.build` receipt's `notices` array, on BOTH load paths — a raw
7
7
  * typography config (resolved through core's defineTheme) and an
8
8
  * already-resolved theme that sets `--font-family-*` tokens directly. Themes
9
- * that only name generics or known system families warn about nothing, and
10
- * the warning never breaks the API's silence contract (default noopLogger).
9
+ * that only name generics or known system families say nothing, and the
10
+ * advisory never breaks the API's silence contract (default noopLogger).
11
+ *
12
+ * `notices`, not `warnings`: a theme file cannot load a font — that is the
13
+ * app's job by design — so this fires on correct themes and is context, not a
14
+ * defect to fix. Every assertion here also pins it OUT of `warnings`, since
15
+ * the whole point is that a good theme builds warning-free.
11
16
  *
12
17
  * Needs a built core — the `node` project's globalSetup
13
18
  * (vitest.global-setup.node.mjs) builds it once before workers fork.
@@ -45,15 +50,16 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
45
50
  const result = await themeBuild('fonty.mjs', {}, {cwd: tmpDir});
46
51
 
47
52
  expect(result?.type).toBe('theme.build');
48
- const warnings = result?.data.warnings ?? [];
49
- expect(warnings).toEqual(
53
+ const notices = result?.data.notices ?? [];
54
+ expect(notices).toEqual(
50
55
  expect.arrayContaining([
51
56
  expect.stringContaining('Font "Space Grotesk"'),
52
57
  expect.stringContaining('Font "JetBrains Mono"'),
53
58
  ]),
54
59
  );
55
- // Heading inherits body's family; the shared family warns exactly once.
56
- expect(warnings.filter(w => w.includes('Space Grotesk'))).toHaveLength(1);
60
+ // Heading inherits body's family; the shared family is named exactly once.
61
+ expect(notices.filter(w => w.includes('Space Grotesk'))).toHaveLength(1);
62
+ expect(result?.data.warnings).toEqual([]);
57
63
  });
58
64
 
59
65
  it('warns for an already-resolved theme: font-family tokens and component overrides, nothing else', async () => {
@@ -74,13 +80,14 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
74
80
  // Exactly the two named families — a non-family --font-* token must not
75
81
  // produce a bogus "Font \\"1rem\\"" entry, and the components half of the
76
82
  // feature must survive the real themeBuild path, not just the unit helper.
77
- const fontWarnings = (result?.data.warnings ?? []).filter(w =>
83
+ const fontNotices = (result?.data.notices ?? []).filter(w =>
78
84
  w.startsWith('Font "'),
79
85
  );
80
- expect(fontWarnings).toEqual([
86
+ expect(fontNotices).toEqual([
81
87
  expect.stringContaining('Font "Bungee"'),
82
88
  expect.stringContaining('Font "Orbitron"'),
83
89
  ]);
90
+ expect(result?.data.warnings).toEqual([]);
84
91
  });
85
92
 
86
93
  it('warns for a family named only inside a pseudo-class component override (defineTheme path)', async () => {
@@ -102,13 +109,14 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
102
109
 
103
110
  const result = await themeBuild('pseudo.mjs', {}, {cwd: tmpDir});
104
111
 
105
- const fontWarnings = (result?.data.warnings ?? []).filter(w =>
112
+ const fontNotices = (result?.data.notices ?? []).filter(w =>
106
113
  w.startsWith('Font "'),
107
114
  );
108
115
  // Exactly the hidden family — Helvetica/Arial are system fonts.
109
- expect(fontWarnings).toEqual([
116
+ expect(fontNotices).toEqual([
110
117
  expect.stringContaining('Font "Rubik Doodle"'),
111
118
  ]);
119
+ expect(result?.data.warnings).toEqual([]);
112
120
  });
113
121
 
114
122
  it('warns about nothing when every named family is a generic or known system font', async () => {
@@ -126,10 +134,11 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
126
134
  const result = await themeBuild('sys.mjs', {}, {cwd: tmpDir});
127
135
 
128
136
  expect(result?.type).toBe('theme.build');
137
+ expect(result?.data.notices).toEqual([]);
129
138
  expect(result?.data.warnings).toEqual([]);
130
139
  });
131
140
 
132
- it('stays silent under the default noopLogger even when font warnings fire', async () => {
141
+ it('stays silent under the default noopLogger even when font notices fire', async () => {
133
142
  fs.writeFileSync(
134
143
  path.join(tmpDir, 'loud.mjs'),
135
144
  `export default { name: 'loud', tokens: { '--font-family-body': '"Orbitron", sans-serif' } };\n`,
@@ -144,7 +153,7 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
144
153
 
145
154
  try {
146
155
  const result = await themeBuild('loud.mjs', {}, {cwd: tmpDir});
147
- expect(result?.data.warnings).toEqual(
156
+ expect(result?.data.notices).toEqual(
148
157
  expect.arrayContaining([expect.stringContaining('Font "Orbitron"')]),
149
158
  );
150
159
  expect(logSpy).not.toHaveBeenCalled();
@@ -0,0 +1,149 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Direct-API tests for `themeBuild`'s `iconsSpecifier` option (#4620).
5
+ *
6
+ * The CLI surface of `--icons-specifier` is pinned in
7
+ * clients/cli/commands/build-theme.icons-specifier.test.mjs; these tests pin
8
+ * the programmatic surface that watch mode, editor tooling, and build scripts
9
+ * call directly: the option reaches the emitted module, its absence leaves the
10
+ * scraped specifier byte-for-byte alone, and `check` mode compares the
11
+ * specifier-bearing text like any other generated byte — outputs built with a
12
+ * different specifier than the one being checked against are stale, not clean.
13
+ */
14
+
15
+ import {describe, it, expect, beforeEach, afterEach} from 'vitest';
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import * as os from 'node:os';
19
+ import {themeBuild} from './build.mjs';
20
+
21
+ let tmpDir;
22
+ beforeEach(() => {
23
+ tmpDir = fs.mkdtempSync(
24
+ path.join(os.tmpdir(), 'astryx-api-icons-specifier-'),
25
+ );
26
+ });
27
+ afterEach(() => {
28
+ fs.rmSync(tmpDir, {recursive: true, force: true});
29
+ });
30
+
31
+ /**
32
+ * Write a theme source (plus a loadable icons source beside it) under
33
+ * `<tmpDir>/src/`. The registry is a plain object — the emit path only
34
+ * re-exports it, so no React is needed.
35
+ */
36
+ function writeIconTheme({withIcons = true, name = 'icotheme'} = {}) {
37
+ const srcDir = path.join(tmpDir, 'src');
38
+ fs.mkdirSync(srcDir, {recursive: true});
39
+ fs.writeFileSync(
40
+ path.join(srcDir, 'icons.ts'),
41
+ `export const myIcons = { close: 'x' };\n`,
42
+ );
43
+ const iconLines = withIcons ? [`import { myIcons } from './icons';`] : [];
44
+ fs.writeFileSync(
45
+ path.join(srcDir, `${name}.ts`),
46
+ [
47
+ ...iconLines,
48
+ `export default { name: '${name}', tokens: { '--color-bg': '#fff' }${
49
+ withIcons ? ', icons: myIcons' : ''
50
+ } };`,
51
+ '',
52
+ ].join('\n'),
53
+ );
54
+ return `src/${name}.ts`;
55
+ }
56
+
57
+ function builtModule(name = 'icotheme') {
58
+ return fs.readFileSync(path.join(tmpDir, 'dist', `${name}.js`), 'utf8');
59
+ }
60
+
61
+ describe('themeBuild({iconsSpecifier}) — direct API', () => {
62
+ it('emits the declared specifier into the generated module', async () => {
63
+ const file = writeIconTheme();
64
+
65
+ const result = await themeBuild(
66
+ file,
67
+ {out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
68
+ {cwd: tmpDir},
69
+ );
70
+
71
+ expect(result?.type).toBe('theme.build');
72
+ const js = builtModule();
73
+ expect(js).toContain(`import { myIcons } from "./icons.mjs";`);
74
+ // The registry re-export survives the override.
75
+ expect(js).toContain('export { myIcons }');
76
+ });
77
+
78
+ it('emits the scraped specifier unchanged when the option is omitted', async () => {
79
+ const file = writeIconTheme();
80
+
81
+ const result = await themeBuild(
82
+ file,
83
+ {out: 'dist/theme.css'},
84
+ {cwd: tmpDir},
85
+ );
86
+
87
+ expect(result?.type).toBe('theme.build');
88
+ expect(builtModule()).toContain(`import { myIcons } from './icons';`);
89
+ });
90
+
91
+ it('is inert for a theme with no icons field', async () => {
92
+ const file = writeIconTheme({withIcons: false});
93
+
94
+ const result = await themeBuild(
95
+ file,
96
+ {out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
97
+ {cwd: tmpDir},
98
+ );
99
+
100
+ expect(result?.type).toBe('theme.build');
101
+ // The header comment's usage example mentions imports; only a real
102
+ // statement (line-leading `import`) would be a leak.
103
+ const js = builtModule();
104
+ expect(js).not.toMatch(/^import /m);
105
+ expect(js).not.toContain('icons.mjs');
106
+ });
107
+
108
+ it('check mode is clean against outputs built with the same specifier', async () => {
109
+ const file = writeIconTheme();
110
+ await themeBuild(
111
+ file,
112
+ {out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
113
+ {cwd: tmpDir},
114
+ );
115
+
116
+ const result = await themeBuild(
117
+ file,
118
+ {out: 'dist/theme.css', check: true, iconsSpecifier: './icons.mjs'},
119
+ {cwd: tmpDir},
120
+ );
121
+
122
+ expect(result?.type).toBe('theme.build.check');
123
+ expect(result?.data.upToDate).toBe(true);
124
+ expect(result?.data.stale).toEqual([]);
125
+ });
126
+
127
+ it('check mode reports outputs stale when the specifier differs', async () => {
128
+ const file = writeIconTheme();
129
+ await themeBuild(
130
+ file,
131
+ {out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
132
+ {cwd: tmpDir},
133
+ );
134
+
135
+ // Checking without the option regenerates with the scraped './icons' —
136
+ // different bytes than the on-disk module, so the check must flag it.
137
+ const result = await themeBuild(
138
+ file,
139
+ {out: 'dist/theme.css', check: true},
140
+ {cwd: tmpDir},
141
+ );
142
+
143
+ expect(result?.type).toBe('theme.build.check');
144
+ expect(result?.data.upToDate).toBe(false);
145
+ expect(result?.data.stale.some(entry => entry.reason === 'outdated')).toBe(
146
+ true,
147
+ );
148
+ });
149
+ });
@@ -591,6 +591,30 @@ const themeScopeStart = (/** @type {string} */ name) =>
591
591
  `[data-astryx-theme="${name}"]`;
592
592
  const THEME_SCOPE_TO = `[data-astryx-theme]`;
593
593
 
594
+ /**
595
+ * Module extensions the theme loader resolves, source before artifact.
596
+ *
597
+ * `theme build` writes `<name>.js` next to `<name>.ts`, and jiti's default
598
+ * order tries `.js` first — so once a base theme had been built, every sibling
599
+ * theme that `extends` it resolved to that generated artifact instead of the
600
+ * source. The artifact carries no `components` and exports a different name,
601
+ * so the inheritance silently evaporated. Resolving source first is also what
602
+ * the author's TypeScript sees, which is the point: the CSS the build emits
603
+ * matches the theme they type-checked.
604
+ */
605
+ const THEME_MODULE_EXTENSIONS = [
606
+ '.ts',
607
+ '.tsx',
608
+ '.mts',
609
+ '.cts',
610
+ '.mtsx',
611
+ '.ctsx',
612
+ '.mjs',
613
+ '.cjs',
614
+ '.js',
615
+ '.json',
616
+ ];
617
+
594
618
  /**
595
619
  * Import a theme module using jiti and find the defineTheme() result.
596
620
  * Returns the resolved DefinedTheme object.
@@ -601,6 +625,7 @@ async function importThemeModule(filePath) {
601
625
  const jiti = createJiti(import.meta.url, {
602
626
  moduleCache: false,
603
627
  jsx: true,
628
+ extensions: THEME_MODULE_EXTENSIONS,
604
629
  });
605
630
 
606
631
  const mod = await jiti.import(filePath, {default: true});
@@ -733,6 +758,13 @@ function extractIconInfo(filePath) {
733
758
  * Includes the theme name, marker, and re-exports the icon registry.
734
759
  * All styling is in the CSS file.
735
760
  *
761
+ * The module carries the theme's resolved `components` and on-media surfaces
762
+ * alongside its tokens. They are not needed to apply the theme — the CSS holds
763
+ * all of that — but a built theme is a legitimate base for `extends` (the
764
+ * shipped themes expose one as their `./built` subpath), and a base that
765
+ * carries only tokens makes its children silently lose every component
766
+ * override it had.
767
+ *
736
768
  * The icon registry is imported rather than inlined because it holds React
737
769
  * elements, which cannot be serialized. `extractIconInfo` lifts the specifier
738
770
  * out of the TypeScript source, where an extensionless `./icons` is resolved by
@@ -774,6 +806,27 @@ function generateBuiltModule(themeDef, iconInfo, iconsSpecifier) {
774
806
  .map((line, i) => (i === 0 ? line : ' ' + line))
775
807
  .join('\n');
776
808
 
809
+ /**
810
+ * Serialize a resolved theme field as an indented object literal, or '' when
811
+ * there is nothing to emit.
812
+ * @param {string} field
813
+ * @param {unknown} value
814
+ * @returns {string}
815
+ */
816
+ const serializeField = (field, value) => {
817
+ if (!value || Object.keys(value).length === 0) return '';
818
+ const body = JSON.stringify(value, null, 2)
819
+ .split('\n')
820
+ .map((line, i) => (i === 0 ? line : ' ' + line))
821
+ .join('\n');
822
+ return ` ${field}: ${body},\n`;
823
+ };
824
+
825
+ const inheritableFields =
826
+ serializeField('components', themeDef.components) +
827
+ serializeField('__onDark', themeDef.__onDark) +
828
+ serializeField('__onLight', themeDef.__onLight);
829
+
777
830
  return `${iconImport}/**
778
831
  * ${themeDef.name} theme — built by \`${getCliInvocation()} theme build\`
779
832
  * Import the CSS file alongside this module:
@@ -785,7 +838,7 @@ export const ${toIdentifier(themeDef.name)}Theme = {
785
838
  name: '${themeDef.name}',
786
839
  __built: true,
787
840
  tokens: ${tokensStr},
788
- ${iconsField}
841
+ ${inheritableFields}${iconsField}
789
842
  };
790
843
  ${iconReExport}`;
791
844
  }
@@ -1061,6 +1114,8 @@ export async function themeBuild(
1061
1114
  // Validate component overrides
1062
1115
  const warnings = await validateComponentOverrides(themeDef);
1063
1116
  const warningMessages = [];
1117
+ /** Advisories about a correct theme — see the `notices` note on the receipt. */
1118
+ const noticeMessages = [];
1064
1119
  for (const w of warnings) {
1065
1120
  warningMessages.push(w);
1066
1121
  logger.warn(` ⚠ ${w}`);
@@ -1099,20 +1154,29 @@ export async function themeBuild(
1099
1154
  let css;
1100
1155
  let resolvedTheme;
1101
1156
  {
1102
- // jiti returns an already-resolved theme; legacy eval returns raw input.
1103
- const isAlreadyResolved =
1104
- !themeDef.typography && !themeDef.motion && !themeDef.radius;
1105
- if (isAlreadyResolved) {
1106
- resolvedTheme = themeDef;
1157
+ // jiti returns an already-resolved theme; a plain object literal (or the
1158
+ // legacy eval path) returns raw defineTheme input, which still has to go
1159
+ // through the resolver. Detect that by the input-only fields — a resolved
1160
+ // theme has none of them — and hand the WHOLE object over: picking fields
1161
+ // by name is how `extends` (and `color`, and `syntax`) used to be dropped
1162
+ // on the way in.
1163
+ const INPUT_ONLY_FIELDS = [
1164
+ 'extends',
1165
+ 'typography',
1166
+ 'motion',
1167
+ 'radius',
1168
+ 'color',
1169
+ 'syntax',
1170
+ 'onDark',
1171
+ 'onLight',
1172
+ ];
1173
+ const needsResolution = INPUT_ONLY_FIELDS.some(
1174
+ field => themeDef[field] !== undefined,
1175
+ );
1176
+ if (needsResolution) {
1177
+ resolvedTheme = _defineTheme({...themeDef});
1107
1178
  } else {
1108
- resolvedTheme = _defineTheme({
1109
- name: themeDef.name,
1110
- typography: themeDef.typography,
1111
- motion: themeDef.motion,
1112
- radius: themeDef.radius,
1113
- tokens: themeDef.tokens,
1114
- components: themeDef.components,
1115
- });
1179
+ resolvedTheme = themeDef;
1116
1180
  }
1117
1181
  const scopeSelector = themeScopeStart(themeDef.name);
1118
1182
  const scopeTo = THEME_SCOPE_TO;
@@ -1350,11 +1414,18 @@ Or with a <link> tag:
1350
1414
  // Fonts the theme names but nothing loads (#5015). Resolved tokens and
1351
1415
  // component overrides carry the final font-family values on both load
1352
1416
  // paths, so this sees jiti-resolved and legacy themes alike.
1417
+ //
1418
+ // A NOTICE, not a warning: naming a font a theme file cannot load is how
1419
+ // the API is meant to be used — Astryx sets `--font-family-*` and loading
1420
+ // is the app's job, which no theme can do for it. So this fires on any
1421
+ // theme with a webfont, including a perfect one, and as a warning it made
1422
+ // every such build read as defective (it also put the shipped template
1423
+ // permanently in violation of its own "compiles with no warnings" guard).
1353
1424
  const unloadedFonts = collectUnloadedFonts(resolvedTheme);
1354
1425
  for (const family of unloadedFonts) {
1355
1426
  const msg = `Font "${family}" is named by this theme but not loaded — add a <link> or @font-face in your app (recipe: astryx docs typography)`;
1356
- warningMessages.push(msg);
1357
- logger.warn(` ${msg}`);
1427
+ noticeMessages.push(msg);
1428
+ logger.log(` note: ${msg}`);
1358
1429
  }
1359
1430
  if (unloadedFonts.length > 0) {
1360
1431
  logger.log(formatFontLoadingHelp(themeDef.name, unloadedFonts));
@@ -1376,6 +1447,7 @@ Or with a <link> tag:
1376
1447
  : {}),
1377
1448
  },
1378
1449
  warnings: warningMessages,
1450
+ notices: noticeMessages,
1379
1451
  },
1380
1452
  };
1381
1453
  }
@@ -299,11 +299,10 @@ describe('themeBuild() — component override validation', () => {
299
299
  describe('themeBuild() — the shipped theme template', () => {
300
300
  // `assets/theme.template.ts` is what `astryx theme template` puts in a
301
301
  // consumer's project. It is the one theme file we hand out, so it has to
302
- // compile as shipped — and cleanly apart from the font warnings it earns on
303
- // purpose: a template that greets its first reader with warnings teaches them
304
- // to ignore warnings. The claims its comments make are checked separately by
305
- // scripts/check-theme-template.test.mjs.
306
- it('compiles as shipped, warning only about the fonts it deliberately names', async () => {
302
+ // compile as shipped — and cleanly: a template that greets its first reader
303
+ // with warnings teaches them to ignore warnings. The claims its comments make
304
+ // are checked separately by scripts/check-theme-template.test.mjs.
305
+ it('compiles as shipped, with no warnings', async () => {
307
306
  const src = path.resolve(
308
307
  import.meta.dirname,
309
308
  '../../../assets/theme.template.ts',
@@ -312,17 +311,189 @@ describe('themeBuild() — the shipped theme template', () => {
312
311
 
313
312
  const result = await themeBuild('theme.template.ts', {}, {cwd: tmpDir});
314
313
 
315
- // The template names Inter and Geist Mono to teach "SHIP THE FONTS YOU
316
- // NAME", and loads neither so the unloaded-font warning firing here is
317
- // the lesson landing, not a defect. Any OTHER warning still fails.
318
- const unexpected = (result?.data.warnings ?? []).filter(
319
- w => !/^Font "(Inter|Geist Mono)" is named by this theme but not loaded/.test(w),
320
- );
321
- expect(unexpected).toEqual([]);
322
- expect(result?.data.warnings).toHaveLength(2);
314
+ expect(result?.data.warnings).toEqual([]);
315
+ // It DOES name Inter and Geist Mono without loading them, to teach "SHIP
316
+ // THE FONTS YOU NAME" — advisories about a correct file, which is why they
317
+ // are notices. Asserted here so moving them out of `warnings` cannot
318
+ // quietly become dropping them.
319
+ expect(result?.data.notices).toHaveLength(2);
323
320
  expect(fs.existsSync(path.join(tmpDir, 'my-theme.css'))).toBe(true);
324
321
  // The template teaches custom variants; the augmentation it promises the
325
322
  // reader has to actually be generated.
326
- expect(fs.existsSync(path.join(tmpDir, 'my-theme.variants.d.ts'))).toBe(true);
323
+ expect(fs.existsSync(path.join(tmpDir, 'my-theme.variants.d.ts'))).toBe(
324
+ true,
325
+ );
326
+ });
327
+ });
328
+
329
+ describe('themeBuild() — extends', () => {
330
+ // These fixtures `import {defineTheme} from '@astryxdesign/core/theme'` the
331
+ // way a real theme file does, so they have to sit somewhere that specifier
332
+ // resolves — an OS temp dir has no node_modules above it.
333
+ let extDir;
334
+ beforeEach(() => {
335
+ extDir = fs.mkdtempSync(
336
+ path.join(path.resolve(import.meta.dirname, '../../..'), '.tmp-extends-'),
337
+ );
338
+ });
339
+ afterEach(() => {
340
+ fs.rmSync(extDir, {recursive: true, force: true});
341
+ });
342
+
343
+ /**
344
+ * Every `prop: value` a generated stylesheet actually applies. Header
345
+ * comments and scope wrappers are ignored — two themes never share those.
346
+ */
347
+ function declarations(css) {
348
+ return new Set(
349
+ css
350
+ .split('\n')
351
+ .map(l => l.trim())
352
+ .filter(l => /^[-a-z][^{}]*:.+;$/.test(l)),
353
+ );
354
+ }
355
+ /** Every component rule a stylesheet opens, e.g. `.astryx-switch {`. */
356
+ function selectors(css) {
357
+ return new Set(
358
+ css
359
+ .split('\n')
360
+ .map(l => l.trim())
361
+ .filter(l => l.endsWith('{') && l.startsWith('.')),
362
+ );
363
+ }
364
+
365
+ /** A base theme with geometry, elevation and a component override. */
366
+ const BASE_SOURCE = `export const brandTheme = {
367
+ name: 'ext-base',
368
+ tokens: {
369
+ '--radius-element': '6px',
370
+ '--shadow-low': '0 1px 3px rgb(0 0 0 / 0.1)',
371
+ '--color-border-emphasized': '#D4D4D4',
372
+ },
373
+ components: {
374
+ switch: {base: {backgroundColor: 'var(--color-border-emphasized)'}},
375
+ },
376
+ };\n`;
377
+
378
+ /**
379
+ * The child names its base with a plain relative specifier, exactly as a
380
+ * generated palette does. `theme build` writes `ext-base.js` next to
381
+ * `ext-base.mjs`, so `./ext-base` is ambiguous — and the artifact, which
382
+ * exports `extBaseTheme` rather than `brandTheme`, is the wrong answer.
383
+ */
384
+ const CHILD_SOURCE = `import {defineTheme} from '@astryxdesign/core/theme';
385
+ import {brandTheme} from './ext-base';
386
+ export const paletteTheme = defineTheme({
387
+ name: 'ext-child',
388
+ extends: brandTheme,
389
+ tokens: {'--color-accent': 'hsl(220 88% 72%)'},
390
+ });\n`;
391
+
392
+ it('emits every declaration its base emits (the child stylesheet is self-contained)', async () => {
393
+ fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
394
+ fs.writeFileSync(path.join(extDir, 'ext-child.mjs'), CHILD_SOURCE);
395
+
396
+ // Build the base FIRST, as any real project does — that write is what
397
+ // used to poison the child's build.
398
+ await themeBuild('ext-base.mjs', {}, {cwd: extDir});
399
+ await themeBuild('ext-child.mjs', {}, {cwd: extDir});
400
+
401
+ const baseCss = fs.readFileSync(path.join(extDir, 'ext-base.css'), 'utf8');
402
+ const childCss = fs.readFileSync(
403
+ path.join(extDir, 'ext-child.css'),
404
+ 'utf8',
405
+ );
406
+
407
+ const childDecls = declarations(childCss);
408
+ expect([...declarations(baseCss)].filter(d => !childDecls.has(d))).toEqual(
409
+ [],
410
+ );
411
+
412
+ const childSelectors = selectors(childCss);
413
+ expect([...selectors(baseCss)].filter(s => !childSelectors.has(s))).toEqual(
414
+ [],
415
+ );
416
+
417
+ // …and the child's own override still wins.
418
+ expect(childCss).toContain('--color-accent: hsl(220 88% 72%);');
419
+ });
420
+
421
+ it('resolves the base from its source, not from the generated sibling artifact', async () => {
422
+ fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
423
+ fs.writeFileSync(path.join(extDir, 'ext-child.mjs'), CHILD_SOURCE);
424
+
425
+ await themeBuild('ext-base.mjs', {}, {cwd: extDir});
426
+ const result = await themeBuild('ext-child.mjs', {}, {cwd: extDir});
427
+
428
+ expect(result?.data.componentCount).toBe(1);
429
+ expect(result?.data.tokenCount).toBe(4);
430
+ });
431
+
432
+ it('inherits component overrides when the base IS a built theme module', async () => {
433
+ fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
434
+ await themeBuild('ext-base.mjs', {}, {cwd: extDir});
435
+
436
+ // Extending a package's pre-built theme module (e.g. the `./built`
437
+ // subpath the shipped themes expose) must not silently drop its
438
+ // component overrides.
439
+ fs.writeFileSync(
440
+ path.join(extDir, 'ext-built-child.mjs'),
441
+ `import {defineTheme} from '@astryxdesign/core/theme';
442
+ import {extBaseTheme} from './ext-base.js';
443
+ export const builtChildTheme = defineTheme({
444
+ name: 'ext-built-child',
445
+ extends: extBaseTheme,
446
+ });\n`,
447
+ );
448
+
449
+ await themeBuild('ext-built-child.mjs', {}, {cwd: extDir});
450
+ const css = fs.readFileSync(
451
+ path.join(extDir, 'ext-built-child.css'),
452
+ 'utf8',
453
+ );
454
+
455
+ expect(css).toContain('.astryx-switch {');
456
+ expect(css).toContain('--radius-element: 6px;');
457
+ });
458
+
459
+ it('resolves extends on a plain object theme file (no defineTheme call)', async () => {
460
+ fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
461
+ fs.writeFileSync(
462
+ path.join(extDir, 'ext-plain.mjs'),
463
+ `import {brandTheme} from './ext-base.mjs';
464
+ export default {
465
+ name: 'ext-plain',
466
+ extends: brandTheme,
467
+ tokens: {'--color-accent': '#ff0000'},
468
+ };\n`,
469
+ );
470
+
471
+ await themeBuild('ext-base.mjs', {}, {cwd: extDir});
472
+ await themeBuild('ext-plain.mjs', {}, {cwd: extDir});
473
+
474
+ const css = fs.readFileSync(path.join(extDir, 'ext-plain.css'), 'utf8');
475
+ expect(css).toContain('--radius-element: 6px;');
476
+ expect(css).toContain('.astryx-switch {');
477
+ });
478
+
479
+ it('fails loudly when the base import resolved to nothing', async () => {
480
+ fs.writeFileSync(
481
+ path.join(extDir, 'ext-broken.mjs'),
482
+ `import {defineTheme} from '@astryxdesign/core/theme';
483
+ import {notAThing} from './ext-missing.mjs';
484
+ export const brokenTheme = defineTheme({
485
+ name: 'ext-broken',
486
+ extends: notAThing,
487
+ tokens: {'--color-accent': '#ff0000'},
488
+ });\n`,
489
+ );
490
+ fs.writeFileSync(
491
+ path.join(extDir, 'ext-missing.mjs'),
492
+ `export const somethingElse = 1;\n`,
493
+ );
494
+
495
+ await expect(
496
+ themeBuild('ext-broken.mjs', {}, {cwd: extDir}),
497
+ ).rejects.toThrow(/extends/);
327
498
  });
328
499
  });