@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.
@@ -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
  /**
@@ -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
+ }
@@ -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)',
@@ -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');