@astryxdesign/cli 0.4.2-canary.b2057d1 → 0.4.2-canary.e60974e

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.
@@ -591,6 +591,30 @@ const themeScopeStart = (/** @type {string} */ name) =>
591
591
  `[data-astryx-theme="${name}"]`;
592
592
  const THEME_SCOPE_TO = `[data-astryx-theme]`;
593
593
 
594
+ /**
595
+ * Module extensions the theme loader resolves, source before artifact.
596
+ *
597
+ * `theme build` writes `<name>.js` next to `<name>.ts`, and jiti's default
598
+ * order tries `.js` first — so once a base theme had been built, every sibling
599
+ * theme that `extends` it resolved to that generated artifact instead of the
600
+ * source. The artifact carries no `components` and exports a different name,
601
+ * so the inheritance silently evaporated. Resolving source first is also what
602
+ * the author's TypeScript sees, which is the point: the CSS the build emits
603
+ * matches the theme they type-checked.
604
+ */
605
+ const THEME_MODULE_EXTENSIONS = [
606
+ '.ts',
607
+ '.tsx',
608
+ '.mts',
609
+ '.cts',
610
+ '.mtsx',
611
+ '.ctsx',
612
+ '.mjs',
613
+ '.cjs',
614
+ '.js',
615
+ '.json',
616
+ ];
617
+
594
618
  /**
595
619
  * Import a theme module using jiti and find the defineTheme() result.
596
620
  * Returns the resolved DefinedTheme object.
@@ -601,6 +625,7 @@ async function importThemeModule(filePath) {
601
625
  const jiti = createJiti(import.meta.url, {
602
626
  moduleCache: false,
603
627
  jsx: true,
628
+ extensions: THEME_MODULE_EXTENSIONS,
604
629
  });
605
630
 
606
631
  const mod = await jiti.import(filePath, {default: true});
@@ -733,6 +758,13 @@ function extractIconInfo(filePath) {
733
758
  * Includes the theme name, marker, and re-exports the icon registry.
734
759
  * All styling is in the CSS file.
735
760
  *
761
+ * The module carries the theme's resolved `components` and on-media surfaces
762
+ * alongside its tokens. They are not needed to apply the theme — the CSS holds
763
+ * all of that — but a built theme is a legitimate base for `extends` (the
764
+ * shipped themes expose one as their `./built` subpath), and a base that
765
+ * carries only tokens makes its children silently lose every component
766
+ * override it had.
767
+ *
736
768
  * The icon registry is imported rather than inlined because it holds React
737
769
  * elements, which cannot be serialized. `extractIconInfo` lifts the specifier
738
770
  * out of the TypeScript source, where an extensionless `./icons` is resolved by
@@ -774,6 +806,27 @@ function generateBuiltModule(themeDef, iconInfo, iconsSpecifier) {
774
806
  .map((line, i) => (i === 0 ? line : ' ' + line))
775
807
  .join('\n');
776
808
 
809
+ /**
810
+ * Serialize a resolved theme field as an indented object literal, or '' when
811
+ * there is nothing to emit.
812
+ * @param {string} field
813
+ * @param {unknown} value
814
+ * @returns {string}
815
+ */
816
+ const serializeField = (field, value) => {
817
+ if (!value || Object.keys(value).length === 0) return '';
818
+ const body = JSON.stringify(value, null, 2)
819
+ .split('\n')
820
+ .map((line, i) => (i === 0 ? line : ' ' + line))
821
+ .join('\n');
822
+ return ` ${field}: ${body},\n`;
823
+ };
824
+
825
+ const inheritableFields =
826
+ serializeField('components', themeDef.components) +
827
+ serializeField('__onDark', themeDef.__onDark) +
828
+ serializeField('__onLight', themeDef.__onLight);
829
+
777
830
  return `${iconImport}/**
778
831
  * ${themeDef.name} theme — built by \`${getCliInvocation()} theme build\`
779
832
  * Import the CSS file alongside this module:
@@ -785,7 +838,7 @@ export const ${toIdentifier(themeDef.name)}Theme = {
785
838
  name: '${themeDef.name}',
786
839
  __built: true,
787
840
  tokens: ${tokensStr},
788
- ${iconsField}
841
+ ${inheritableFields}${iconsField}
789
842
  };
790
843
  ${iconReExport}`;
791
844
  }
@@ -1099,20 +1152,29 @@ export async function themeBuild(
1099
1152
  let css;
1100
1153
  let resolvedTheme;
1101
1154
  {
1102
- // jiti returns an already-resolved theme; legacy eval returns raw input.
1103
- const isAlreadyResolved =
1104
- !themeDef.typography && !themeDef.motion && !themeDef.radius;
1105
- if (isAlreadyResolved) {
1106
- resolvedTheme = themeDef;
1155
+ // jiti returns an already-resolved theme; a plain object literal (or the
1156
+ // legacy eval path) returns raw defineTheme input, which still has to go
1157
+ // through the resolver. Detect that by the input-only fields — a resolved
1158
+ // theme has none of them — and hand the WHOLE object over: picking fields
1159
+ // by name is how `extends` (and `color`, and `syntax`) used to be dropped
1160
+ // on the way in.
1161
+ const INPUT_ONLY_FIELDS = [
1162
+ 'extends',
1163
+ 'typography',
1164
+ 'motion',
1165
+ 'radius',
1166
+ 'color',
1167
+ 'syntax',
1168
+ 'onDark',
1169
+ 'onLight',
1170
+ ];
1171
+ const needsResolution = INPUT_ONLY_FIELDS.some(
1172
+ field => themeDef[field] !== undefined,
1173
+ );
1174
+ if (needsResolution) {
1175
+ resolvedTheme = _defineTheme({...themeDef});
1107
1176
  } else {
1108
- resolvedTheme = _defineTheme({
1109
- name: themeDef.name,
1110
- typography: themeDef.typography,
1111
- motion: themeDef.motion,
1112
- radius: themeDef.radius,
1113
- tokens: themeDef.tokens,
1114
- components: themeDef.components,
1115
- });
1177
+ resolvedTheme = themeDef;
1116
1178
  }
1117
1179
  const scopeSelector = themeScopeStart(themeDef.name);
1118
1180
  const scopeTo = THEME_SCOPE_TO;
@@ -323,6 +323,180 @@ describe('themeBuild() — the shipped theme template', () => {
323
323
  expect(fs.existsSync(path.join(tmpDir, 'my-theme.css'))).toBe(true);
324
324
  // The template teaches custom variants; the augmentation it promises the
325
325
  // reader has to actually be generated.
326
- expect(fs.existsSync(path.join(tmpDir, 'my-theme.variants.d.ts'))).toBe(true);
326
+ expect(fs.existsSync(path.join(tmpDir, 'my-theme.variants.d.ts'))).toBe(
327
+ true,
328
+ );
329
+ });
330
+ });
331
+
332
+ describe('themeBuild() — extends', () => {
333
+ // These fixtures `import {defineTheme} from '@astryxdesign/core/theme'` the
334
+ // way a real theme file does, so they have to sit somewhere that specifier
335
+ // resolves — an OS temp dir has no node_modules above it.
336
+ let extDir;
337
+ beforeEach(() => {
338
+ extDir = fs.mkdtempSync(
339
+ path.join(path.resolve(import.meta.dirname, '../../..'), '.tmp-extends-'),
340
+ );
341
+ });
342
+ afterEach(() => {
343
+ fs.rmSync(extDir, {recursive: true, force: true});
344
+ });
345
+
346
+ /**
347
+ * Every `prop: value` a generated stylesheet actually applies. Header
348
+ * comments and scope wrappers are ignored — two themes never share those.
349
+ */
350
+ function declarations(css) {
351
+ return new Set(
352
+ css
353
+ .split('\n')
354
+ .map(l => l.trim())
355
+ .filter(l => /^[-a-z][^{}]*:.+;$/.test(l)),
356
+ );
357
+ }
358
+ /** Every component rule a stylesheet opens, e.g. `.astryx-switch {`. */
359
+ function selectors(css) {
360
+ return new Set(
361
+ css
362
+ .split('\n')
363
+ .map(l => l.trim())
364
+ .filter(l => l.endsWith('{') && l.startsWith('.')),
365
+ );
366
+ }
367
+
368
+ /** A base theme with geometry, elevation and a component override. */
369
+ const BASE_SOURCE = `export const brandTheme = {
370
+ name: 'ext-base',
371
+ tokens: {
372
+ '--radius-element': '6px',
373
+ '--shadow-low': '0 1px 3px rgb(0 0 0 / 0.1)',
374
+ '--color-border-emphasized': '#D4D4D4',
375
+ },
376
+ components: {
377
+ switch: {base: {backgroundColor: 'var(--color-border-emphasized)'}},
378
+ },
379
+ };\n`;
380
+
381
+ /**
382
+ * The child names its base with a plain relative specifier, exactly as a
383
+ * generated palette does. `theme build` writes `ext-base.js` next to
384
+ * `ext-base.mjs`, so `./ext-base` is ambiguous — and the artifact, which
385
+ * exports `extBaseTheme` rather than `brandTheme`, is the wrong answer.
386
+ */
387
+ const CHILD_SOURCE = `import {defineTheme} from '@astryxdesign/core/theme';
388
+ import {brandTheme} from './ext-base';
389
+ export const paletteTheme = defineTheme({
390
+ name: 'ext-child',
391
+ extends: brandTheme,
392
+ tokens: {'--color-accent': 'hsl(220 88% 72%)'},
393
+ });\n`;
394
+
395
+ it('emits every declaration its base emits (the child stylesheet is self-contained)', async () => {
396
+ fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
397
+ fs.writeFileSync(path.join(extDir, 'ext-child.mjs'), CHILD_SOURCE);
398
+
399
+ // Build the base FIRST, as any real project does — that write is what
400
+ // used to poison the child's build.
401
+ await themeBuild('ext-base.mjs', {}, {cwd: extDir});
402
+ await themeBuild('ext-child.mjs', {}, {cwd: extDir});
403
+
404
+ const baseCss = fs.readFileSync(path.join(extDir, 'ext-base.css'), 'utf8');
405
+ const childCss = fs.readFileSync(
406
+ path.join(extDir, 'ext-child.css'),
407
+ 'utf8',
408
+ );
409
+
410
+ const childDecls = declarations(childCss);
411
+ expect([...declarations(baseCss)].filter(d => !childDecls.has(d))).toEqual(
412
+ [],
413
+ );
414
+
415
+ const childSelectors = selectors(childCss);
416
+ expect([...selectors(baseCss)].filter(s => !childSelectors.has(s))).toEqual(
417
+ [],
418
+ );
419
+
420
+ // …and the child's own override still wins.
421
+ expect(childCss).toContain('--color-accent: hsl(220 88% 72%);');
422
+ });
423
+
424
+ it('resolves the base from its source, not from the generated sibling artifact', async () => {
425
+ fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
426
+ fs.writeFileSync(path.join(extDir, 'ext-child.mjs'), CHILD_SOURCE);
427
+
428
+ await themeBuild('ext-base.mjs', {}, {cwd: extDir});
429
+ const result = await themeBuild('ext-child.mjs', {}, {cwd: extDir});
430
+
431
+ expect(result?.data.componentCount).toBe(1);
432
+ expect(result?.data.tokenCount).toBe(4);
433
+ });
434
+
435
+ it('inherits component overrides when the base IS a built theme module', async () => {
436
+ fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
437
+ await themeBuild('ext-base.mjs', {}, {cwd: extDir});
438
+
439
+ // Extending a package's pre-built theme module (e.g. the `./built`
440
+ // subpath the shipped themes expose) must not silently drop its
441
+ // component overrides.
442
+ fs.writeFileSync(
443
+ path.join(extDir, 'ext-built-child.mjs'),
444
+ `import {defineTheme} from '@astryxdesign/core/theme';
445
+ import {extBaseTheme} from './ext-base.js';
446
+ export const builtChildTheme = defineTheme({
447
+ name: 'ext-built-child',
448
+ extends: extBaseTheme,
449
+ });\n`,
450
+ );
451
+
452
+ await themeBuild('ext-built-child.mjs', {}, {cwd: extDir});
453
+ const css = fs.readFileSync(
454
+ path.join(extDir, 'ext-built-child.css'),
455
+ 'utf8',
456
+ );
457
+
458
+ expect(css).toContain('.astryx-switch {');
459
+ expect(css).toContain('--radius-element: 6px;');
460
+ });
461
+
462
+ it('resolves extends on a plain object theme file (no defineTheme call)', async () => {
463
+ fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
464
+ fs.writeFileSync(
465
+ path.join(extDir, 'ext-plain.mjs'),
466
+ `import {brandTheme} from './ext-base.mjs';
467
+ export default {
468
+ name: 'ext-plain',
469
+ extends: brandTheme,
470
+ tokens: {'--color-accent': '#ff0000'},
471
+ };\n`,
472
+ );
473
+
474
+ await themeBuild('ext-base.mjs', {}, {cwd: extDir});
475
+ await themeBuild('ext-plain.mjs', {}, {cwd: extDir});
476
+
477
+ const css = fs.readFileSync(path.join(extDir, 'ext-plain.css'), 'utf8');
478
+ expect(css).toContain('--radius-element: 6px;');
479
+ expect(css).toContain('.astryx-switch {');
480
+ });
481
+
482
+ it('fails loudly when the base import resolved to nothing', async () => {
483
+ fs.writeFileSync(
484
+ path.join(extDir, 'ext-broken.mjs'),
485
+ `import {defineTheme} from '@astryxdesign/core/theme';
486
+ import {notAThing} from './ext-missing.mjs';
487
+ export const brokenTheme = defineTheme({
488
+ name: 'ext-broken',
489
+ extends: notAThing,
490
+ tokens: {'--color-accent': '#ff0000'},
491
+ });\n`,
492
+ );
493
+ fs.writeFileSync(
494
+ path.join(extDir, 'ext-missing.mjs'),
495
+ `export const somethingElse = 1;\n`,
496
+ );
497
+
498
+ await expect(
499
+ themeBuild('ext-broken.mjs', {}, {cwd: extDir}),
500
+ ).rejects.toThrow(/extends/);
327
501
  });
328
502
  });
@@ -253,10 +253,15 @@ const brandTheme = defineTheme({
253
253
  ['tokens', 'Base tokens are copied first, then child tokens override on top.'],
254
254
  ['components', 'Deep-merged: child component rules override matching keys from the base.'],
255
255
  ['icons', 'Shallow-merged: child icons override matching names from the base.'],
256
- ['fonts', 'Base fonts included first, then child fonts appended.'],
256
+ ['indicators', 'Shallow-merged: child indicators override matching names from the base.'],
257
+ ['onDark, onLight', "Deep-merged per surface: the base's resolved surface first, then the child's overrides."],
257
258
  ['typography, motion, radius, color', 'Child config replaces base entirely (these are scale inputs, not additive).'],
258
259
  ],
259
260
  },
261
+ {
262
+ type: 'prose',
263
+ text: 'Inheritance is resolved when the theme is defined, so an extended theme is flat: `astryx theme build` emits one self-contained stylesheet holding everything the child inherited, and the base theme\'s CSS does not need to be loaded next to it. A base that is not a theme — most often an import that missed — is a build error rather than a theme that silently inherits nothing.',
264
+ },
260
265
  ],
261
266
  },
262
267
  {
@@ -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
+ }
@@ -154,9 +154,9 @@ export function sanitizeName(name, options = {}) {
154
154
  );
155
155
  }
156
156
 
157
- if (name === '.' || name === '..' || name.startsWith('..')) {
157
+ if (name === '.' || name === '..' || name.startsWith('.')) {
158
158
  throw new PathSafetyError(
159
- `Invalid ${label} "${name}": must not be '.' or start with '..'.`,
159
+ `Invalid ${label} "${name}": must not start with '.'.`,
160
160
  'NAME_TRAVERSAL',
161
161
  );
162
162
  }
@@ -91,6 +91,13 @@ describe('sanitizeName', () => {
91
91
  expect(() => sanitizeName('.')).toThrow(PathSafetyError);
92
92
  });
93
93
 
94
+ it('rejects dotfile names (.env, .htaccess) that could create hidden files', () => {
95
+ expect(() => sanitizeName('.env')).toThrow(PathSafetyError);
96
+ expect(() => sanitizeName('.htaccess')).toThrow(PathSafetyError);
97
+ expect(() => sanitizeName('.bashrc')).toThrow(PathSafetyError);
98
+ expect(() => sanitizeName('.gitignore')).toThrow(PathSafetyError);
99
+ });
100
+
94
101
  it('rejects NUL bytes', () => {
95
102
  expect(() => sanitizeName('foo\0bar')).toThrow(PathSafetyError);
96
103
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.4.2-canary.b2057d1",
3
+ "version": "0.4.2-canary.e60974e",
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.b2057d1",
88
- "@astryxdesign/core": "0.4.2-canary.b2057d1",
89
- "@astryxdesign/lab": "0.4.2-canary.b2057d1",
90
- "@astryxdesign/theme-neutral": "0.4.2-canary.b2057d1",
87
+ "@astryxdesign/charts": "0.4.2-canary.e60974e",
88
+ "@astryxdesign/core": "0.4.2-canary.e60974e",
89
+ "@astryxdesign/lab": "0.4.2-canary.e60974e",
90
+ "@astryxdesign/theme-neutral": "0.4.2-canary.e60974e",
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.b2057d1",
109
- "@astryxdesign/core": "0.4.2-canary.b2057d1",
110
- "@astryxdesign/lab": "0.4.2-canary.b2057d1",
111
- "@astryxdesign/theme-neutral": "0.4.2-canary.b2057d1",
108
+ "@astryxdesign/charts": "0.4.2-canary.e60974e",
109
+ "@astryxdesign/core": "0.4.2-canary.e60974e",
110
+ "@astryxdesign/lab": "0.4.2-canary.e60974e",
111
+ "@astryxdesign/theme-neutral": "0.4.2-canary.e60974e",
112
112
  "gpt-tokenizer": "^3.4.0"
113
113
  },
114
114
  "scripts": {