@astryxdesign/cli 0.4.2 → 0.4.3-canary.00bed29

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 (69) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +45 -43
  3. package/api/discover/_adapter.d.mts +5 -3
  4. package/api/discover/_adapter.mjs +6 -4
  5. package/api/template/cdn/cdn.d.mts +23 -0
  6. package/api/template/cdn/cdn.mjs +86 -0
  7. package/api/template/cdn/cdn.test.mjs +108 -0
  8. package/api/template/template.d.mts +2 -0
  9. package/api/template/template.doc.mjs +21 -3
  10. package/api/template/template.mjs +16 -2
  11. package/api/template/template.type.d.mts +19 -0
  12. package/api/template/template.type.mjs +12 -0
  13. package/api/theme/build/build.font-warning.test.mjs +23 -14
  14. package/api/theme/build/build.icons-specifier.test.mjs +149 -0
  15. package/api/theme/build/build.mjs +88 -16
  16. package/api/theme/build/build.test.mjs +185 -14
  17. package/api/theme/theme.type.d.mts +22 -0
  18. package/api/theme/theme.type.mjs +15 -1
  19. package/assets/cdn.template.html +124 -0
  20. package/assets/docs/theme.doc.dense.mjs +1 -1
  21. package/assets/docs/theme.doc.mjs +11 -5
  22. package/assets/docs/theme.doc.zh.mjs +1 -1
  23. package/assets/templates/blocks/components/BottomSheet/BottomSheetHeights.doc.mjs +21 -0
  24. package/assets/templates/blocks/components/BottomSheet/BottomSheetHeights.tsx +46 -0
  25. package/assets/templates/blocks/components/BottomSheet/BottomSheetMobileKeyboard.doc.mjs +23 -0
  26. package/assets/templates/blocks/components/BottomSheet/BottomSheetMobileKeyboard.tsx +100 -0
  27. package/assets/templates/blocks/components/BottomSheet/BottomSheetNoScrim.doc.mjs +22 -0
  28. package/assets/templates/blocks/components/BottomSheet/BottomSheetNoScrim.tsx +47 -0
  29. package/assets/templates/blocks/components/BottomSheet/BottomSheetShowcase.doc.mjs +22 -0
  30. package/assets/templates/blocks/components/BottomSheet/BottomSheetShowcase.tsx +51 -0
  31. package/assets/templates/blocks/components/BottomSheet/BottomSheetSwitcherShowcase.doc.mjs +26 -0
  32. package/assets/templates/blocks/components/BottomSheet/BottomSheetSwitcherShowcase.tsx +221 -0
  33. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs +20 -0
  34. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.tsx +87 -0
  35. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.doc.mjs +15 -0
  36. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.tsx +199 -0
  37. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.doc.mjs +14 -0
  38. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.tsx +188 -0
  39. package/assets/templates/themes/neutral/neutralTheme.ts +6 -4
  40. package/assets/theme.template.ts +4 -3
  41. package/authoring/doctypes/base/type.ts +9 -0
  42. package/clients/cli/commands/build-theme.font-warning.test.mjs +9 -6
  43. package/clients/cli/commands/build-theme.icons-specifier.test.mjs +132 -7
  44. package/clients/cli/commands/build-theme.mjs +154 -65
  45. package/clients/cli/commands/build-theme.multi.test.mjs +148 -0
  46. package/clients/cli/commands/build-theme.watch.test.mjs +67 -0
  47. package/clients/cli/commands/discover.broken-integration.test.mjs +112 -0
  48. package/clients/cli/commands/discover.mjs +12 -0
  49. package/clients/cli/commands/search.mjs +9 -0
  50. package/clients/cli/commands/template-cdn.behavior.test.mjs +113 -0
  51. package/clients/cli/commands/template.doc.mjs +12 -2
  52. package/clients/cli/commands/template.mjs +24 -3
  53. package/clients/cli/commands/theme-build.doc.mjs +11 -5
  54. package/clients/cli/lib/json-shim.test.mjs +1 -1
  55. package/clients/cli/lib/manifest.mjs +3 -2
  56. package/clients/cli/lib/manifest.test.mjs +4 -2
  57. package/foundation/fs/path-safety.mjs +2 -2
  58. package/foundation/fs/path-safety.test.mjs +7 -0
  59. package/foundation/integrations/integration-warnings.test.mjs +17 -0
  60. package/foundation/integrations/validate-contributions.mjs +7 -0
  61. package/foundation/response/response-types.doc.mjs +11 -0
  62. package/foundation/text/copyright-header.mjs +11 -4
  63. package/package.json +12 -9
  64. package/assets/templates/pages/table-page-chart/page.tsx +0 -577
  65. package/assets/templates/pages/table-page-chart/template.doc.mjs +0 -13
  66. package/assets/templates/pages/table-page-heatmap-status/page.tsx +0 -467
  67. package/assets/templates/pages/table-page-heatmap-status/template.doc.mjs +0 -13
  68. package/assets/templates/pages/table-page-shoe-store-heatmap/page.tsx +0 -931
  69. package/assets/templates/pages/table-page-shoe-store-heatmap/template.doc.mjs +0 -13
@@ -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
+ }
@@ -528,15 +528,17 @@ export const neutralTheme = defineTheme({
528
528
  // to a deep tinted bg + light text rather than locking the
529
529
  // light-mode pastel.
530
530
  //
531
- // The inner-header *-muted token is forced transparent so the outer
532
- // tinted background shows through cleanly.
531
+ // The inner-header *-muted token carries the tinted background for every
532
+ // status, info included. A theme override that sets a plain CSS property
533
+ // instead lands in @layer astryx-theme, which StyleX's @layer priority4
534
+ // outranks, so `backgroundColor` here would silently do nothing and the
535
+ // info banner would paint no background at all.
533
536
  //
534
537
  // Status overrides reference --color-text-{hue} so text/icon colors
535
538
  // stay in sync with the palette anchors automatically.
536
539
  banner: {
537
540
  'status:info': {
538
- backgroundColor: 'var(--color-background-blue)',
539
- '--color-accent-muted': 'transparent',
541
+ '--color-accent-muted': 'var(--color-background-blue)',
540
542
  '--color-text-primary': 'var(--color-text-blue)',
541
543
  '--color-text-secondary': 'var(--color-text-blue)',
542
544
  '--color-accent': 'var(--color-text-blue)',
@@ -105,12 +105,13 @@ export const myTheme = defineTheme({
105
105
  // ───────────────────────────────────────────────────────────────────────
106
106
 
107
107
  /**
108
- * Generates the neutral ramp and the accent tokens from one seed colour
108
+ * Generates the neutral ramp and the accent tokens from a seed colour
109
109
  * using the HCT perceptual model: surfaces, text, icons, borders, muted
110
110
  * fills, hover and pressed overlays — light and dark both.
111
111
  *
112
- * accent seed hex; omit to keep the default accent and re-tone only
113
- * the neutrals
112
+ * accent seed hex, or a [light, dark] pair to seed each scheme's
113
+ * palette from its own colour; omit to keep the default
114
+ * accent and re-tone only the neutrals
114
115
  * neutralStyle 'warm' | 'cool' | 'neutral' — the temperature of the greys
115
116
  * contrast 'standard' | 'high' — 'high' widens the text/surface tone
116
117
  * gap, for dense data UI or bright and clinical screens
@@ -117,6 +117,15 @@ export interface ComponentPlaygroundConfig {
117
117
  * the component is visible on load and knobs stay usable, whereas a real
118
118
  * top-layer modal makes the rest of the page inert (#3657). */
119
119
  overlay?: boolean;
120
+ /** Override the controlled prop and open value used by an overlay preview.
121
+ * Use with `overlay: true` when the component does not use `isOpen: true`
122
+ * to represent its open state. */
123
+ overlayControl?: {
124
+ /** Controlled prop that determines whether the overlay is open. */
125
+ stateProp: string;
126
+ /** Value assigned to stateProp when the preview's open button is used. */
127
+ openValue: unknown;
128
+ };
120
129
  /** The component reads AppShell mobile context and renders nothing
121
130
  * without it (e.g. `MobileNavToggle` returns null unless the context
122
131
  * reports an enabled mobile viewport — the default value outside
@@ -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,115 @@ 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 JavaScript module. fs.watch delivery is
313
+ // best-effort under load, so re-touch until the rebuild shows up
314
+ // (idempotent write).
315
+ const touched =
316
+ `import {testIcons} from './icons';\n` +
317
+ `export default {\n` +
318
+ ` name: "watched",\n` +
319
+ ` icons: testIcons,\n` +
320
+ ` tokens: {'--color-bg': '#0a0b0c'},\n` +
321
+ `};\n`;
322
+ fs.writeFileSync(themeFile, touched);
323
+ const rebuilt = await waitFor(() => {
324
+ try {
325
+ if (fs.readFileSync(builtFile, 'utf-8').includes('#0a0b0c'))
326
+ return true;
327
+ } catch {
328
+ // JavaScript module mid-write; fall through to re-touch.
329
+ }
330
+ try {
331
+ fs.writeFileSync(themeFile, touched);
332
+ } catch {
333
+ // Retried on the next poll.
334
+ }
335
+ return false;
336
+ });
337
+ expect(rebuilt).toBe(true);
338
+
339
+ // The regenerated module still carries the declared specifier — the
340
+ // forwarding is what this test pins. A watch loop that dropped the flag
341
+ // would regenerate with the scraped './icons' here and ship the #4620
342
+ // bytes on every save.
343
+ expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
344
+ declaredImport,
345
+ );
346
+ } finally {
347
+ child.kill('SIGINT');
348
+ }
349
+ }, 60_000);
350
+ });