@astryxdesign/cli 0.4.2-canary.bb07062 → 0.4.2-canary.da6ea67

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.
@@ -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
+ });
@@ -1114,6 +1114,8 @@ export async function themeBuild(
1114
1114
  // Validate component overrides
1115
1115
  const warnings = await validateComponentOverrides(themeDef);
1116
1116
  const warningMessages = [];
1117
+ /** Advisories about a correct theme — see the `notices` note on the receipt. */
1118
+ const noticeMessages = [];
1117
1119
  for (const w of warnings) {
1118
1120
  warningMessages.push(w);
1119
1121
  logger.warn(` ⚠ ${w}`);
@@ -1412,11 +1414,18 @@ Or with a <link> tag:
1412
1414
  // Fonts the theme names but nothing loads (#5015). Resolved tokens and
1413
1415
  // component overrides carry the final font-family values on both load
1414
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).
1415
1424
  const unloadedFonts = collectUnloadedFonts(resolvedTheme);
1416
1425
  for (const family of unloadedFonts) {
1417
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)`;
1418
- warningMessages.push(msg);
1419
- logger.warn(` ${msg}`);
1427
+ noticeMessages.push(msg);
1428
+ logger.log(` note: ${msg}`);
1420
1429
  }
1421
1430
  if (unloadedFonts.length > 0) {
1422
1431
  logger.log(formatFontLoadingHelp(themeDef.name, unloadedFonts));
@@ -1438,6 +1447,7 @@ Or with a <link> tag:
1438
1447
  : {}),
1439
1448
  },
1440
1449
  warnings: warningMessages,
1450
+ notices: noticeMessages,
1441
1451
  },
1442
1452
  };
1443
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,14 +311,12 @@ 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.
@@ -5,6 +5,11 @@
5
5
  * xds --json theme build <file>
6
6
  */
7
7
  export type ThemeBuildResponse = {
8
+ /**
9
+ * `warnings` are defects the theme author should fix. `notices` are advisories
10
+ * about a correct theme — most of them cannot be fixed in a theme file at all,
11
+ * so folding them into `warnings` makes a clean build look dirty.
12
+ */
8
13
  type: "theme.build";
9
14
  data: {
10
15
  name: string;
@@ -18,6 +23,7 @@ export type ThemeBuildResponse = {
18
23
  variantsDts?: string;
19
24
  };
20
25
  warnings: string[];
26
+ notices: string[];
21
27
  };
22
28
  };
23
29
  /**
@@ -22,7 +22,10 @@
22
22
  * xds --json theme build <file>
23
23
  * @typedef {object} ThemeBuildResponse
24
24
  * @property {'theme.build'} type
25
- * @property {{name: string, tokenCount: number, componentCount: number, sizeKB: number, outputs: {css: string, js: string, dts: string, variantsDts?: string}, warnings: string[]}} data
25
+ * `warnings` are defects the theme author should fix. `notices` are advisories
26
+ * about a correct theme — most of them cannot be fixed in a theme file at all,
27
+ * so folding them into `warnings` makes a clean build look dirty.
28
+ * @property {{name: string, tokenCount: number, componentCount: number, sizeKB: number, outputs: {css: string, js: string, dts: string, variantsDts?: string}, warnings: string[], notices: string[]}} data
26
29
  */
27
30
 
28
31
  /**
@@ -0,0 +1,20 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'ComplexSelector',
7
+ name: 'ComplexSelector — Deadline Picker',
8
+ displayName: 'Complex Selector — Deadline Picker',
9
+ description:
10
+ 'A multi-step deadline field: pick a preset like Today or Next week, or switch to a custom date and time before applying. The popup stays open until the user commits, so the content owns the Apply action.',
11
+ isReady: true,
12
+ aspectRatio: 4 / 3,
13
+ componentsUsed: [
14
+ 'ComplexSelector',
15
+ 'RadioList',
16
+ 'DateInput',
17
+ 'TimeInput',
18
+ 'Button',
19
+ ],
20
+ };
@@ -0,0 +1,87 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useState} from 'react';
6
+ import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
7
+ import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList';
8
+ import {DateInput} from '@astryxdesign/core/DateInput';
9
+ import {TimeInput} from '@astryxdesign/core/TimeInput';
10
+ import {Button} from '@astryxdesign/core/Button';
11
+ import {VStack} from '@astryxdesign/core/Layout';
12
+
13
+ type ISODate =
14
+ `${number}${number}${number}${number}-${number}${number}-${number}${number}`;
15
+ type ISOTime = string & {readonly __brand: 'ISOTimeString'};
16
+
17
+ interface Deadline {
18
+ preset: 'today' | 'next-week' | 'custom';
19
+ date: ISODate;
20
+ time: ISOTime;
21
+ }
22
+
23
+ const presetLabels: Record<Deadline['preset'], string> = {
24
+ today: 'Today',
25
+ 'next-week': 'Next week',
26
+ custom: 'Custom date',
27
+ };
28
+
29
+ function formatDeadline(value: Deadline) {
30
+ if (value.preset === 'custom') {
31
+ return `${value.date} at ${value.time}`;
32
+ }
33
+ return presetLabels[value.preset];
34
+ }
35
+
36
+ export default function ComplexSelectorDeadlinePicker() {
37
+ const [value, setValue] = useState<Deadline>({
38
+ preset: 'today',
39
+ date: '2026-04-06' as ISODate,
40
+ time: '17:00' as ISOTime,
41
+ });
42
+
43
+ return (
44
+ <ComplexSelector<Deadline>
45
+ label="Deadline"
46
+ description="Choose a preset or set a custom date and time."
47
+ value={value}
48
+ onChange={setValue}
49
+ triggerLabel={formatDeadline(value)}
50
+ style={{width: 320}}>
51
+ {(selectedValue, onChange, close) => {
52
+ const set = (patch: Partial<Deadline>) =>
53
+ onChange({...selectedValue, ...patch});
54
+
55
+ return (
56
+ <VStack gap={4} style={{width: 320}}>
57
+ <RadioList
58
+ label="When is it due?"
59
+ value={selectedValue.preset}
60
+ onChange={preset => set({preset: preset as Deadline['preset']})}>
61
+ <RadioListItem label="Today" value="today" />
62
+ <RadioListItem label="Next week" value="next-week" />
63
+ <RadioListItem label="Custom date" value="custom" />
64
+ </RadioList>
65
+
66
+ {selectedValue.preset === 'custom' && (
67
+ <VStack gap={3}>
68
+ <DateInput
69
+ label="Date"
70
+ value={selectedValue.date}
71
+ onChange={date => date && set({date})}
72
+ />
73
+ <TimeInput
74
+ label="Time"
75
+ value={selectedValue.time}
76
+ onChange={time => time && set({time})}
77
+ />
78
+ </VStack>
79
+ )}
80
+
81
+ <Button label="Apply" variant="primary" onClick={close} />
82
+ </VStack>
83
+ );
84
+ }}
85
+ </ComplexSelector>
86
+ );
87
+ }
@@ -0,0 +1,15 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'ComplexSelector',
7
+ name: 'ComplexSelector',
8
+ displayName: 'Complex Selector',
9
+ description:
10
+ 'A two-axis picker: choose a fruit and a ripeness level from one control. ComplexSelector owns the trigger, popover, and focus restore while the custom grid owns its keyboard semantics.',
11
+ isReady: true,
12
+ aspectRatio: 1,
13
+ isShowcase: true,
14
+ componentsUsed: ['ComplexSelector', 'Text'],
15
+ };
@@ -0,0 +1,199 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useEffect, useState} from 'react';
6
+ import * as stylex from '@stylexjs/stylex';
7
+ import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
8
+ import {Text} from '@astryxdesign/core/Text';
9
+ import {useGridFocus} from '@astryxdesign/core/hooks';
10
+ import {
11
+ borderVars,
12
+ colorVars,
13
+ radiusVars,
14
+ spacingVars,
15
+ } from '@astryxdesign/core/theme/tokens.stylex';
16
+
17
+ type Fruit = 'Apple' | 'Pear' | 'Peach' | 'Plum';
18
+ type Ripeness = 'Crisp' | 'Tender' | 'Juicy' | 'Peak';
19
+
20
+ interface FruitValue {
21
+ fruit: Fruit;
22
+ ripeness: Ripeness;
23
+ }
24
+
25
+ const fruits: Array<{id: Fruit; emoji: string; description: string}> = [
26
+ {id: 'Apple', emoji: '🍎', description: 'Bright and balanced'},
27
+ {id: 'Pear', emoji: '🍐', description: 'Soft floral sweetness'},
28
+ {id: 'Peach', emoji: '🍑', description: 'Round summer flavor'},
29
+ {id: 'Plum', emoji: '🟣', description: 'Jammy and tart'},
30
+ ];
31
+
32
+ const ripenessLevels: Array<{
33
+ id: Ripeness;
34
+ shortLabel: string;
35
+ description: string;
36
+ }> = [
37
+ {id: 'Crisp', shortLabel: 'C', description: 'Snappy bite'},
38
+ {id: 'Tender', shortLabel: 'T', description: 'Easy bite'},
39
+ {id: 'Juicy', shortLabel: 'J', description: 'Full juice'},
40
+ {id: 'Peak', shortLabel: 'P', description: 'Most intense'},
41
+ ];
42
+
43
+ const GRID_CELL_SELECTOR = '[role="gridcell"]';
44
+
45
+ const styles = stylex.create({
46
+ grid: {
47
+ display: 'flex',
48
+ flexDirection: 'column',
49
+ gap: spacingVars['--spacing-1'],
50
+ minWidth: 280,
51
+ },
52
+ row: {
53
+ display: 'grid',
54
+ gridTemplateColumns: 'minmax(150px, 1fr) repeat(4, 44px)',
55
+ alignItems: 'center',
56
+ columnGap: spacingVars['--spacing-1'],
57
+ },
58
+ rowHeader: {
59
+ display: 'flex',
60
+ alignItems: 'center',
61
+ gap: spacingVars['--spacing-2'],
62
+ textAlign: 'start',
63
+ minWidth: 0,
64
+ },
65
+ emoji: {
66
+ fontSize: 18,
67
+ flexShrink: 0,
68
+ },
69
+ fruitText: {
70
+ display: 'flex',
71
+ flexDirection: 'column',
72
+ minWidth: 0,
73
+ },
74
+ cell: {
75
+ display: 'flex',
76
+ alignItems: 'center',
77
+ justifyContent: 'center',
78
+ height: 36,
79
+ borderWidth: borderVars['--border-width'],
80
+ borderStyle: 'solid',
81
+ borderColor: colorVars['--color-border'],
82
+ borderRadius: radiusVars['--radius-container'],
83
+ backgroundColor: colorVars['--color-background-card'],
84
+ color: colorVars['--color-text-secondary'],
85
+ fontFamily: 'inherit',
86
+ cursor: 'pointer',
87
+ ':hover': {
88
+ '@media (hover: hover)': {
89
+ borderColor: colorVars['--color-border-emphasized'],
90
+ color: colorVars['--color-text-primary'],
91
+ },
92
+ },
93
+ },
94
+ cellSelected: {
95
+ borderColor: colorVars['--color-accent'],
96
+ backgroundColor: colorVars['--color-accent'],
97
+ color: colorVars['--color-on-accent'],
98
+ },
99
+ });
100
+
101
+ function FruitRipenessGrid({
102
+ value,
103
+ onChange,
104
+ }: {
105
+ value: FruitValue;
106
+ onChange: (value: FruitValue) => void;
107
+ }) {
108
+ const {gridRef, handleKeyDown, handleFocus, focusCell} =
109
+ useGridFocus<HTMLDivElement>({
110
+ columns: ripenessLevels.length,
111
+ cellSelector: GRID_CELL_SELECTOR,
112
+ hasRovingTabIndex: true,
113
+ });
114
+
115
+ useEffect(() => {
116
+ const rowIndex = fruits.findIndex(f => f.id === value.fruit);
117
+ const columnIndex = ripenessLevels.findIndex(l => l.id === value.ripeness);
118
+ requestAnimationFrame(() => {
119
+ focusCell(
120
+ rowIndex >= 0 && columnIndex >= 0
121
+ ? rowIndex * ripenessLevels.length + columnIndex
122
+ : 0,
123
+ );
124
+ });
125
+ }, [focusCell, value]);
126
+
127
+ return (
128
+ <div
129
+ ref={gridRef}
130
+ role="grid"
131
+ aria-label="Fruit ripeness choices"
132
+ onKeyDown={handleKeyDown}
133
+ onFocus={handleFocus}
134
+ {...stylex.props(styles.grid)}>
135
+ {fruits.map(fruit => (
136
+ <div key={fruit.id} role="row" {...stylex.props(styles.row)}>
137
+ <div role="rowheader" {...stylex.props(styles.rowHeader)}>
138
+ <span aria-hidden="true" {...stylex.props(styles.emoji)}>
139
+ {fruit.emoji}
140
+ </span>
141
+ <span {...stylex.props(styles.fruitText)}>
142
+ <Text type="body">{fruit.id}</Text>
143
+ <Text type="supporting" color="secondary">
144
+ {fruit.description}
145
+ </Text>
146
+ </span>
147
+ </div>
148
+ {ripenessLevels.map(level => {
149
+ const isSelected =
150
+ value.fruit === fruit.id && value.ripeness === level.id;
151
+ return (
152
+ <button
153
+ key={`${fruit.id}-${level.id}`}
154
+ type="button"
155
+ role="gridcell"
156
+ aria-label={`${fruit.id}, ${level.id}: ${level.description}`}
157
+ aria-selected={isSelected || undefined}
158
+ tabIndex={isSelected ? 0 : -1}
159
+ onClick={() => onChange({fruit: fruit.id, ripeness: level.id})}
160
+ {...stylex.props(
161
+ styles.cell,
162
+ isSelected && styles.cellSelected,
163
+ )}>
164
+ {level.shortLabel}
165
+ </button>
166
+ );
167
+ })}
168
+ </div>
169
+ ))}
170
+ </div>
171
+ );
172
+ }
173
+
174
+ export default function ComplexSelectorShowcase() {
175
+ const [value, setValue] = useState<FruitValue>({
176
+ fruit: 'Apple',
177
+ ripeness: 'Juicy',
178
+ });
179
+
180
+ return (
181
+ <ComplexSelector<FruitValue>
182
+ label="Fruit blend"
183
+ description="Choose a fruit and ripeness in one control. Arrow keys move across the grid."
184
+ value={value}
185
+ onChange={setValue}
186
+ triggerLabel={`${value.fruit} · ${value.ripeness}`}
187
+ style={{width: 280}}>
188
+ {(selectedValue, onChange, close) => (
189
+ <FruitRipenessGrid
190
+ value={selectedValue}
191
+ onChange={nextValue => {
192
+ onChange(nextValue);
193
+ close();
194
+ }}
195
+ />
196
+ )}
197
+ </ComplexSelector>
198
+ );
199
+ }
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'ComplexSelector',
7
+ name: 'ComplexSelector — Tree Search',
8
+ displayName: 'Complex Selector — Tree Search',
9
+ description:
10
+ 'A destination picker that combines a search field with a TreeList hierarchy. TreeList owns tree keyboard navigation; ComplexSelector owns the trigger, popover, and focus restore. Selecting a folder closes the popup.',
11
+ isReady: true,
12
+ aspectRatio: 4 / 3,
13
+ componentsUsed: ['ComplexSelector', 'TextInput', 'TreeList'],
14
+ };
@@ -0,0 +1,188 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useMemo, useState} from 'react';
6
+ import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
7
+ import {TextInput} from '@astryxdesign/core/TextInput';
8
+ import {TreeList, type TreeListItemData} from '@astryxdesign/core/TreeList';
9
+ import {Text} from '@astryxdesign/core/Text';
10
+ import {VStack} from '@astryxdesign/core/Layout';
11
+
12
+ interface DestinationNode {
13
+ id: string;
14
+ label: string;
15
+ path: string;
16
+ children?: DestinationNode[];
17
+ }
18
+
19
+ interface Destination {
20
+ id: string;
21
+ label: string;
22
+ path: string;
23
+ }
24
+
25
+ const destinationTree: DestinationNode[] = [
26
+ {
27
+ id: 'workspace',
28
+ label: 'Workspace',
29
+ path: '/Workspace',
30
+ children: [
31
+ {
32
+ id: 'research',
33
+ label: 'Research',
34
+ path: '/Workspace/Research',
35
+ children: [
36
+ {
37
+ id: 'field-notes',
38
+ label: 'Field notes',
39
+ path: '/Workspace/Research/Field notes',
40
+ },
41
+ {
42
+ id: 'interviews',
43
+ label: 'Interviews',
44
+ path: '/Workspace/Research/Interviews',
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ id: 'roadmap',
50
+ label: 'Roadmap',
51
+ path: '/Workspace/Roadmap',
52
+ },
53
+ ],
54
+ },
55
+ {
56
+ id: 'teams',
57
+ label: 'Teams',
58
+ path: '/Teams',
59
+ children: [
60
+ {
61
+ id: 'design-systems',
62
+ label: 'Design systems',
63
+ path: '/Teams/Design systems',
64
+ children: [
65
+ {
66
+ id: 'accessibility',
67
+ label: 'Accessibility',
68
+ path: '/Teams/Design systems/Accessibility',
69
+ },
70
+ ],
71
+ },
72
+ ],
73
+ },
74
+ ];
75
+
76
+ function matches(node: DestinationNode, query: string): boolean {
77
+ if (node.label.toLowerCase().includes(query)) {
78
+ return true;
79
+ }
80
+ return (node.children ?? []).some(child => matches(child, query));
81
+ }
82
+
83
+ function filterTree(
84
+ nodes: DestinationNode[],
85
+ query: string,
86
+ ): DestinationNode[] {
87
+ if (!query) {
88
+ return nodes;
89
+ }
90
+ return nodes
91
+ .filter(node => matches(node, query))
92
+ .map(node => ({
93
+ ...node,
94
+ children: node.children ? filterTree(node.children, query) : undefined,
95
+ }));
96
+ }
97
+
98
+ function toItems(
99
+ nodes: DestinationNode[],
100
+ selectedId: string,
101
+ onSelect: (value: Destination) => void,
102
+ ): TreeListItemData[] {
103
+ return nodes.map(node => {
104
+ const hasChildren = (node.children ?? []).length > 0;
105
+ return {
106
+ id: node.id,
107
+ label: node.label,
108
+ isSelected: node.id === selectedId,
109
+ isExpanded: true,
110
+ onClick: hasChildren
111
+ ? undefined
112
+ : () => onSelect({id: node.id, label: node.label, path: node.path}),
113
+ children: hasChildren
114
+ ? toItems(node.children ?? [], selectedId, onSelect)
115
+ : undefined,
116
+ };
117
+ });
118
+ }
119
+
120
+ function DestinationSearch({
121
+ value,
122
+ onChange,
123
+ close,
124
+ }: {
125
+ value: Destination;
126
+ onChange: (value: Destination) => void;
127
+ close: () => void;
128
+ }) {
129
+ const [query, setQuery] = useState('');
130
+ const items = useMemo(
131
+ () =>
132
+ toItems(
133
+ filterTree(destinationTree, query.toLowerCase()),
134
+ value.id,
135
+ next => {
136
+ onChange(next);
137
+ close();
138
+ },
139
+ ),
140
+ [query, value.id, onChange, close],
141
+ );
142
+
143
+ return (
144
+ <VStack gap={3} style={{width: 360}}>
145
+ <TextInput
146
+ label="Search destinations"
147
+ isLabelHidden
148
+ value={query}
149
+ onChange={setQuery}
150
+ hasClear
151
+ placeholder="Search folders or teams"
152
+ />
153
+ {items.length > 0 ? (
154
+ <TreeList items={items} density="compact" />
155
+ ) : (
156
+ <Text type="supporting" color="secondary">
157
+ No matching destinations.
158
+ </Text>
159
+ )}
160
+ </VStack>
161
+ );
162
+ }
163
+
164
+ export default function ComplexSelectorTreeSearch() {
165
+ const [value, setValue] = useState<Destination>({
166
+ id: 'accessibility',
167
+ label: 'Accessibility',
168
+ path: '/Teams/Design systems/Accessibility',
169
+ });
170
+
171
+ return (
172
+ <ComplexSelector<Destination>
173
+ label="Destination"
174
+ description="Search and browse nested folders."
175
+ value={value}
176
+ onChange={setValue}
177
+ triggerLabel={value.path}
178
+ style={{width: 360}}>
179
+ {(selectedValue, onChange, close) => (
180
+ <DestinationSearch
181
+ value={selectedValue}
182
+ onChange={onChange}
183
+ close={close}
184
+ />
185
+ )}
186
+ </ComplexSelector>
187
+ );
188
+ }
@@ -75,13 +75,16 @@ describe('theme build font-loading warning', () => {
75
75
  expect(result.stdout).toContain('@font-face');
76
76
  expect(result.stdout).toContain('font-display: swap');
77
77
  expect(result.stdout).toContain('astryx docs typography');
78
- // The one-line summaries follow the CLI's stream contract: warnings on
79
- // stderr, like the override-validation warnings in the same build.
80
- expect(result.stderr).toContain('Font "Space Grotesk"');
81
- expect(result.stderr).toContain('Font "JetBrains Mono"');
78
+ // The one-line summaries follow the CLI's stream contract. These are
79
+ // NOTICES about a correct theme, not warnings, so they go to stdout with
80
+ // the rest of the build's progress — stderr stays for the defects an
81
+ // author has to fix.
82
+ expect(result.stdout).toContain('note: Font "Space Grotesk"');
83
+ expect(result.stdout).toContain('note: Font "JetBrains Mono"');
84
+ expect(result.stderr).not.toContain('Font "');
82
85
  });
83
86
 
84
- it('keeps --json stdout one valid envelope: warnings inside, snippet suppressed', async () => {
87
+ it('keeps --json stdout one valid envelope: notices inside, snippet suppressed', async () => {
85
88
  const project = path.join(tmpDir, 'project');
86
89
  const themeFile = writeTheme(
87
90
  project,
@@ -100,7 +103,7 @@ describe('theme build font-loading warning', () => {
100
103
  // contract, not just substring presence.
101
104
  const envelope = JSON.parse(result.stdout);
102
105
  expect(envelope.type).toBe('theme.build');
103
- expect(envelope.data.warnings).toEqual(
106
+ expect(envelope.data.notices).toEqual(
104
107
  expect.arrayContaining([expect.stringContaining('Font "Space Grotesk"')]),
105
108
  );
106
109
  expect(result.stdout).not.toContain('fonts.googleapis.com');
@@ -15,15 +15,34 @@
15
15
  * declared rather than inferred. Absent the flag, output is byte-for-byte what
16
16
  * it was before, which keeps the default no-`--out` flow — where the neighbour
17
17
  * is an uncompiled `icons.tsx` that only a bundler can resolve — working.
18
+ *
19
+ * The spawned-process block at the bottom pins what only real processes can
20
+ * prove: the emitted module actually loads under Node ESM, and the watch
21
+ * loop's child re-invocations carry the flag to every rebuild.
18
22
  */
19
23
 
20
24
  import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
25
+ import {spawn, spawnSync} from 'node:child_process';
21
26
  import * as fs from 'node:fs';
22
27
  import * as path from 'node:path';
23
28
  import * as os from 'node:os';
29
+ import {fileURLToPath, pathToFileURL} from 'node:url';
24
30
  import {ensureCoreBuilt} from './ensure-core-built.mjs';
25
31
  import {runCli} from '../../../test-utils/run-cli.mjs';
26
32
 
33
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
+ const CLI_BIN = path.resolve(__dirname, '../bin/astryx.mjs');
35
+
36
+ /** Poll until `predicate()` is true or the timeout elapses. */
37
+ async function waitFor(predicate, {timeout = 20000, interval = 100} = {}) {
38
+ const start = Date.now();
39
+ for (;;) {
40
+ if (predicate()) return true;
41
+ if (Date.now() - start > timeout) return false;
42
+ await new Promise(r => setTimeout(r, interval));
43
+ }
44
+ }
45
+
27
46
  /**
28
47
  * The emitted icon import, or null. Reads the statement rather than the whole
29
48
  * file: the `@generated` header quotes the source filename and a usage example,
@@ -142,13 +161,7 @@ describe('theme build --icons-specifier', () => {
142
161
  const relativeTheme = path.relative(project, themeFile);
143
162
 
144
163
  const built = await runCli(
145
- [
146
- 'theme',
147
- 'build',
148
- relativeTheme,
149
- '--icons-specifier',
150
- './icons.mjs',
151
- ],
164
+ ['theme', 'build', relativeTheme, '--icons-specifier', './icons.mjs'],
152
165
  project,
153
166
  );
154
167
  expect(built.code).toBe(0);
@@ -223,3 +236,114 @@ describe('theme build --icons-specifier', () => {
223
236
  expect(generated).not.toContain('icons:');
224
237
  });
225
238
  });
239
+
240
+ describe('theme build --icons-specifier (spawned processes)', () => {
241
+ it('emits a module Node can actually load', async () => {
242
+ const project = path.join(tmpDir, 'project');
243
+ const themeFile = writeThemeWithIcons(project, 'loadable');
244
+
245
+ const result = await runCli(
246
+ [
247
+ 'theme',
248
+ 'build',
249
+ path.relative(project, themeFile),
250
+ '--icons-specifier',
251
+ './icons.mjs',
252
+ ],
253
+ project,
254
+ );
255
+ expect(result.code).toBe(0);
256
+
257
+ // The text assertions above prove the emitted line; only a real Node
258
+ // process proves the module resolves and evaluates. That distinction is
259
+ // the regression #4620 shipped: every byte existed, none of them loaded.
260
+ const builtUrl = pathToFileURL(path.join(project, 'loadable.js'));
261
+ const probe = spawnSync(
262
+ process.execPath,
263
+ [
264
+ '--input-type=module',
265
+ '-e',
266
+ `const m = await import(${JSON.stringify(builtUrl.href)});` +
267
+ `if (m.loadableTheme?.name !== 'loadable') throw new Error('bad theme export');` +
268
+ `if (typeof m.testIcons !== 'object') throw new Error('bad registry export');`,
269
+ ],
270
+ {encoding: 'utf8'},
271
+ );
272
+ expect(probe.stderr).toBe('');
273
+ expect(probe.status).toBe(0);
274
+ });
275
+
276
+ it('watch mode forwards the flag to every rebuild', async () => {
277
+ const project = path.join(tmpDir, 'project');
278
+ const themeFile = writeThemeWithIcons(project, 'watched');
279
+ const cssFile = path.join(project, 'out', 'theme.css');
280
+ const builtFile = path.join(project, 'out', 'watched.js');
281
+ const declaredImport = 'import { testIcons } from "./icons.mjs";';
282
+
283
+ const child = spawn(
284
+ process.execPath,
285
+ [
286
+ CLI_BIN,
287
+ 'theme',
288
+ 'build',
289
+ path.relative(project, themeFile),
290
+ '--out',
291
+ 'out/theme.css',
292
+ '--icons-specifier',
293
+ './icons.mjs',
294
+ '--watch',
295
+ ],
296
+ {cwd: project, env: {...process.env, FORCE_COLOR: '0'}},
297
+ );
298
+ let output = '';
299
+ child.stdout.on('data', d => (output += d.toString()));
300
+ child.stderr.on('data', d => (output += d.toString()));
301
+
302
+ try {
303
+ // Initial build: the declared specifier reaches the module.
304
+ expect(await waitFor(() => fs.existsSync(cssFile))).toBe(true);
305
+ expect(await waitFor(() => /Watching/i.test(output))).toBe(true);
306
+ expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
307
+ declaredImport,
308
+ );
309
+
310
+ // Rebuilds run through a child re-invocation of `theme build`, so the
311
+ // flag reaches them only if the watch loop forwards it. Change a token
312
+ // and wait for the rebuilt CSS. fs.watch delivery is best-effort under
313
+ // load, so re-touch until the rebuild shows up (idempotent write).
314
+ const touched =
315
+ `import {testIcons} from './icons';\n` +
316
+ `export default {\n` +
317
+ ` name: "watched",\n` +
318
+ ` icons: testIcons,\n` +
319
+ ` tokens: {'--color-bg': '#0a0b0c'},\n` +
320
+ `};\n`;
321
+ fs.writeFileSync(themeFile, touched);
322
+ const rebuilt = await waitFor(() => {
323
+ try {
324
+ if (fs.readFileSync(cssFile, 'utf-8').includes('#0a0b0c'))
325
+ return true;
326
+ } catch {
327
+ // CSS mid-write; fall through to re-touch.
328
+ }
329
+ try {
330
+ fs.writeFileSync(themeFile, touched);
331
+ } catch {
332
+ // Retried on the next poll.
333
+ }
334
+ return false;
335
+ });
336
+ expect(rebuilt).toBe(true);
337
+
338
+ // The regenerated module still carries the declared specifier — the
339
+ // forwarding is what this test pins. A watch loop that dropped the flag
340
+ // would regenerate with the scraped './icons' here and ship the #4620
341
+ // bytes on every save.
342
+ expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
343
+ declaredImport,
344
+ );
345
+ } finally {
346
+ child.kill('SIGINT');
347
+ }
348
+ }, 60_000);
349
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.4.2-canary.bb07062",
3
+ "version": "0.4.2-canary.da6ea67",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -84,10 +84,10 @@
84
84
  "zod": "^4.4.3"
85
85
  },
86
86
  "peerDependencies": {
87
- "@astryxdesign/charts": "0.4.2-canary.bb07062",
88
- "@astryxdesign/core": "0.4.2-canary.bb07062",
89
- "@astryxdesign/lab": "0.4.2-canary.bb07062",
90
- "@astryxdesign/theme-neutral": "0.4.2-canary.bb07062",
87
+ "@astryxdesign/charts": "0.4.2-canary.da6ea67",
88
+ "@astryxdesign/core": "0.4.2-canary.da6ea67",
89
+ "@astryxdesign/lab": "0.4.2-canary.da6ea67",
90
+ "@astryxdesign/theme-neutral": "0.4.2-canary.da6ea67",
91
91
  "gpt-tokenizer": "^3.4.0"
92
92
  },
93
93
  "peerDependenciesMeta": {
@@ -105,10 +105,10 @@
105
105
  }
106
106
  },
107
107
  "devDependencies": {
108
- "@astryxdesign/charts": "0.4.2-canary.bb07062",
109
- "@astryxdesign/core": "0.4.2-canary.bb07062",
110
- "@astryxdesign/lab": "0.4.2-canary.bb07062",
111
- "@astryxdesign/theme-neutral": "0.4.2-canary.bb07062",
108
+ "@astryxdesign/charts": "0.4.2-canary.da6ea67",
109
+ "@astryxdesign/core": "0.4.2-canary.da6ea67",
110
+ "@astryxdesign/lab": "0.4.2-canary.da6ea67",
111
+ "@astryxdesign/theme-neutral": "0.4.2-canary.da6ea67",
112
112
  "gpt-tokenizer": "^3.4.0"
113
113
  },
114
114
  "scripts": {