@astryxdesign/cli 0.1.4 → 0.1.5-canary.76e6697

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 (75) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/docs/getting-started.doc.mjs +4 -0
  3. package/docs/migration.doc.mjs +134 -0
  4. package/docs/styling.doc.mjs +53 -0
  5. package/docs/working-with-ai.doc.mjs +1 -1
  6. package/package.json +15 -10
  7. package/src/api/component.mjs +2 -1
  8. package/src/api/doctor.mjs +13 -2
  9. package/src/api/template.mjs +14 -3
  10. package/src/codemods/__tests__/registry.test.mjs +1 -0
  11. package/src/codemods/__tests__/runner.test.mjs +38 -0
  12. package/src/codemods/registry.mjs +1 -0
  13. package/src/codemods/runner.mjs +2 -0
  14. package/src/codemods/transforms/v0.1.5/__tests__/rename-switch-label-spacing-default-to-hug.test.mjs +93 -0
  15. package/src/codemods/transforms/v0.1.5/index.mjs +19 -0
  16. package/src/codemods/transforms/v0.1.5/rename-switch-label-spacing-default-to-hug.mjs +140 -0
  17. package/src/commands/agent-docs.mjs +9 -6
  18. package/src/commands/agent-docs.test.mjs +33 -0
  19. package/src/commands/build-theme.color-scheme.test.mjs +153 -0
  20. package/src/commands/build-theme.mjs +112 -14
  21. package/src/commands/build-theme.variants.test.mjs +193 -0
  22. package/src/commands/component-resolution.test.mjs +4 -4
  23. package/src/commands/doctor.test.mjs +34 -0
  24. package/src/commands/init.mjs +1 -1
  25. package/src/commands/swizzle.mjs +34 -0
  26. package/src/commands/swizzle.routing.test.mjs +67 -0
  27. package/src/lib/component-discovery.mjs +8 -4
  28. package/templates/blocks/components/AspectRatio/AspectRatioCircleImage.tsx +1 -6
  29. package/templates/blocks/components/AspectRatio/AspectRatioImageGallery.tsx +2 -7
  30. package/templates/blocks/components/AspectRatio/AspectRatioShowcase.tsx +2 -5
  31. package/templates/blocks/components/AspectRatio/AspectRatioSquareImage.tsx +1 -6
  32. package/templates/blocks/components/AspectRatio/AspectRatioWidescreen.tsx +1 -6
  33. package/templates/blocks/components/BaseTypeahead/BaseTypeaheadCustomSearch.doc.mjs +14 -0
  34. package/templates/blocks/components/BaseTypeahead/BaseTypeaheadCustomSearch.tsx +58 -0
  35. package/templates/blocks/components/CodeBlock/CodeBlockTerminal.doc.mjs +14 -0
  36. package/templates/blocks/components/CodeBlock/CodeBlockTerminal.tsx +24 -0
  37. package/templates/blocks/components/Collapsible/CollapsibleDividedAccordion.doc.mjs +13 -0
  38. package/templates/blocks/components/Collapsible/CollapsibleDividedAccordion.tsx +33 -0
  39. package/templates/blocks/components/Collapsible/CollapsibleWithoutCard.tsx +25 -23
  40. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupShowcase.tsx +27 -48
  41. package/templates/blocks/components/CommandPaletteInput/CommandPaletteInputBasic.doc.mjs +15 -0
  42. package/templates/blocks/components/CommandPaletteInput/CommandPaletteInputBasic.tsx +39 -0
  43. package/templates/blocks/components/CommandPaletteInput/CommandPaletteInputShowcase.doc.mjs +15 -0
  44. package/templates/blocks/components/CommandPaletteInput/CommandPaletteInputShowcase.tsx +39 -0
  45. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemShowcase.tsx +31 -54
  46. package/templates/blocks/components/CommandPaletteList/CommandPaletteListBasic.doc.mjs +15 -0
  47. package/templates/blocks/components/CommandPaletteList/CommandPaletteListBasic.tsx +27 -0
  48. package/templates/blocks/components/CommandPaletteList/CommandPaletteListShowcase.doc.mjs +15 -0
  49. package/templates/blocks/components/CommandPaletteList/CommandPaletteListShowcase.tsx +38 -0
  50. package/templates/blocks/components/ContextMenuItem/ContextMenuItemBasic.doc.mjs +14 -0
  51. package/templates/blocks/components/ContextMenuItem/ContextMenuItemBasic.tsx +44 -0
  52. package/templates/blocks/components/ContextMenuItem/ContextMenuItemShowcase.doc.mjs +15 -0
  53. package/templates/blocks/components/ContextMenuItem/ContextMenuItemShowcase.tsx +107 -0
  54. package/templates/blocks/components/NavHeadingMenu/NavHeadingMenuShowcase.doc.mjs +15 -0
  55. package/templates/blocks/components/NavHeadingMenu/NavHeadingMenuShowcase.tsx +47 -0
  56. package/templates/blocks/components/Table/TableGroupedRowsTable.doc.mjs +14 -0
  57. package/templates/blocks/components/Table/TableGroupedRowsTable.tsx +66 -0
  58. package/templates/pages/classic-gallery/page.tsx +1 -1
  59. package/templates/pages/dashboard/page.tsx +1 -1
  60. package/templates/pages/documentation/page.tsx +1 -1
  61. package/templates/pages/incident-console/page.tsx +580 -0
  62. package/templates/pages/incident-console/template.doc.mjs +12 -0
  63. package/templates/pages/kanban-board/page.tsx +37 -13
  64. package/templates/pages/messaging-shell/page.tsx +676 -0
  65. package/templates/pages/messaging-shell/template.doc.mjs +12 -0
  66. package/templates/pages/mixed-gallery/page.tsx +1 -1
  67. package/templates/pages/payment-form/page.tsx +2 -6
  68. package/templates/pages/product-detail/page.tsx +3 -11
  69. package/templates/pages/product-gallery/page.tsx +1 -1
  70. package/templates/pages/settings/page.tsx +1 -1
  71. package/templates/pages/side-gallery/page.tsx +1 -1
  72. package/templates/pages/table-page/page.tsx +1 -1
  73. package/templates/pages/table-page-chart/page.tsx +4 -14
  74. package/templates/pages/table-page-heatmap-status/page.tsx +1 -1
  75. package/templates/pages/table-page-shoe-store-heatmap/page.tsx +5 -19
@@ -0,0 +1,140 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Codemod: Rename Switch labelSpacing "default" to "hug"
5
+ * @see https://github.com/facebook/astryx/issues/2889
6
+ *
7
+ * The `labelSpacing="default"` value is renamed to `labelSpacing="hug"` so
8
+ * the name describes the behavior (label and switch hug each other), matching
9
+ * the behavioral register of the sibling `spread` value and the existing
10
+ * `hug`/`fill` layout vocabulary on TabList and SegmentedControl. The old
11
+ * `default` value keeps working as a deprecated alias, so this codemod is a
12
+ * cleanup, not a build fix.
13
+ *
14
+ * Transforms (scoped to Switch):
15
+ * - <Switch labelSpacing="default" /> → <Switch labelSpacing="hug" />
16
+ * - <Switch labelSpacing={'default'} /> → <Switch labelSpacing={'hug'} />
17
+ * - <Switch labelSpacing={cond ? 'default' : x}> → <Switch labelSpacing={cond ? 'hug' : x}>
18
+ * - Storybook argTypes: labelSpacing: { options: [..., 'default', ...] }
19
+ * → replaces 'default' with 'hug' (files importing Switch)
20
+ * - Object properties: { labelSpacing: 'default' } / { labelSpacing: 'default' as const }
21
+ * → { labelSpacing: 'hug' } (files importing Switch)
22
+ *
23
+ * The transform deliberately does NOT touch bare `'default'` strings that are
24
+ * not a `labelSpacing` value (an extremely common string otherwise), so it is
25
+ * safe to run across an entire codebase.
26
+ */
27
+
28
+ export const meta = {
29
+ title: 'Rename Switch labelSpacing "default" to "hug"',
30
+ description:
31
+ 'Renames the `labelSpacing="default"` value to `labelSpacing="hug"` on ' +
32
+ 'Switch. The old value keeps working as a deprecated alias. Also updates ' +
33
+ 'Storybook labelSpacing argTypes options and object-literal `labelSpacing` ' +
34
+ 'props in files that import Switch.',
35
+ pr: '#2889',
36
+ };
37
+
38
+ const OLD_VALUE = 'default';
39
+ const NEW_VALUE = 'hug';
40
+
41
+ /** Prop whose value is being renamed. */
42
+ const TARGET_PROP = 'labelSpacing';
43
+
44
+ /** Components whose `labelSpacing` prop accepts a SwitchLabelSpacing value. */
45
+ const TARGET_COMPONENTS = new Set(['Switch']);
46
+
47
+ export default function transformer(file, api) {
48
+ const j = api.jscodeshift;
49
+ const root = j(file.source);
50
+ let hasChanges = false;
51
+
52
+ /** Rewrite a string-literal node when it equals the old value. */
53
+ function renameStringLiteral(node) {
54
+ if (!node) return false;
55
+ if (
56
+ (node.type === 'StringLiteral' || node.type === 'Literal') &&
57
+ node.value === OLD_VALUE
58
+ ) {
59
+ node.value = NEW_VALUE;
60
+ if (node.raw) node.raw = `'${NEW_VALUE}'`;
61
+ return true;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ /** Recursively rewrite the old value inside ternary/logical expressions. */
67
+ function renameInExpression(node) {
68
+ if (!node) return false;
69
+ let changed = false;
70
+ if (node.type === 'StringLiteral' || node.type === 'Literal') {
71
+ changed = renameStringLiteral(node) || changed;
72
+ } else if (node.type === 'ConditionalExpression') {
73
+ changed = renameInExpression(node.consequent) || changed;
74
+ changed = renameInExpression(node.alternate) || changed;
75
+ } else if (node.type === 'LogicalExpression') {
76
+ changed = renameInExpression(node.left) || changed;
77
+ changed = renameInExpression(node.right) || changed;
78
+ } else if (node.type === 'TSAsExpression') {
79
+ // `'default' as const` → rewrite the inner expression
80
+ changed = renameInExpression(node.expression) || changed;
81
+ }
82
+ return changed;
83
+ }
84
+
85
+ // --- 1. JSX attribute: labelSpacing="default" / labelSpacing={'default'} on Switch ---
86
+ root.find(j.JSXOpeningElement).forEach(path => {
87
+ const name = path.node.name;
88
+ const componentName = name.type === 'JSXIdentifier' ? name.name : null;
89
+ if (!componentName || !TARGET_COMPONENTS.has(componentName)) return;
90
+
91
+ path.node.attributes.forEach(attr => {
92
+ if (attr.type !== 'JSXAttribute') return;
93
+ if (!attr.name || attr.name.name !== TARGET_PROP) return;
94
+
95
+ const value = attr.value;
96
+ if (!value) return;
97
+
98
+ if (value.type === 'StringLiteral' || value.type === 'Literal') {
99
+ if (renameStringLiteral(value)) hasChanges = true;
100
+ } else if (value.type === 'JSXExpressionContainer') {
101
+ if (renameInExpression(value.expression)) hasChanges = true;
102
+ }
103
+ });
104
+ });
105
+
106
+ // Object-property / argTypes transforms only run in files that use a target
107
+ // component — keeps unrelated `{ labelSpacing: 'default' }` strings safe.
108
+ const importsTarget =
109
+ root
110
+ .find(j.ImportSpecifier)
111
+ .filter(p => TARGET_COMPONENTS.has(p.node.imported?.name))
112
+ .size() > 0;
113
+
114
+ if (importsTarget) {
115
+ const PropertyType = j.ObjectProperty ?? j.Property;
116
+
117
+ // --- 2. Object property: { labelSpacing: 'default' } / 'default' as const ---
118
+ root.find(PropertyType, {key: {name: TARGET_PROP}}).forEach(path => {
119
+ if (renameInExpression(path.node.value)) hasChanges = true;
120
+ });
121
+
122
+ // --- 3. Storybook argTypes: labelSpacing: { options: [..., 'default', ...] } ---
123
+ root.find(PropertyType, {key: {name: TARGET_PROP}}).forEach(path => {
124
+ const value = path.node.value;
125
+ if (!value || value.type !== 'ObjectExpression') return;
126
+
127
+ const optionsProp = value.properties.find(
128
+ p => p.key && (p.key.name === 'options' || p.key.value === 'options'),
129
+ );
130
+ if (optionsProp && optionsProp.value.type === 'ArrayExpression') {
131
+ optionsProp.value.elements.forEach(el => {
132
+ if (renameStringLiteral(el)) hasChanges = true;
133
+ });
134
+ }
135
+ });
136
+ }
137
+
138
+ if (!hasChanges) return undefined;
139
+ return root.toSource({quote: 'single'});
140
+ }
@@ -9,11 +9,12 @@
9
9
  * - Claude Code: CLAUDE.md (root) or .claude/CLAUDE.md
10
10
  * - Cursor: .cursorrules
11
11
  * - Codex/generic: AGENTS.md
12
+ * - Hermes Agent: .hermes.md or HERMES.md (existing), else AGENTS.md
12
13
  *
13
14
  * Auto-detect: discovers existing files and updates them in place.
14
15
  * Default (no existing files): creates .claude/CLAUDE.md.
15
16
  *
16
- * --agent <tool>: target a specific tool preset (claude, cursor, codex, all)
17
+ * --agent <tool>: target a specific tool preset (claude, cursor, codex, hermes, all)
17
18
  * --agent-docs-path <path>: explicit file path(s)
18
19
  */
19
20
 
@@ -29,7 +30,8 @@ import {ERROR_CODES} from '../lib/error-codes.mjs';
29
30
 
30
31
  const AGENTS_MD = 'AGENTS.md';
31
32
  const CLAUDE_MD = 'CLAUDE.md';
32
- const CLAUDE_DIR_MD = path.join('.claude', 'CLAUDE.md');
33
+ const CLAUDE_DIR_MD = '.claude/CLAUDE.md'; // cross-platform literal
34
+
33
35
 
34
36
  const MARKER_START = '<!-- ASTRYX:START -->';
35
37
  const MARKER_END = '<!-- ASTRYX:END -->';
@@ -45,6 +47,7 @@ const AGENT_PRESETS = {
45
47
  claude: [CLAUDE_MD, CLAUDE_DIR_MD],
46
48
  cursor: ['.cursorrules', AGENTS_MD],
47
49
  codex: [AGENTS_MD],
50
+ hermes: ['.hermes.md', 'HERMES.md', AGENTS_MD],
48
51
  };
49
52
 
50
53
  /**
@@ -63,7 +66,7 @@ export function discoverAgentDocs(targetDir) {
63
66
  * Searches for existing files first, falls back to default creation path.
64
67
  *
65
68
  * @param {string} targetDir
66
- * @param {string} agent - Preset name: 'claude', 'cursor', 'codex', 'all'
69
+ * @param {string} agent - Preset name: 'claude', 'cursor', 'codex', 'hermes', 'all'
67
70
  * @returns {{inject: string[], create: string[]}} Files to inject into vs create fresh
68
71
  */
69
72
  export function resolveAgentPaths(targetDir, agent) {
@@ -385,7 +388,7 @@ export function removeAgentDocs(targetDir) {
385
388
  * @param {object} [options]
386
389
  * @param {boolean} [options.zh]
387
390
  * @param {string} [options.lang]
388
- * @param {string} [options.agent] - Tool preset: 'claude', 'cursor', 'codex', 'all'
391
+ * @param {string} [options.agent] - Tool preset: 'claude', 'cursor', 'codex', 'hermes', 'all'
389
392
  * @param {string[]} [options.paths] - Explicit paths (overrides agent/auto-detect)
390
393
  * @param {boolean} [options.onlyReplace] - Only update files that already have Astryx markers (for upgrades)
391
394
  * @returns {string[]} List of files written
@@ -467,14 +470,14 @@ export function installAgentDocs(targetDir, {zh = false, lang, agent, paths, onl
467
470
  return written;
468
471
  }
469
472
 
470
- const VALID_AGENTS = ['claude', 'cursor', 'codex', 'all'];
473
+ const VALID_AGENTS = ['claude', 'cursor', 'codex', 'hermes', 'all'];
471
474
 
472
475
  export function registerAgentDocs(program) {
473
476
  program
474
477
  .command('agent-docs')
475
478
  .description('Install/update the component index for AI coding agents')
476
479
  .option('--remove', 'Remove the design system section from all agent doc files')
477
- .option('--agent <tool>', 'Target tool: claude, cursor, codex, all')
480
+ .option('--agent <tool>', 'Target tool: claude, cursor, codex, hermes, all')
478
481
  .option('--agent-docs-path <path...>', 'Explicit file path(s) to write to')
479
482
  .action(options => {
480
483
  const targetDir = process.cwd();
@@ -449,6 +449,17 @@ describe('installAgentDocs', () => {
449
449
  expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(true);
450
450
  });
451
451
 
452
+ it('respects --agent hermes preset: creates AGENTS.md', () => {
453
+ setupCorePackage(tmpDir);
454
+
455
+ const written = installAgentDocs(tmpDir, {agent: 'hermes'});
456
+
457
+ expect(written).toEqual(['AGENTS.md']);
458
+ const content = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
459
+ expect(content).toContain('<!-- ASTRYX:START -->');
460
+ expect(fs.existsSync(path.join(tmpDir, '.claude'))).toBe(false);
461
+ });
462
+
452
463
  it('respects explicit --paths', () => {
453
464
  setupCorePackage(tmpDir);
454
465
 
@@ -548,4 +559,26 @@ describe('resolveAgentPaths', () => {
548
559
  expect(result.create).toContain('AGENTS.md');
549
560
  expect(result.create).toContain('.claude/CLAUDE.md');
550
561
  });
562
+
563
+ it('hermes preset creates AGENTS.md when nothing exists', () => {
564
+ const result = resolveAgentPaths(tmpDir, 'hermes');
565
+ expect(result).toEqual({inject: [], create: ['AGENTS.md']});
566
+ });
567
+
568
+ it('hermes preset finds existing .hermes.md', () => {
569
+ fs.writeFileSync(path.join(tmpDir, '.hermes.md'), '');
570
+ const result = resolveAgentPaths(tmpDir, 'hermes');
571
+ expect(result).toEqual({inject: ['.hermes.md'], create: []});
572
+ });
573
+
574
+ it('hermes preset finds existing HERMES.md when no .hermes.md', () => {
575
+ fs.writeFileSync(path.join(tmpDir, 'HERMES.md'), '');
576
+ const result = resolveAgentPaths(tmpDir, 'hermes');
577
+ expect(result).toEqual({inject: ['HERMES.md'], create: []});
578
+ });
579
+
580
+ it('claude preset still creates .claude/CLAUDE.md when nothing exists (hermes is additive)', () => {
581
+ const result = resolveAgentPaths(tmpDir, 'claude');
582
+ expect(result).toEqual({inject: [], create: ['.claude/CLAUDE.md']});
583
+ });
551
584
  });
@@ -0,0 +1,153 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Regression test for the `color-scheme` declaration `astryx theme build`
5
+ * emits alongside `light-dark()` token output (see #3658).
6
+ *
7
+ * `astryx theme build` injects a `color-scheme` declaration into
8
+ * `@layer astryx-theme` whenever the generated CSS contains `light-dark()`,
9
+ * so LightningCSS/browsers can resolve it. That declaration must mirror
10
+ * reset.css's own `html[data-theme]` -> `color-scheme` mapping (a bare
11
+ * `:root { color-scheme: light dark; }` alone permanently pins the page to
12
+ * "light dark", defeating `<Theme mode="light|dark">` forcing regardless of
13
+ * `@layer astryx-theme` being declared after `@layer reset` in layer order):
14
+ *
15
+ * :root { color-scheme: light dark; }
16
+ * html[data-theme="light"] { color-scheme: light; }
17
+ * html[data-theme="dark"] { color-scheme: dark; }
18
+ *
19
+ * Themes that never use `light-dark()` have no `color-scheme` ambiguity to
20
+ * resolve, so none of the three rules should be emitted for them.
21
+ *
22
+ * Building `astryx theme build` requires a compiled @astryxdesign/core (there is no in-CLI
23
+ * fallback generator), so this suite builds core once in beforeAll via the
24
+ * shared ensureCoreBuilt() helper — which serializes concurrent Vitest workers
25
+ * behind a lock — to stay self-sufficient regardless of CI job ordering.
26
+ */
27
+
28
+ import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
29
+ import {execFileSync} from 'node:child_process';
30
+ import * as fs from 'node:fs';
31
+ import * as path from 'node:path';
32
+ import * as os from 'node:os';
33
+ import {fileURLToPath} from 'node:url';
34
+ import {ensureCoreBuilt} from './ensure-core-built.mjs';
35
+
36
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
+ const CLI_BIN = path.resolve(__dirname, '../../bin/astryx.mjs');
38
+
39
+ const COLOR_SCHEME_ROOT_DECL = ':root { color-scheme: light dark; }';
40
+ const COLOR_SCHEME_LIGHT_DECL = 'html[data-theme="light"] { color-scheme: light; }';
41
+ const COLOR_SCHEME_DARK_DECL = 'html[data-theme="dark"] { color-scheme: dark; }';
42
+
43
+ function runCli(args, cwd) {
44
+ try {
45
+ const out = execFileSync('node', [CLI_BIN, ...args], {
46
+ cwd,
47
+ encoding: 'utf-8',
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ env: {...process.env, FORCE_COLOR: '0'},
50
+ });
51
+ return {code: 0, stdout: out, stderr: ''};
52
+ } catch (e) {
53
+ return {
54
+ code: e.status ?? 1,
55
+ stdout: e.stdout?.toString() ?? '',
56
+ stderr: e.stderr?.toString() ?? '',
57
+ };
58
+ }
59
+ }
60
+
61
+ function writeTheme(dir, name, tokens) {
62
+ fs.mkdirSync(dir, {recursive: true});
63
+ // The CLI writes <basename>.css next to the source file, so use the
64
+ // theme name as the filename for unambiguous fixtures.
65
+ const file = path.join(dir, `${name}.mjs`);
66
+ fs.writeFileSync(
67
+ file,
68
+ `export default { name: ${JSON.stringify(name)}, tokens: ${JSON.stringify(tokens)} };\n`,
69
+ );
70
+ return file;
71
+ }
72
+
73
+ // `astryx theme build` imports the compiled @astryxdesign/core/theme entry. Build core
74
+ // once if it isn't already present so the suite works in any CI job.
75
+ beforeAll(() => {
76
+ ensureCoreBuilt();
77
+ }, 200_000);
78
+
79
+ let tmpDir;
80
+ beforeEach(() => {
81
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-build-theme-color-scheme-'));
82
+ });
83
+ afterEach(() => {
84
+ fs.rmSync(tmpDir, {recursive: true, force: true});
85
+ });
86
+
87
+ describe('theme build color-scheme output', () => {
88
+ it('emits the mode-aware color-scheme rules in @layer astryx-theme for a light-dark() theme', () => {
89
+ const project = path.join(tmpDir, 'project');
90
+ const themesDir = path.join(project, 'themes');
91
+ // A raw light-dark() token value is what triggers the color-scheme
92
+ // injection in build-theme.mjs (it checks the generated CSS for the
93
+ // literal substring). `defineTheme.test.ts` already covers the separate
94
+ // [light, dark] tuple -> light-dark() conversion; this fixture writes
95
+ // the string directly so this test stays focused on the CLI's own
96
+ // color-scheme decision, not on that conversion.
97
+ const themeFile = writeTheme(themesDir, 'with-light-dark', {
98
+ '--color-accent': 'light-dark(#0077B6, #48CAE4)',
99
+ });
100
+
101
+ const result = runCli(
102
+ ['theme', 'build', path.relative(project, themeFile)],
103
+ project,
104
+ );
105
+
106
+ expect(result.code).toBe(0);
107
+
108
+ const cssPath = path.join(themesDir, 'with-light-dark.css');
109
+ expect(fs.existsSync(cssPath)).toBe(true);
110
+ const css = fs.readFileSync(cssPath, 'utf-8');
111
+
112
+ expect(css).toContain('light-dark(#0077B6, #48CAE4)');
113
+
114
+ // All 3 rules must be present...
115
+ expect(css).toContain(COLOR_SCHEME_ROOT_DECL);
116
+ expect(css).toContain(COLOR_SCHEME_LIGHT_DECL);
117
+ expect(css).toContain(COLOR_SCHEME_DARK_DECL);
118
+
119
+ // ...and specifically inside @layer astryx-theme, not @layer reset (the
120
+ // declaration must sit alongside the light-dark() token/component output
121
+ // it resolves, not the zero-specificity prose defaults).
122
+ const themeLayerStart = css.indexOf('@layer astryx-theme');
123
+ expect(themeLayerStart).toBeGreaterThanOrEqual(0);
124
+ const themeLayerBody = css.slice(themeLayerStart);
125
+ expect(themeLayerBody).toContain(COLOR_SCHEME_ROOT_DECL);
126
+ expect(themeLayerBody).toContain(COLOR_SCHEME_LIGHT_DECL);
127
+ expect(themeLayerBody).toContain(COLOR_SCHEME_DARK_DECL);
128
+ });
129
+
130
+ it('omits all color-scheme rules for a theme with no light-dark() tokens', () => {
131
+ const project = path.join(tmpDir, 'project');
132
+ const themesDir = path.join(project, 'themes');
133
+ const themeFile = writeTheme(themesDir, 'no-light-dark', {
134
+ '--color-accent': '#0077B6',
135
+ });
136
+
137
+ const result = runCli(
138
+ ['theme', 'build', path.relative(project, themeFile)],
139
+ project,
140
+ );
141
+
142
+ expect(result.code).toBe(0);
143
+
144
+ const cssPath = path.join(themesDir, 'no-light-dark.css');
145
+ expect(fs.existsSync(cssPath)).toBe(true);
146
+ const css = fs.readFileSync(cssPath, 'utf-8');
147
+
148
+ expect(css).not.toContain('light-dark(');
149
+ expect(css).not.toContain(COLOR_SCHEME_ROOT_DECL);
150
+ expect(css).not.toContain(COLOR_SCHEME_LIGHT_DECL);
151
+ expect(css).not.toContain(COLOR_SCHEME_DARK_DECL);
152
+ });
153
+ });
@@ -175,14 +175,85 @@ async function getKnownValues(componentName) {
175
175
  return _knownValuesCache.get(componentName);
176
176
  }
177
177
 
178
+ /**
179
+ * Resolve `@astryxdesign/core`'s package root relative to the CLI package. Core
180
+ * and the CLI ship as siblings (`@astryxdesign/core`, `@astryxdesign/cli`), so
181
+ * `../../../core` from `src/commands/` reaches core whether installed from npm
182
+ * or run inside the monorepo. Returns null if it can't be found.
183
+ */
184
+ function resolveCoreRoot() {
185
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
186
+ const coreRoot = path.resolve(cliDir, '../../../core');
187
+ return fs.existsSync(coreRoot) ? coreRoot : null;
188
+ }
189
+
190
+ /**
191
+ * Read the type declarations `@astryxdesign/core/<Component>` exposes so we can
192
+ * check whether a given augmentation-target interface actually exists before
193
+ * generating a module augmentation against it.
194
+ *
195
+ * Reads the shipped `dist/<Component>/index.d.ts` (what a consumer's TypeScript
196
+ * actually sees), falling back to the `src/<Component>/index.ts` in the
197
+ * monorepo. Returns the file contents, or '' if nothing is found.
198
+ */
199
+ const _componentDeclCache = new Map();
200
+ function readComponentDeclarations(pascalName) {
201
+ if (_componentDeclCache.has(pascalName)) {
202
+ return _componentDeclCache.get(pascalName);
203
+ }
204
+ let contents = '';
205
+ const coreRoot = resolveCoreRoot();
206
+ if (coreRoot) {
207
+ const candidates = [
208
+ path.join(coreRoot, 'dist', pascalName, 'index.d.ts'),
209
+ path.join(coreRoot, 'src', pascalName, 'index.ts'),
210
+ ];
211
+ for (const file of candidates) {
212
+ try {
213
+ if (fs.existsSync(file)) {
214
+ contents = fs.readFileSync(file, 'utf-8');
215
+ break;
216
+ }
217
+ } catch {
218
+ // ignore and try the next candidate
219
+ }
220
+ }
221
+ }
222
+ _componentDeclCache.set(pascalName, contents);
223
+ return contents;
224
+ }
225
+
226
+ /**
227
+ * Determine whether `@astryxdesign/core/<Component>` exports an interface named
228
+ * `interfaceName` that can be augmented via module augmentation.
229
+ *
230
+ * Only interfaces are extension points — closed literal-union types (e.g.
231
+ * `HeadingType`, `ButtonSize`) are NOT augmentable, so a generated augmentation
232
+ * against them is dead code. We check that the name is exported (directly or
233
+ * re-exported) as a type/interface.
234
+ */
235
+ function componentHasAugmentableInterface(pascalName, interfaceName) {
236
+ const decl = readComponentDeclarations(pascalName);
237
+ if (!decl) return false;
238
+ // Word-boundary match so `ButtonVariantMap` doesn't match `XButtonVariantMap`.
239
+ const re = new RegExp(`\\b${interfaceName}\\b`);
240
+ return re.test(decl);
241
+ }
242
+
178
243
  /**
179
244
  * Generate TypeScript declaration content with module augmentation for custom
180
245
  * component prop values found in the theme's `components` keys. Reads known
181
246
  * values from doc files to filter out base prop values.
182
247
  *
183
- * Interface naming convention: Astryx + PascalCase(component) + PascalCase(prop) + Map
184
- * banner + status → XDSBannerStatusMap
185
- * button + variant → XDSButtonVariantMap
248
+ * Interface naming convention: PascalCase(component) + PascalCase(prop) + Map
249
+ * banner + status → BannerStatusMap
250
+ * button + variant → ButtonVariantMap
251
+ *
252
+ * An augmentation is only emitted when `@astryxdesign/core/<Component>` actually
253
+ * exports a matching interface. Props backed by closed literal-union types
254
+ * (e.g. Button `size`, Heading `type`/`level`) have no augmentation point, so
255
+ * generating a `declare module` block for them would be dead code — those are
256
+ * skipped.
186
257
  *
187
258
  * @param {object} themeDef - Theme definition (resolved by defineTheme)
188
259
  * @returns {Promise<string|null>} TypeScript declaration content, or null if no augmentations needed
@@ -234,7 +305,14 @@ async function generateVariantDeclarationsAsync(themeDef) {
234
305
  const pascal = toPascalCase(component);
235
306
  const propPascal = prop.charAt(0).toUpperCase() + prop.slice(1);
236
307
  const modulePath = `@astryxdesign/core/${pascal}`;
237
- const interfaceName = `XDS${pascal}${propPascal}Map`;
308
+ const interfaceName = `${pascal}${propPascal}Map`;
309
+
310
+ // Only augment interfaces that actually exist as an extension point in
311
+ // core. Props backed by closed literal-union types (e.g. Button `size`,
312
+ // Heading `type`/`level`) have no `*Map` interface — a `declare module`
313
+ // block against a non-existent interface just creates a new, unused
314
+ // interface and never extends the component's prop union, so skip it.
315
+ if (!componentHasAugmentableInterface(pascal, interfaceName)) continue;
238
316
 
239
317
  sections.push(`declare module '${modulePath}' {`);
240
318
  sections.push(` interface ${interfaceName} {`);
@@ -247,6 +325,13 @@ async function generateVariantDeclarationsAsync(themeDef) {
247
325
  }
248
326
  }
249
327
 
328
+ // If every custom value targeted a non-augmentable prop, there's nothing to
329
+ // emit beyond the header — return null so no `.variants.d.ts` is written.
330
+ const hasEmittedAugmentation = sections.some(line =>
331
+ line.startsWith('declare module'),
332
+ );
333
+ if (!hasEmittedAugmentation) return null;
334
+
250
335
  return sections.join('\n');
251
336
  }
252
337
 
@@ -433,13 +518,21 @@ ${iconReExport}`;
433
518
  /**
434
519
  * Generate TypeScript declarations for a built theme module.
435
520
  */
436
- function generateBuiltTypes(themeDef, iconInfo) {
521
+ function generateBuiltTypes(themeDef, iconInfo, variantsFileName) {
437
522
  const iconType = iconInfo
438
523
  ? `import type { IconRegistry } from '@astryxdesign/core/Icon';
439
524
  export declare const ${iconInfo.exportName}: IconRegistry;
440
525
  `
441
526
  : '';
442
- return `import type { DefinedTheme } from '@astryxdesign/core/theme';
527
+ // Pull in the generated custom-variant augmentations so that importing the
528
+ // theme's types also loads the module augmentations (otherwise the
529
+ // `.variants.d.ts` is emitted but never referenced, and the custom variants
530
+ // never widen the component prop unions for consumers).
531
+ const variantsRef = variantsFileName
532
+ ? `/// <reference path="./${variantsFileName}" />
533
+ `
534
+ : '';
535
+ return `${variantsRef}import type { DefinedTheme } from '@astryxdesign/core/theme';
443
536
  ${iconType}export declare const ${toIdentifier(themeDef.name)}Theme: DefinedTheme;
444
537
  `;
445
538
  }
@@ -846,8 +939,9 @@ export function registerTheme(program) {
846
939
  if (component.length > 0) {
847
940
  const componentInner = component.join('\n\n');
848
941
  const componentScope = `@scope (${scopeSelector}) to (${scopeTo}) {\n${componentInner}\n}`;
942
+ // #3658: also emit attribute-specific rules so <Theme mode> can override color-scheme
849
943
  const colorSchemeDecl = componentScope.includes('light-dark(')
850
- ? ' :root { color-scheme: light dark; }\n\n'
944
+ ? ' :root { color-scheme: light dark; }\n html[data-theme="light"] { color-scheme: light; }\n html[data-theme="dark"] { color-scheme: dark; }\n\n'
851
945
  : '';
852
946
  cssParts.push(
853
947
  `@layer astryx-theme {\n${colorSchemeDecl}${componentScope}\n}`,
@@ -894,19 +988,23 @@ export function registerTheme(program) {
894
988
 
895
989
  const iconInfo = extractIconInfo(filePath);
896
990
 
897
- // Generate all file contents in memory first.
898
- const cssContent = generatedHeader(sourceRelative, 'css', buildCommand) + css;
899
- const jsContent = generatedHeader(sourceRelative, 'js', buildCommand) + generateBuiltModule(resolvedTheme || themeDef, iconInfo);
900
- const dtsContent = generatedHeader(sourceRelative, 'ts', buildCommand) + generateBuiltTypes(themeDef, iconInfo);
901
-
902
- // Type augmentation .d.ts if theme has custom prop values
991
+ // Type augmentation .d.ts if theme has custom prop values. Computed
992
+ // before the main .d.ts so the latter can reference it (see below).
903
993
  const augmentationSource = resolvedTheme || themeDef;
904
994
  const variantDecl = await generateVariantDeclarationsAsync(augmentationSource);
905
- const variantDtsPath = variantDecl ? path.join(outDir, `${baseName}.variants.d.ts`) : null;
995
+ const variantsFileName = variantDecl ? `${baseName}.variants.d.ts` : null;
996
+ const variantDtsPath = variantDecl ? path.join(outDir, variantsFileName) : null;
906
997
  const variantContent = variantDecl
907
998
  ? generatedHeader(sourceRelative, 'ts', buildCommand) + variantDecl
908
999
  : null;
909
1000
 
1001
+ // Generate all file contents in memory first. The main .d.ts references
1002
+ // the variants file (when present) via a triple-slash directive so
1003
+ // importing the theme also loads the custom-variant augmentations.
1004
+ const cssContent = generatedHeader(sourceRelative, 'css', buildCommand) + css;
1005
+ const jsContent = generatedHeader(sourceRelative, 'js', buildCommand) + generateBuiltModule(resolvedTheme || themeDef, iconInfo);
1006
+ const dtsContent = generatedHeader(sourceRelative, 'ts', buildCommand) + generateBuiltTypes(themeDef, iconInfo, variantsFileName);
1007
+
910
1008
  // Atomic-ish write: stage every file as `<dest>.tmp`, then rename
911
1009
  // each into place. If any stage step fails we clean up partials and
912
1010
  // exit; if a rename fails mid-way we still have the originals (or