@astryxdesign/cli 0.4.2 → 0.4.3-canary.d9189c9
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.
- package/CHANGELOG.md +23 -0
- package/api/theme/build/build.font-warning.test.mjs +23 -14
- package/api/theme/build/build.icons-specifier.test.mjs +149 -0
- package/api/theme/build/build.mjs +88 -16
- package/api/theme/build/build.test.mjs +185 -14
- package/api/theme/theme.type.d.mts +6 -0
- package/api/theme/theme.type.mjs +4 -1
- package/assets/docs/theme.doc.mjs +6 -1
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs +20 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.tsx +87 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.doc.mjs +15 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.tsx +199 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.doc.mjs +14 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.tsx +188 -0
- package/assets/templates/themes/neutral/neutralTheme.ts +6 -4
- package/clients/cli/commands/build-theme.font-warning.test.mjs +9 -6
- package/clients/cli/commands/build-theme.icons-specifier.test.mjs +132 -7
- package/foundation/fs/path-safety.mjs +2 -2
- package/foundation/fs/path-safety.test.mjs +7 -0
- package/package.json +9 -9
|
@@ -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
|
/**
|
package/api/theme/theme.type.mjs
CHANGED
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* xds --json theme build <file>
|
|
23
23
|
* @typedef {object} ThemeBuildResponse
|
|
24
24
|
* @property {'theme.build'} type
|
|
25
|
-
*
|
|
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
|
/**
|
|
@@ -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
|
-
['
|
|
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
|
{
|
package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs
ADDED
|
@@ -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
|
+
}
|
|
@@ -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
|
|
532
|
-
//
|
|
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
|
-
|
|
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)',
|
|
@@ -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
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
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.
|
|
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');
|