@astryxdesign/cli 0.4.1 → 0.4.2-canary.01592f7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -0
  3. package/api/init/init.doc.mjs +2 -2
  4. package/api/init/init.test.mjs +19 -2
  5. package/api/init/init.type.d.mts +9 -1
  6. package/api/init/init.type.mjs +3 -1
  7. package/api/init/run/run.mjs +36 -6
  8. package/api/theme/add/add.mjs +2 -13
  9. package/api/theme/build/build.font-warning.test.mjs +161 -0
  10. package/api/theme/build/build.mjs +95 -26
  11. package/api/theme/build/build.test.mjs +227 -0
  12. package/api/theme/build/font-warning.d.mts +26 -0
  13. package/api/theme/build/font-warning.mjs +214 -0
  14. package/api/theme/build/font-warning.test.mjs +242 -0
  15. package/api/theme/template/template.d.mts +21 -0
  16. package/api/theme/template/template.mjs +69 -0
  17. package/api/theme/template/template.test.mjs +82 -0
  18. package/api/theme/theme.d.mts +1 -0
  19. package/api/theme/theme.mjs +1 -0
  20. package/api/theme/theme.type.d.mts +13 -0
  21. package/api/theme/theme.type.mjs +11 -1
  22. package/api/theme/themeTemplate.doc.d.mts +11 -0
  23. package/api/theme/themeTemplate.doc.mjs +64 -0
  24. package/assets/docs/getting-started.doc.mjs +11 -1
  25. package/assets/docs/theme.doc.dense.mjs +2 -2
  26. package/assets/docs/theme.doc.mjs +26 -10
  27. package/assets/docs/theme.doc.zh.mjs +1 -1
  28. package/assets/docs/typography.doc.mjs +38 -0
  29. package/assets/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleCustomContent.doc.mjs +13 -0
  30. package/assets/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleCustomContent.tsx +55 -0
  31. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs +20 -0
  32. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.tsx +87 -0
  33. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.doc.mjs +15 -0
  34. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.tsx +199 -0
  35. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.doc.mjs +14 -0
  36. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.tsx +188 -0
  37. package/assets/templates/pages/ai-chat/page.tsx +18 -13
  38. package/assets/templates/themes/butter/butterTheme.ts +4 -1
  39. package/assets/templates/themes/chocolate/chocolateTheme.ts +4 -1
  40. package/assets/templates/themes/gothic/gothicTheme.ts +4 -1
  41. package/assets/templates/themes/stone/stoneTheme.ts +4 -1
  42. package/assets/theme.template.ts +322 -0
  43. package/authoring/doctypes/base/type.ts +9 -1
  44. package/authoring/doctypes/component/component.doc.mjs +1 -1
  45. package/clients/cli/commands/build-theme.font-warning.test.mjs +138 -0
  46. package/clients/cli/commands/build-theme.mjs +50 -0
  47. package/clients/cli/commands/init.behavior.test.mjs +13 -2
  48. package/clients/cli/commands/theme-template.behavior.test.mjs +85 -0
  49. package/clients/cli/commands/theme-template.doc.mjs +42 -0
  50. package/clients/cli/commands/theme.doc.mjs +2 -2
  51. package/clients/cli/index.mjs +1 -0
  52. package/clients/cli/lib/manifest.mjs +2 -0
  53. package/foundation/agent-docs/agent-docs.mjs +1 -1
  54. package/foundation/fs/path-safety.mjs +2 -2
  55. package/foundation/fs/path-safety.test.mjs +7 -0
  56. package/foundation/response/response-types.doc.mjs +5 -0
  57. package/foundation/text/copyright-header.d.mts +11 -0
  58. package/foundation/text/copyright-header.mjs +35 -0
  59. package/foundation/text/copyright-header.test.mjs +58 -0
  60. package/package.json +9 -9
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # @xds/cli
2
2
 
3
+ # 0.4.2
4
+
5
+ #### New Features
6
+
7
+ - `astryx theme build` warns when a theme names fonts it does not load. The resolved `--font-family-*` tokens and component-override `fontFamily` values are checked against CSS generics and known system families; anything else gets one warning per family in the receipt and, after the install instructions, the `<link>`/`@font-face` snippet to add. `astryx docs typography` gains a Loading Custom Fonts section (Google Fonts and self-hosted recipes, `font-display: swap`, real fallback stacks), and the theme docs' production-build section points at it (#5015).
8
+ - `astryx theme template` writes an annotated theme template into your project (#5048).
9
+ New sibling of `theme add`: where `add` starts you from a theme we ship, `template` starts you from a blank annotated one. `astryx init --features theme` calls the same leaf, so project setup writes it too — it previously printed a one-line hint and wrote nothing, which is the weakest form of the help a theme author needs, since the first problem is not knowing the command but not knowing what the theme surface contains. The file is `theme.template.ts`: every `defineTheme` field with a note on when to reach for it, the token families, the component override syntax, and the consumption steps (providing the theme, loading the fonts you name, building for SSR), each section naming the CLI command that prints its authoritative reference. An existing file is never clobbered.
10
+
11
+ This came out of a vibe test (#5047): agents given an annotated template reached twice as far into the theme surface as agents given only the docs (17 component targets vs 8, and the only arm to use interaction states, custom variants and `onDark`), and shipped a third of the contrast defects.
12
+
13
+ A template that lies is worse than no template, so its claims are machine-checked against live sources rather than trusted: `scripts/check-theme-template.test.mjs` fails when a `defineTheme` field is added and left undocumented, when a token family is missing from the inventory, when a CSS variable or component key it names does not exist, when it cites a docs topic that does not, or when a theme source drops its SYNC reference. `theme build` compiles it warning-free in CI, and the CLI typecheck now covers it.
14
+
15
+ #### Fixes
16
+
17
+ - Heading's `type` is a documented theming target, and the docs stop teaching a CSS variable that does not exist (#5016).
18
+ `Heading` reflects `type` as a theme selector — `typography.scale` generates `heading: {'type:display-1' …}` rules for it — but `theming.targets` listed only `level` and `color`, so `astryx theme build` warned `Unknown prop "type" on component "heading"` on every theme that sets a type scale, including the shipped `neutralTheme`. The drift guard missed it twice over: it read a conditional spread (`{level, color, ...(type && {type})}`) as an unknown bag, and it only checked a component against a doc file in its own directory, so `Heading/` — documented from `Text/Text.doc.mjs` — was never checked at all. Both are fixed, which brings three more previously unchecked directories under the guard.
19
+
20
+ Separately, the theme docs' component-override example set `--button-press-scale`, which no component defines: copying it produces CSS that silently never applies. It now sets a real public var, and the example no longer declares the same `button` key twice.
21
+
22
+ - Two guards left failing on `main` by their own landings, so every PR since has been red through no fault of its own. #4963 gave Thumbnail's remove button a coarse-pointer hit-area var and did not document it, which the derived-var guard reads as an undocumented private var; the var is an `inset` on a `::after` overlay, so it is documented as private and listed alongside the other vars no standard CSS property maps onto. #5026 moved `borderDefaults` into `CoreTokenName` — the landing the theme-template guard was explicitly waiting for (its comment says "when #5017 lands, this guard starts requiring the template to cover it") — so the template's token inventory now names `--border-width`.
23
+
24
+ #### Documentation
25
+
26
+ - MobileNavToggle preview simulates a mobile AppShell instead of an empty stage: new playground.appShellMobile for components that render nothing without AppShell mobile context (#4983)
27
+
28
+ #### Contributors
29
+
30
+ Thanks to everyone who contributed to this release:
31
+
32
+ - @AKnassa
33
+ - @cixzhang
34
+
35
+ ---
36
+
3
37
  # 0.4.1
4
38
 
5
39
  #### Fixes
package/README.md CHANGED
@@ -416,6 +416,7 @@ Every response has a `type` discriminant. The full set is below (generated from
416
416
  | `theme.build.check` | The --check receipt: theme name, an upToDate flag, the stale outputs (each {path, reason: missing \| outdated}), and the full list of checked paths. Writes nothing. |
417
417
  | `theme.list` | Every bundled theme as a ThemeListEntry[]: each with slug, displayName, description, and a maintained flag. |
418
418
  | `theme.add` | A scaffold receipt: resolved slug, displayName, maintained flag, outputDir (relative to cwd), the theme entry file, its exportName, and the files written. |
419
+ | `theme.template` | A write receipt for the annotated theme template: the path (relative to cwd), whether it was written, and the reason it was not — `exists` when a file was already there, which is a success. |
419
420
  | `upgrade.list` | Every available codemod, oldest→newest, as {name, title, version, optional}; returned for --list without running anything. |
420
421
  | `upgrade.status` | A short-circuit outcome with no codemods run (up_to_date, no_codemods, or config_fixable), each carrying the agent-docs summary. |
421
422
  | `upgrade.run` | The run receipt: from/to versions, codemod count, integrations processed, the agent-docs summary, and (apply mode) filesChanged, transformsApplied, and per-codemod errors. |
@@ -30,7 +30,7 @@ export const doc = {
30
30
  name: 'options.features',
31
31
  type: 'string',
32
32
  description:
33
- 'Comma-separated features to install: agents, theme, template.',
33
+ 'Comma-separated features to install: agents (agent docs), theme (writes the annotated theme.template.ts), template (page-template guidance).',
34
34
  },
35
35
  {
36
36
  name: 'options.all',
@@ -64,7 +64,7 @@ export const doc = {
64
64
  {
65
65
  type: 'init.run',
66
66
  description:
67
- 'The install receipt: the `mode` (`default` | `features`), the features run, agent-doc files written, any soft docsError, whether theme guidance was emitted, the template outcome (`workflow` | `created` | `skipped`) plus its path, and whether the next-steps were emitted.',
67
+ 'The install receipt: the `mode` (`default` | `features`), the features run, agent-doc files written, any soft docsError, the theme-template outcome (`created` | `skipped` | `failed`) plus its path, the page-template outcome (`workflow` | `created` | `skipped`) plus its path, and whether the next-steps were emitted.',
68
68
  },
69
69
  {
70
70
  type: 'init.remove',
@@ -57,13 +57,30 @@ describe('init() — receipts + side effects', () => {
57
57
  expect(res.type === 'init.run' && res.data.docsWritten).toContain('AGENTS.md');
58
58
  });
59
59
 
60
- it('--features theme emits guidance, writes no files, and flags theme', async () => {
60
+ it('--features theme writes the annotated template and reports it on the receipt', async () => {
61
61
  const res = await init({features: 'theme'}, {cwd: tmpDir});
62
62
  expect(res.type).toBe('init.run');
63
63
  if (res.type !== 'init.run') return;
64
64
  expect(res.data.theme).toBe(true);
65
+ expect(res.data.themeTemplate).toBe('created');
66
+ expect(res.data.themeTemplatePath).toBe('theme.template.ts');
65
67
  expect(res.data.docsWritten).toEqual([]);
66
- expect(fs.readdirSync(tmpDir)).toEqual([]);
68
+ expect(fs.readdirSync(tmpDir)).toEqual(['theme.template.ts']);
69
+ // The consumer's copy is their file: it must not carry our repo header,
70
+ // which their own lint would flag.
71
+ const written = fs.readFileSync(path.join(tmpDir, 'theme.template.ts'), 'utf-8');
72
+ expect(written).not.toMatch(/Copyright \(c\) Meta Platforms/);
73
+ expect(written.startsWith('/**')).toBe(true);
74
+ });
75
+
76
+ it('--features theme reports `skipped` rather than overwriting an existing template', async () => {
77
+ fs.writeFileSync(path.join(tmpDir, 'theme.template.ts'), '// mine\n');
78
+ const res = await init({features: 'theme'}, {cwd: tmpDir});
79
+ expect(res.type).toBe('init.run');
80
+ if (res.type !== 'init.run') return;
81
+ expect(res.data.themeTemplate).toBe('skipped');
82
+ expect(res.data.themeTemplatePath).toBe(null);
83
+ expect(fs.readFileSync(path.join(tmpDir, 'theme.template.ts'), 'utf-8')).toBe('// mine\n');
67
84
  });
68
85
 
69
86
  it('--features template returns the workflow (or skipped) outcome, no crash', async () => {
@@ -22,9 +22,17 @@ export type InitRunData = {
22
22
  message?: string;
23
23
  } | null;
24
24
  /**
25
- * Whether theme guidance was emitted.
25
+ * Whether the theme feature ran.
26
26
  */
27
27
  theme: boolean;
28
+ /**
29
+ * Outcome of writing the annotated theme template (`skipped` = a file was already there).
30
+ */
31
+ themeTemplate: "created" | "skipped" | "failed" | null;
32
+ /**
33
+ * Relative output path when `themeTemplate === 'created'`.
34
+ */
35
+ themeTemplatePath: string | null;
28
36
  /**
29
37
  * Template outcome (`workflow` is the CLI default; `created`/`skipped` are programmatic).
30
38
  */
@@ -12,7 +12,9 @@
12
12
  * @property {string[]} features Features that were run, in order.
13
13
  * @property {string[]} docsWritten Agent-doc files written (empty if agents weren't run or install failed).
14
14
  * @property {{kind: 'path-safety' | 'install-failed', message?: string} | null} docsError Soft agent-docs failure, if any. `path-safety` also implies a non-zero exit.
15
- * @property {boolean} theme Whether theme guidance was emitted.
15
+ * @property {boolean} theme Whether the theme feature ran.
16
+ * @property {'created' | 'skipped' | 'failed' | null} themeTemplate Outcome of writing the annotated theme template (`skipped` = a file was already there).
17
+ * @property {string | null} themeTemplatePath Relative output path when `themeTemplate === 'created'`.
16
18
  * @property {'workflow' | 'created' | 'skipped' | null} template Template outcome (`workflow` is the CLI default; `created`/`skipped` are programmatic).
17
19
  * @property {string | null} templatePath Relative output path when `template === 'created'`.
18
20
  * @property {boolean} nextSteps Whether the getting-started "Next steps" were emitted (default mode).
@@ -20,6 +20,7 @@ import {CLI_ROOT} from '../../../foundation/fs/paths.mjs';
20
20
  import {PathSafetyError} from '../../../foundation/fs/path-safety.mjs';
21
21
  import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
22
22
  import {installAgentDocs} from '../../../foundation/agent-docs/agent-docs.mjs';
23
+ import {themeTemplate} from '../../theme/template/template.mjs';
23
24
  import {listTemplates} from '../../template/template.mjs';
24
25
  import {AstryxError} from '../../error.mjs';
25
26
  import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
@@ -106,6 +107,36 @@ function applyAgents(cwd, options, invocation, data) {
106
107
  }
107
108
  }
108
109
 
110
+ /**
111
+ * Write the annotated theme template, via the same leaf `astryx theme template`
112
+ * uses — init is a convenience wrapper over the theme command, not a second
113
+ * implementation of it.
114
+ *
115
+ * @param {string} cwd
116
+ * @param {string} invocation
117
+ * @param {import('../init.type.mjs').InitRunData} data
118
+ */
119
+ function applyTheme(cwd, invocation, data) {
120
+ data.theme = true;
121
+ try {
122
+ const {path: written, written: didWrite} = themeTemplate({cwd}).data;
123
+ data.themeTemplate = didWrite ? 'created' : 'skipped';
124
+ data.themeTemplatePath = didWrite ? written : null;
125
+ logger.log(
126
+ didWrite
127
+ ? `✓ Theme template written → ${written}`
128
+ : `• ${written} already exists — left as is.`,
129
+ );
130
+ } catch {
131
+ // Soft failure, like agent docs: the guidance below is still useful.
132
+ data.themeTemplate = 'failed';
133
+ logger.error('Could not write the theme template.');
134
+ }
135
+ logger.log(
136
+ ` Copy it to your theme file and edit, or run \`${invocation} theme add <slug>\` to start from a shipped theme (\`${invocation} theme list\` to browse).`,
137
+ );
138
+ }
139
+
109
140
  /**
110
141
  * Emit the template guidance, or (programmatic-only) scaffold a named template.
111
142
  * The CLI never passes `templateName`, so from the CLI this always emits the
@@ -210,18 +241,15 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
210
241
  docsWritten: [],
211
242
  docsError: null,
212
243
  theme: false,
244
+ themeTemplate: null,
245
+ themeTemplatePath: null,
213
246
  template: null,
214
247
  templatePath: null,
215
248
  nextSteps: false,
216
249
  };
217
250
  for (const feature of features) {
218
251
  if (feature === 'agents') applyAgents(cwd, options, invocation, data);
219
- if (feature === 'theme') {
220
- logger.log(
221
- `✓ For a custom theme, run \`${invocation} theme\` (browse) or \`${invocation} theme add <slug>\` (scaffold).`,
222
- );
223
- data.theme = true;
224
- }
252
+ if (feature === 'theme') applyTheme(cwd, invocation, data);
225
253
  if (feature === 'template') {
226
254
  applyTemplate(cwd, {templateName: options.templateName}, invocation, data);
227
255
  }
@@ -238,6 +266,8 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
238
266
  docsWritten: [],
239
267
  docsError: null,
240
268
  theme: false,
269
+ themeTemplate: null,
270
+ themeTemplatePath: null,
241
271
  template: null,
242
272
  templatePath: null,
243
273
  nextSteps: true,
@@ -13,19 +13,8 @@ import {assertWithin, PathSafetyError} from '../../../foundation/fs/path-safety.
13
13
  import {AstryxError} from '../../error.mjs';
14
14
  import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
15
15
  import {THEMES_DIR, listThemes, findTheme} from '../_adapter.mjs';
16
-
17
- // Stripped from scaffolded files so the consumer's copy doesn't carry our
18
- // repo boilerplate (mirrors the docsite). Preserves a leading BOM/shebang.
19
- const META_COPYRIGHT_HEADER_RE =
20
- /^(\uFEFF?(?:#![^\r\n]*(?:\r?\n))?)\/\/ Copyright \(c\) Meta Platforms, Inc\. and affiliates\.\r?\n(?:\r?\n)*/;
21
-
22
- /**
23
- * @param {string} source
24
- * @returns {string}
25
- */
26
- function stripCopyrightHeader(source) {
27
- return source.replace(META_COPYRIGHT_HEADER_RE, '$1');
28
- }
16
+ // Scaffolded files must not carry our repo boilerplate into a consumer's tree.
17
+ import {stripCopyrightHeader} from '../../../foundation/text/copyright-header.mjs';
29
18
 
30
19
  /**
31
20
  * @param {string} slug
@@ -0,0 +1,161 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * API-contract tests for the font-loading warning in `themeBuild()` (#5015):
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
7
+ * typography config (resolved through core's defineTheme) and an
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).
11
+ *
12
+ * Needs a built core — the `node` project's globalSetup
13
+ * (vitest.global-setup.node.mjs) builds it once before workers fork.
14
+ */
15
+
16
+ import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import * as os from 'node:os';
20
+ import {themeBuild} from './build.mjs';
21
+
22
+ vi.setConfig({testTimeout: 30000});
23
+
24
+ let tmpDir;
25
+ beforeEach(() => {
26
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-theme-fonts-api-'));
27
+ });
28
+ afterEach(() => {
29
+ fs.rmSync(tmpDir, {recursive: true, force: true});
30
+ });
31
+
32
+ describe('themeBuild() — font-loading warnings in the receipt', () => {
33
+ it('warns once per unloaded family for a typography config (heading inherits body without duplicating)', async () => {
34
+ fs.writeFileSync(
35
+ path.join(tmpDir, 'fonty.mjs'),
36
+ `export default {
37
+ name: 'fonty',
38
+ typography: {
39
+ body: {family: 'Space Grotesk', fallbacks: 'Arial, sans-serif'},
40
+ code: {family: 'JetBrains Mono'},
41
+ },
42
+ };\n`,
43
+ );
44
+
45
+ const result = await themeBuild('fonty.mjs', {}, {cwd: tmpDir});
46
+
47
+ expect(result?.type).toBe('theme.build');
48
+ const warnings = result?.data.warnings ?? [];
49
+ expect(warnings).toEqual(
50
+ expect.arrayContaining([
51
+ expect.stringContaining('Font "Space Grotesk"'),
52
+ expect.stringContaining('Font "JetBrains Mono"'),
53
+ ]),
54
+ );
55
+ // Heading inherits body's family; the shared family warns exactly once.
56
+ expect(warnings.filter(w => w.includes('Space Grotesk'))).toHaveLength(1);
57
+ });
58
+
59
+ it('warns for an already-resolved theme: font-family tokens and component overrides, nothing else', async () => {
60
+ fs.writeFileSync(
61
+ path.join(tmpDir, 'raw.mjs'),
62
+ `export default {
63
+ name: 'raw',
64
+ tokens: {
65
+ '--font-family-body': '"Bungee", cursive',
66
+ '--font-size-base': '1rem',
67
+ },
68
+ components: {button: {base: {fontFamily: 'Orbitron'}}},
69
+ };\n`,
70
+ );
71
+
72
+ const result = await themeBuild('raw.mjs', {}, {cwd: tmpDir});
73
+
74
+ // Exactly the two named families — a non-family --font-* token must not
75
+ // produce a bogus "Font \\"1rem\\"" entry, and the components half of the
76
+ // feature must survive the real themeBuild path, not just the unit helper.
77
+ const fontWarnings = (result?.data.warnings ?? []).filter(w =>
78
+ w.startsWith('Font "'),
79
+ );
80
+ expect(fontWarnings).toEqual([
81
+ expect.stringContaining('Font "Bungee"'),
82
+ expect.stringContaining('Font "Orbitron"'),
83
+ ]);
84
+ });
85
+
86
+ it('warns for a family named only inside a pseudo-class component override (defineTheme path)', async () => {
87
+ // ':hover' blocks are legal override values (generateThemeRules), and the
88
+ // typography path deep-merges author components with generated ones — a
89
+ // webfont hiding at that depth must survive the merge and still warn.
90
+ fs.writeFileSync(
91
+ path.join(tmpDir, 'pseudo.mjs'),
92
+ `export default {
93
+ name: 'pseudo',
94
+ typography: {
95
+ body: {family: 'Helvetica', fallbacks: 'Arial, sans-serif'},
96
+ },
97
+ components: {
98
+ button: {base: {':hover': {fontFamily: '"Rubik Doodle", cursive'}}},
99
+ },
100
+ };\n`,
101
+ );
102
+
103
+ const result = await themeBuild('pseudo.mjs', {}, {cwd: tmpDir});
104
+
105
+ const fontWarnings = (result?.data.warnings ?? []).filter(w =>
106
+ w.startsWith('Font "'),
107
+ );
108
+ // Exactly the hidden family — Helvetica/Arial are system fonts.
109
+ expect(fontWarnings).toEqual([
110
+ expect.stringContaining('Font "Rubik Doodle"'),
111
+ ]);
112
+ });
113
+
114
+ it('warns about nothing when every named family is a generic or known system font', async () => {
115
+ fs.writeFileSync(
116
+ path.join(tmpDir, 'sys.mjs'),
117
+ `export default {
118
+ name: 'sys',
119
+ tokens: {
120
+ '--color-bg': '#fff',
121
+ '--font-family-body': 'Helvetica, Arial, sans-serif',
122
+ },
123
+ };\n`,
124
+ );
125
+
126
+ const result = await themeBuild('sys.mjs', {}, {cwd: tmpDir});
127
+
128
+ expect(result?.type).toBe('theme.build');
129
+ expect(result?.data.warnings).toEqual([]);
130
+ });
131
+
132
+ it('stays silent under the default noopLogger even when font warnings fire', async () => {
133
+ fs.writeFileSync(
134
+ path.join(tmpDir, 'loud.mjs'),
135
+ `export default { name: 'loud', tokens: { '--font-family-body': '"Orbitron", sans-serif' } };\n`,
136
+ );
137
+
138
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
139
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
140
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
141
+ const outSpy = vi
142
+ .spyOn(process.stdout, 'write')
143
+ .mockImplementation(() => true);
144
+
145
+ try {
146
+ const result = await themeBuild('loud.mjs', {}, {cwd: tmpDir});
147
+ expect(result?.data.warnings).toEqual(
148
+ expect.arrayContaining([expect.stringContaining('Font "Orbitron"')]),
149
+ );
150
+ expect(logSpy).not.toHaveBeenCalled();
151
+ expect(warnSpy).not.toHaveBeenCalled();
152
+ expect(errSpy).not.toHaveBeenCalled();
153
+ expect(outSpy).not.toHaveBeenCalled();
154
+ } finally {
155
+ logSpy.mockRestore();
156
+ warnSpy.mockRestore();
157
+ errSpy.mockRestore();
158
+ outSpy.mockRestore();
159
+ }
160
+ });
161
+ });
@@ -11,8 +11,10 @@
11
11
  * - A JS module that re-exports the built theme (+ icon registry)
12
12
  * - A .d.ts (plus an optional .variants.d.ts for custom prop values)
13
13
  *
14
- * It performs the writes and returns a `theme.build` receipt, or `null` when
15
- * the theme produced no CSS (nothing to build). Errors throw AstryxError (with
14
+ * It performs the writes and returns a `theme.build` receipt its `warnings`
15
+ * carry override problems and any fonts the theme names but does not load
16
+ * (font-warning.mjs) — or `null` when the theme produced no CSS (nothing to
17
+ * build). Errors throw AstryxError (with
16
18
  * a stable code). Human progress is emitted through the shared `logger`
17
19
  * (silent by default), so the CLI keeps its exact output while a programmatic
18
20
  * caller stays quiet.
@@ -41,6 +43,10 @@ import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
41
43
  import {AstryxError} from '../../error.mjs';
42
44
  import {logger} from '../../logger.mjs';
43
45
  import {loadComponentDoc} from '../../../foundation/discovery/component-loader.mjs';
46
+ import {
47
+ collectUnloadedFonts,
48
+ formatFontLoadingHelp,
49
+ } from './font-warning.mjs';
44
50
 
45
51
  // Import shared theme processing from core. `astryx theme build` MUST produce the
46
52
  // exact same CSS as the `<Theme>` runtime, so it has exactly one generation
@@ -585,6 +591,30 @@ const themeScopeStart = (/** @type {string} */ name) =>
585
591
  `[data-astryx-theme="${name}"]`;
586
592
  const THEME_SCOPE_TO = `[data-astryx-theme]`;
587
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
+
588
618
  /**
589
619
  * Import a theme module using jiti and find the defineTheme() result.
590
620
  * Returns the resolved DefinedTheme object.
@@ -595,6 +625,7 @@ async function importThemeModule(filePath) {
595
625
  const jiti = createJiti(import.meta.url, {
596
626
  moduleCache: false,
597
627
  jsx: true,
628
+ extensions: THEME_MODULE_EXTENSIONS,
598
629
  });
599
630
 
600
631
  const mod = await jiti.import(filePath, {default: true});
@@ -727,6 +758,13 @@ function extractIconInfo(filePath) {
727
758
  * Includes the theme name, marker, and re-exports the icon registry.
728
759
  * All styling is in the CSS file.
729
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
+ *
730
768
  * The icon registry is imported rather than inlined because it holds React
731
769
  * elements, which cannot be serialized. `extractIconInfo` lifts the specifier
732
770
  * out of the TypeScript source, where an extensionless `./icons` is resolved by
@@ -768,6 +806,27 @@ function generateBuiltModule(themeDef, iconInfo, iconsSpecifier) {
768
806
  .map((line, i) => (i === 0 ? line : ' ' + line))
769
807
  .join('\n');
770
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
+
771
830
  return `${iconImport}/**
772
831
  * ${themeDef.name} theme — built by \`${getCliInvocation()} theme build\`
773
832
  * Import the CSS file alongside this module:
@@ -779,7 +838,7 @@ export const ${toIdentifier(themeDef.name)}Theme = {
779
838
  name: '${themeDef.name}',
780
839
  __built: true,
781
840
  tokens: ${tokensStr},
782
- ${iconsField}
841
+ ${inheritableFields}${iconsField}
783
842
  };
784
843
  ${iconReExport}`;
785
844
  }
@@ -1093,20 +1152,29 @@ export async function themeBuild(
1093
1152
  let css;
1094
1153
  let resolvedTheme;
1095
1154
  {
1096
- // jiti returns an already-resolved theme; legacy eval returns raw input.
1097
- const isAlreadyResolved =
1098
- !themeDef.typography && !themeDef.motion && !themeDef.radius;
1099
- if (isAlreadyResolved) {
1100
- resolvedTheme = themeDef;
1155
+ // jiti returns an already-resolved theme; a plain object literal (or the
1156
+ // legacy eval path) returns raw defineTheme input, which still has to go
1157
+ // through the resolver. Detect that by the input-only fields — a resolved
1158
+ // theme has none of them — and hand the WHOLE object over: picking fields
1159
+ // by name is how `extends` (and `color`, and `syntax`) used to be dropped
1160
+ // on the way in.
1161
+ const INPUT_ONLY_FIELDS = [
1162
+ 'extends',
1163
+ 'typography',
1164
+ 'motion',
1165
+ 'radius',
1166
+ 'color',
1167
+ 'syntax',
1168
+ 'onDark',
1169
+ 'onLight',
1170
+ ];
1171
+ const needsResolution = INPUT_ONLY_FIELDS.some(
1172
+ field => themeDef[field] !== undefined,
1173
+ );
1174
+ if (needsResolution) {
1175
+ resolvedTheme = _defineTheme({...themeDef});
1101
1176
  } else {
1102
- resolvedTheme = _defineTheme({
1103
- name: themeDef.name,
1104
- typography: themeDef.typography,
1105
- motion: themeDef.motion,
1106
- radius: themeDef.radius,
1107
- tokens: themeDef.tokens,
1108
- components: themeDef.components,
1109
- });
1177
+ resolvedTheme = themeDef;
1110
1178
  }
1111
1179
  const scopeSelector = themeScopeStart(themeDef.name);
1112
1180
  const scopeTo = THEME_SCOPE_TO;
@@ -1341,16 +1409,17 @@ Or with a <link> tag:
1341
1409
  </Theme>
1342
1410
  `);
1343
1411
 
1344
- // Print font declaration warnings (derived from typography roles)
1345
- if (resolvedTheme && resolvedTheme.fonts && resolvedTheme.fonts.length > 0) {
1346
- logger.log(
1347
- `\n⚠ Theme "${themeDef.name}" requires fonts not included in the build:`,
1348
- );
1349
- for (const font of resolvedTheme.fonts) {
1350
- logger.log(` ${font.family} — add to your document <head>:`);
1351
- logger.log(` <link rel="stylesheet" href="${font.url}" />`);
1352
- }
1353
- logger.log('');
1412
+ // Fonts the theme names but nothing loads (#5015). Resolved tokens and
1413
+ // component overrides carry the final font-family values on both load
1414
+ // paths, so this sees jiti-resolved and legacy themes alike.
1415
+ const unloadedFonts = collectUnloadedFonts(resolvedTheme);
1416
+ for (const family of unloadedFonts) {
1417
+ 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)`;
1418
+ warningMessages.push(msg);
1419
+ logger.warn(` ${msg}`);
1420
+ }
1421
+ if (unloadedFonts.length > 0) {
1422
+ logger.log(formatFontLoadingHelp(themeDef.name, unloadedFonts));
1354
1423
  }
1355
1424
 
1356
1425
  return {