@astryxdesign/cli 0.5.0-canary.32a62fd → 0.5.0-canary.408cef2
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/api/template/data-token-fallbacks.test.mjs +85 -0
- package/api/theme/build/build.mjs +28 -2
- package/api/theme/themeTargets.doc.mjs +3 -3
- package/assets/templates/blocks/components/Step/StepContent.doc.mjs +1 -1
- package/assets/templates/blocks/components/Stepper/StepperCustomContent.doc.mjs +1 -1
- package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +1 -1
- package/assets/templates/pages/dashboard-data/page.tsx +1 -1
- package/assets/templates/pages/dashboard-portfolio/page.tsx +5 -5
- package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1 -1
- package/assets/templates/pages/theme-showcase/page.tsx +36 -17
- package/assets/templates/themes/stone/stoneTheme.ts +3 -1
- package/clients/cli/commands/build-theme.color-scheme.test.mjs +7 -6
- package/clients/cli/commands/build-theme.data-tokens.test.mjs +115 -0
- package/clients/cli/commands/build-theme.variants.test.mjs +3 -0
- package/package.json +9 -9
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Asserts every `var(--color-data-*, <hex>)` fallback in the dashboard
|
|
5
|
+
* templates equals the token's own default.
|
|
6
|
+
*
|
|
7
|
+
* The fallback is what paints when the token does not resolve, so a fallback
|
|
8
|
+
* that differs from its token makes the same chart series two different colours
|
|
9
|
+
* depending on whether a theme is present. Three of them had drifted
|
|
10
|
+
* (`#22c55e`, `#E5484D`, `#008E80`), which is also why "the defaults are
|
|
11
|
+
* byte-identical to the template fallbacks" was not true.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {describe, it, expect} from 'vitest';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import {fileURLToPath} from 'node:url';
|
|
18
|
+
import {dataTokenDefaults} from '@astryxdesign/core/theme';
|
|
19
|
+
|
|
20
|
+
const TEMPLATES_DIR = path.resolve(
|
|
21
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
22
|
+
'../../assets/templates',
|
|
23
|
+
);
|
|
24
|
+
const CALL_SITE = /var\(\s*(--color-data-[a-z0-9-]+)\s*,\s*([^)]+?)\s*\)/g;
|
|
25
|
+
|
|
26
|
+
/** The light side of a `light-dark(a, b)` pair — what a template falls back to. */
|
|
27
|
+
function lightSide(value) {
|
|
28
|
+
const match = /^light-dark\(\s*([^,]+?)\s*,/.exec(value);
|
|
29
|
+
return match ? match[1] : value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function walk(dir) {
|
|
33
|
+
return fs.readdirSync(dir, {withFileTypes: true}).flatMap(entry => {
|
|
34
|
+
const full = path.join(dir, entry.name);
|
|
35
|
+
return entry.isDirectory() ? walk(full) : [full];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function callSites() {
|
|
40
|
+
const sites = [];
|
|
41
|
+
for (const file of walk(TEMPLATES_DIR)) {
|
|
42
|
+
if (!/\.(tsx|ts|jsx|js|mjs|css)$/.test(file)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
fs.readFileSync(file, 'utf-8')
|
|
46
|
+
.split('\n')
|
|
47
|
+
.forEach((line, index) => {
|
|
48
|
+
for (const match of line.matchAll(CALL_SITE)) {
|
|
49
|
+
sites.push({
|
|
50
|
+
where: `${path.relative(TEMPLATES_DIR, file)}:${index + 1}`,
|
|
51
|
+
token: match[1],
|
|
52
|
+
fallback: match[2],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return sites;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('template --color-data-* fallbacks', () => {
|
|
61
|
+
const sites = callSites();
|
|
62
|
+
|
|
63
|
+
it('finds the call sites at all', () => {
|
|
64
|
+
expect(sites.length).toBeGreaterThan(40);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('names a real token at every call site', () => {
|
|
68
|
+
const unknown = sites.filter(site => !dataTokenDefaults[site.token]);
|
|
69
|
+
expect(unknown.map(site => `${site.where} ${site.token}`)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('matches each token default exactly', () => {
|
|
73
|
+
const drifted = sites
|
|
74
|
+
.filter(site => dataTokenDefaults[site.token])
|
|
75
|
+
.filter(
|
|
76
|
+
site => site.fallback !== lightSide(dataTokenDefaults[site.token]),
|
|
77
|
+
)
|
|
78
|
+
.map(
|
|
79
|
+
site =>
|
|
80
|
+
`${site.where} ${site.token}: ${site.fallback} != ${lightSide(dataTokenDefaults[site.token])}`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(drifted).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -62,12 +62,14 @@ import {
|
|
|
62
62
|
/** @type {any} */ let _defineTheme = null;
|
|
63
63
|
/** @type {any} */ let _generateThemeRulesSplit = null;
|
|
64
64
|
/** @type {any} */ let _generateOnMediaCSS = null;
|
|
65
|
+
/** @type {any} */ let _generateThemeCSS = null;
|
|
65
66
|
/** @type {any} */ let _coreImportError = null;
|
|
66
67
|
try {
|
|
67
68
|
const coreTheme = await import('@astryxdesign/core/theme');
|
|
68
69
|
_defineTheme = coreTheme.defineTheme;
|
|
69
70
|
_generateThemeRulesSplit = coreTheme.generateThemeRulesSplit;
|
|
70
71
|
_generateOnMediaCSS = coreTheme.generateOnMediaCSS;
|
|
72
|
+
_generateThemeCSS = coreTheme.generateThemeCSS;
|
|
71
73
|
} catch (e) {
|
|
72
74
|
// Capture the reason so the theme action can surface a precise, actionable
|
|
73
75
|
// error. We don't throw here: this module is imported eagerly by the CLI
|
|
@@ -1166,8 +1168,15 @@ export async function themeBuild(
|
|
|
1166
1168
|
if (component.length > 0) {
|
|
1167
1169
|
const componentInner = component.join('\n\n');
|
|
1168
1170
|
const componentScope = `@scope (${scopeSelector}) to (${scopeTo}) {\n${componentInner}\n}`;
|
|
1169
|
-
// #3658: also emit attribute-specific rules so <Theme mode> can override color-scheme
|
|
1170
|
-
|
|
1171
|
+
// #3658: also emit attribute-specific rules so <Theme mode> can override color-scheme.
|
|
1172
|
+
// Decided from the theme's own values, not the generated CSS: that CSS
|
|
1173
|
+
// also carries the data-token defaults, which are light-dark() pairs, so
|
|
1174
|
+
// a substring check on it would fire for every theme.
|
|
1175
|
+
const themeOwnValues = JSON.stringify([
|
|
1176
|
+
resolvedTheme.tokens ?? {},
|
|
1177
|
+
resolvedTheme.components ?? {},
|
|
1178
|
+
]);
|
|
1179
|
+
const colorSchemeDecl = themeOwnValues.includes('light-dark(')
|
|
1171
1180
|
? ' :root { color-scheme: light dark; }\n html[data-theme="light"] { color-scheme: light; }\n html[data-theme="dark"] { color-scheme: dark; }\n\n'
|
|
1172
1181
|
: '';
|
|
1173
1182
|
cssParts.push(
|
|
@@ -1185,6 +1194,23 @@ export async function themeBuild(
|
|
|
1185
1194
|
logger.log('No overrides found — nothing to build.');
|
|
1186
1195
|
return null;
|
|
1187
1196
|
}
|
|
1197
|
+
// The data-token defaults are theme-independent and go in @layer
|
|
1198
|
+
// astryx-base, below the theme's own overrides. Taken from the runtime's
|
|
1199
|
+
// own generator so the two paths cannot emit different bytes.
|
|
1200
|
+
// Placed after the reset block and before the theme block: a layer's order
|
|
1201
|
+
// is fixed by where it is first declared, so emitting it anywhere else in
|
|
1202
|
+
// the file would invert reset < astryx-base < astryx-theme for a consumer
|
|
1203
|
+
// who imports this stylesheet on its own.
|
|
1204
|
+
const baseCss = _generateThemeCSS
|
|
1205
|
+
? _generateThemeCSS(resolvedTheme).base
|
|
1206
|
+
: '';
|
|
1207
|
+
if (baseCss) {
|
|
1208
|
+
cssParts.splice(
|
|
1209
|
+
prose.length > 0 ? 1 : 0,
|
|
1210
|
+
0,
|
|
1211
|
+
`@layer astryx-base {\n${baseCss}\n}`,
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1188
1214
|
css = cssParts.join('\n\n') + '\n';
|
|
1189
1215
|
}
|
|
1190
1216
|
|
|
@@ -18,9 +18,9 @@ export const doc = {
|
|
|
18
18
|
'Enumerates the whole themeable surface: each `defineTheme` components key, the stable ' +
|
|
19
19
|
'class it paints, the component that declares it, and the props and states that are legal ' +
|
|
20
20
|
'override keys under it. Same source as the Theming table `astryx component <Name>` prints ' +
|
|
21
|
-
'
|
|
21
|
+
'(the component docs), so the list cannot drift from the components, and `theme build` ' +
|
|
22
22
|
'validates overrides against this exact set. A filter naming a component gives that ' +
|
|
23
|
-
'component\
|
|
23
|
+
'component\'s set; anything else is a substring search over the keys.',
|
|
24
24
|
importPath: '@astryxdesign/cli/api',
|
|
25
25
|
signature:
|
|
26
26
|
'themeTargets(filter?: string, ctx?: {cwd?: string}): Promise<ThemeTargetsResponse>',
|
|
@@ -35,7 +35,7 @@ export const doc = {
|
|
|
35
35
|
{
|
|
36
36
|
name: 'ctx.cwd',
|
|
37
37
|
type: 'string',
|
|
38
|
-
description: 'Directory the project
|
|
38
|
+
description: 'Directory the project\'s @astryxdesign/core is resolved from.',
|
|
39
39
|
},
|
|
40
40
|
],
|
|
41
41
|
returns: [
|
|
@@ -7,7 +7,7 @@ export const doc = {
|
|
|
7
7
|
name: 'Step — Content Slot',
|
|
8
8
|
displayName: 'Step — Content Slot',
|
|
9
9
|
description:
|
|
10
|
-
'Children passed to a Step render below its description, indented to line up with the label rather than the indicator, and stay outside the clickable label area so buttons inside remain their own targets. In a full flow you gate the slot on the step being active
|
|
10
|
+
'Children passed to a Step render below its description, indented to line up with the label rather than the indicator, and stay outside the clickable label area so buttons inside remain their own targets. In a full flow you gate the slot on the step being active. That is what turns a vertical stepper into an expanding one.',
|
|
11
11
|
isReady: true,
|
|
12
12
|
aspectRatio: 4 / 3,
|
|
13
13
|
componentsUsed: ['Stepper', 'Step', 'TextInput', 'Button'],
|
|
@@ -7,7 +7,7 @@ export const doc = {
|
|
|
7
7
|
name: 'Stepper — Custom Content',
|
|
8
8
|
displayName: 'Stepper — Custom Content',
|
|
9
9
|
description:
|
|
10
|
-
'A vertical stepper where each step owns a slice of the page. The content slot takes any node
|
|
10
|
+
'A vertical stepper where each step owns a slice of the page. The content slot takes any node (form fields, a summary panel, a banner), so a stepper is not limited to multi-step forms. Rendering the slot only for the active step is what makes the flow expand one step at a time.',
|
|
11
11
|
isReady: true,
|
|
12
12
|
aspectRatio: 4 / 3,
|
|
13
13
|
componentsUsed: [
|
|
@@ -79,7 +79,7 @@ const COLORS = {
|
|
|
79
79
|
green: 'var(--color-data-categorical-green, #0B991F)',
|
|
80
80
|
orange: 'var(--color-data-categorical-orange, #EB6E00)',
|
|
81
81
|
purple: 'var(--color-data-categorical-purple, #6B1EFD)',
|
|
82
|
-
teal: 'var(--color-data-categorical-teal, #
|
|
82
|
+
teal: 'var(--color-data-categorical-teal, #08A3A3)',
|
|
83
83
|
};
|
|
84
84
|
const GRID_STROKE = 'var(--color-border, rgba(5, 54, 89, 0.1))';
|
|
85
85
|
const AXIS_TICK = {
|
|
@@ -98,7 +98,7 @@ const COLORS = {
|
|
|
98
98
|
green: 'var(--color-data-categorical-green, #0B991F)',
|
|
99
99
|
orange: 'var(--color-data-categorical-orange, #EB6E00)',
|
|
100
100
|
purple: 'var(--color-data-categorical-purple, #6B1EFD)',
|
|
101
|
-
teal: 'var(--color-data-categorical-teal, #
|
|
101
|
+
teal: 'var(--color-data-categorical-teal, #08A3A3)',
|
|
102
102
|
neutral: 'var(--color-data-neutral, #8494A3)',
|
|
103
103
|
};
|
|
104
104
|
const GRID_STROKE = 'var(--color-border, rgba(5, 54, 89, 0.1))';
|
|
@@ -370,12 +370,12 @@ function PortfolioChart() {
|
|
|
370
370
|
<linearGradient id="portfolioGradient" x1="0" y1="0" x2="0" y2="1">
|
|
371
371
|
<stop
|
|
372
372
|
offset="5%"
|
|
373
|
-
stopColor="var(--color-data-categorical-green, #
|
|
373
|
+
stopColor="var(--color-data-categorical-green, #0B991F)"
|
|
374
374
|
stopOpacity={0.3}
|
|
375
375
|
/>
|
|
376
376
|
<stop
|
|
377
377
|
offset="95%"
|
|
378
|
-
stopColor="var(--color-data-categorical-green, #
|
|
378
|
+
stopColor="var(--color-data-categorical-green, #0B991F)"
|
|
379
379
|
stopOpacity={0.05}
|
|
380
380
|
/>
|
|
381
381
|
</linearGradient>
|
|
@@ -417,7 +417,7 @@ function PortfolioChart() {
|
|
|
417
417
|
<Area
|
|
418
418
|
type="linear"
|
|
419
419
|
dataKey="value"
|
|
420
|
-
stroke="var(--color-data-categorical-green, #
|
|
420
|
+
stroke="var(--color-data-categorical-green, #0B991F)"
|
|
421
421
|
strokeWidth={1.5}
|
|
422
422
|
fill="url(#portfolioGradient)"
|
|
423
423
|
dot={false}
|
|
@@ -434,7 +434,7 @@ function Sparkline({data, positive}: {data: number[]; positive: boolean}) {
|
|
|
434
434
|
const chartData = data.map((v, i) => ({i, v}));
|
|
435
435
|
const color = positive
|
|
436
436
|
? 'var(--color-data-categorical-green, #0B991F)'
|
|
437
|
-
: 'var(--color-data-categorical-red, #
|
|
437
|
+
: 'var(--color-data-categorical-red, #F5394F)';
|
|
438
438
|
return (
|
|
439
439
|
<ResponsiveContainer width="100%" height={40}>
|
|
440
440
|
<LineChart
|
|
@@ -503,7 +503,7 @@ function TrendSparkline({data, positive}: {data: number[]; positive: boolean}) {
|
|
|
503
503
|
const chartData = data.map((v, i) => ({i, v}));
|
|
504
504
|
const color = positive
|
|
505
505
|
? 'var(--color-data-categorical-green, #0B991F)'
|
|
506
|
-
: 'var(--color-data-categorical-red, #
|
|
506
|
+
: 'var(--color-data-categorical-red, #F5394F)';
|
|
507
507
|
return (
|
|
508
508
|
<ResponsiveContainer width="100%" height={24}>
|
|
509
509
|
<LineChart
|
|
@@ -727,7 +727,7 @@ interface ChartLine {
|
|
|
727
727
|
const BLUE = 'var(--color-data-categorical-blue, #0171E3)';
|
|
728
728
|
const ORANGE = 'var(--color-data-categorical-orange, #EB6E00)';
|
|
729
729
|
const PURPLE = 'var(--color-data-categorical-purple, #6B1EFD)';
|
|
730
|
-
const TEAL = 'var(--color-data-categorical-teal, #
|
|
730
|
+
const TEAL = 'var(--color-data-categorical-teal, #08A3A3)';
|
|
731
731
|
|
|
732
732
|
const LATENCY_LINES: ChartLine[] = [
|
|
733
733
|
{key: 'p50', name: 'p50', color: BLUE},
|
|
@@ -153,14 +153,16 @@ const styles: Record<string, CSSProperties> = {
|
|
|
153
153
|
flex: 1,
|
|
154
154
|
},
|
|
155
155
|
quantityInput: {
|
|
156
|
-
// minWidth (not a hard width) so the field grows to fit the digit + the
|
|
157
|
-
// theme's input padding. A fixed 40px was too tight on themes with larger
|
|
158
|
-
// padding / bigger type scale (e.g. Matcha, Y2K), clipping the value.
|
|
159
|
-
minWidth: 64,
|
|
160
156
|
flexShrink: 0,
|
|
161
157
|
},
|
|
158
|
+
// `auto` basis so the button wraps to its own line rather than ellipsizing.
|
|
162
159
|
cartButton: {
|
|
163
|
-
flex: 1,
|
|
160
|
+
flex: '1 1 auto',
|
|
161
|
+
minWidth: 0,
|
|
162
|
+
},
|
|
163
|
+
cartRow: {
|
|
164
|
+
width: '100%',
|
|
165
|
+
minWidth: 0,
|
|
164
166
|
},
|
|
165
167
|
};
|
|
166
168
|
|
|
@@ -236,6 +238,18 @@ const inlineStyles: Record<string, CSSProperties> = {
|
|
|
236
238
|
display: 'flex',
|
|
237
239
|
flexDirection: 'column',
|
|
238
240
|
},
|
|
241
|
+
// Flex bases, not grid tracks: the checkout form needs a width floor, and the
|
|
242
|
+
// chat wraps to its own row rather than squeezing it under one.
|
|
243
|
+
checkoutColumn: {
|
|
244
|
+
flex: '1 1 300px',
|
|
245
|
+
minWidth: 0,
|
|
246
|
+
display: 'grid',
|
|
247
|
+
},
|
|
248
|
+
chatColumn: {
|
|
249
|
+
flex: '2 1 520px',
|
|
250
|
+
minWidth: 0,
|
|
251
|
+
display: 'grid',
|
|
252
|
+
},
|
|
239
253
|
};
|
|
240
254
|
|
|
241
255
|
const PRODUCT_IMAGE_KEYS = ['watch', 'headphones', 'backpack'];
|
|
@@ -284,8 +298,7 @@ const DEFAULT_PRODUCTS: ProductSpec[] = [
|
|
|
284
298
|
// scaffolded template renders real imagery without needing local public assets.
|
|
285
299
|
const DEFAULT_IMAGES: Record<string, string> = {
|
|
286
300
|
watch: '/template-assets/Neutral-Watch.png',
|
|
287
|
-
headphones:
|
|
288
|
-
'/template-assets/Neutral-Headphones.png',
|
|
301
|
+
headphones: '/template-assets/Neutral-Headphones.png',
|
|
289
302
|
backpack: '/template-assets/Neutral-Backpack.png',
|
|
290
303
|
wallet: '/template-assets/Neutral-Wallet.png',
|
|
291
304
|
tumbler: '/template-assets/Neutral-Tumbler.png',
|
|
@@ -350,14 +363,14 @@ function CardShowcase({
|
|
|
350
363
|
|
|
351
364
|
return (
|
|
352
365
|
<VStack gap={8}>
|
|
353
|
-
<
|
|
354
|
-
<
|
|
366
|
+
<HStack gap={4} wrap="wrap">
|
|
367
|
+
<div style={inlineStyles.checkoutColumn}>
|
|
355
368
|
<CheckoutCard isMobile={isMobile} />
|
|
356
|
-
</
|
|
357
|
-
<
|
|
369
|
+
</div>
|
|
370
|
+
<div style={inlineStyles.chatColumn}>
|
|
358
371
|
<ChatCard />
|
|
359
|
-
</
|
|
360
|
-
</
|
|
372
|
+
</div>
|
|
373
|
+
</HStack>
|
|
361
374
|
<Grid columns={columns} gap={4}>
|
|
362
375
|
<GridSpan columns={isMobile ? 1 : 3}>
|
|
363
376
|
<InventoryCard images={images} inventory={inventory} />
|
|
@@ -472,7 +485,12 @@ function StorePreview({
|
|
|
472
485
|
}}>
|
|
473
486
|
{p.description}
|
|
474
487
|
</Text>
|
|
475
|
-
<HStack
|
|
488
|
+
<HStack
|
|
489
|
+
gap={2}
|
|
490
|
+
vAlign="center"
|
|
491
|
+
hAlign="center"
|
|
492
|
+
wrap="wrap"
|
|
493
|
+
style={styles.cartRow}>
|
|
476
494
|
<NumberInput
|
|
477
495
|
label="Quantity"
|
|
478
496
|
isLabelHidden
|
|
@@ -481,6 +499,7 @@ function StorePreview({
|
|
|
481
499
|
min={1}
|
|
482
500
|
max={99}
|
|
483
501
|
size="sm"
|
|
502
|
+
width={72}
|
|
484
503
|
style={styles.quantityInput}
|
|
485
504
|
/>
|
|
486
505
|
<Button
|
|
@@ -506,7 +525,7 @@ function StorePreview({
|
|
|
506
525
|
|
|
507
526
|
function CheckoutCard({isMobile}: {isMobile: boolean}) {
|
|
508
527
|
return (
|
|
509
|
-
<Card padding={5} style={styles.card}>
|
|
528
|
+
<Card padding={isMobile ? 4 : 5} style={styles.card}>
|
|
510
529
|
<VStack gap={4} style={styles.checkoutStack}>
|
|
511
530
|
<Heading level={2}>Checkout</Heading>
|
|
512
531
|
|
|
@@ -560,7 +579,7 @@ function CheckoutCard({isMobile}: {isMobile: boolean}) {
|
|
|
560
579
|
<Text type="supporting" weight="bold">
|
|
561
580
|
Payment method
|
|
562
581
|
</Text>
|
|
563
|
-
<Grid columns={isMobile ? 1 : {minWidth:
|
|
582
|
+
<Grid columns={isMobile ? 1 : {minWidth: 80, max: 3}} gap={2}>
|
|
564
583
|
<SelectableCard
|
|
565
584
|
label="Pay with card"
|
|
566
585
|
isSelected={true}
|
|
@@ -614,7 +633,7 @@ function CheckoutCard({isMobile}: {isMobile: boolean}) {
|
|
|
614
633
|
placeholder="1234 1234 1234 1234"
|
|
615
634
|
value=""
|
|
616
635
|
onChange={() => {}}
|
|
617
|
-
startIcon={<CreditCard size={16} />}
|
|
636
|
+
startIcon={isMobile ? undefined : <CreditCard size={16} />}
|
|
618
637
|
size="lg"
|
|
619
638
|
/>
|
|
620
639
|
|
|
@@ -105,7 +105,9 @@ export const stoneTheme = defineTheme({
|
|
|
105
105
|
|
|
106
106
|
// Text — H=291
|
|
107
107
|
'--color-text-primary': ['#25252a', '#f3f3f5'], // light: Stone Neutral T15
|
|
108
|
-
|
|
108
|
+
// T40/T70 keeps normal secondary text above AA on every stone surface,
|
|
109
|
+
// including the muted and tinted fills that components pair it with.
|
|
110
|
+
'--color-text-secondary': ['#5e5e63', '#ababb0'], // Stone Neutral T40 / T70
|
|
109
111
|
'--color-text-disabled': ['#d7d7da', '#5e5e61'], // T86 C=1.6 / T40 C=2
|
|
110
112
|
'--color-text-accent': ['#25252a', '#f3f3f5'], // light: Stone Neutral T15
|
|
111
113
|
'--color-on-dark': '#FFFFFF',
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* html[data-theme="dark"] { color-scheme: dark; }
|
|
18
18
|
*
|
|
19
19
|
* Themes that never use `light-dark()` have no `color-scheme` ambiguity to
|
|
20
|
-
* resolve, so none of the three rules should be emitted for them
|
|
20
|
+
* resolve, so none of the three rules should be emitted for them — the
|
|
21
|
+
* generated CSS still carries the `--color-data-*` defaults, which are
|
|
22
|
+
* `light-dark()` pairs, so the decision reads the theme's own values.
|
|
21
23
|
*
|
|
22
24
|
* Building `astryx theme build` requires a compiled @astryxdesign/core (there is no in-CLI
|
|
23
25
|
* fallback generator), so this suite builds core once in beforeAll via the
|
|
@@ -67,10 +69,9 @@ describe('theme build color-scheme output', () => {
|
|
|
67
69
|
const project = path.join(tmpDir, 'project');
|
|
68
70
|
const themesDir = path.join(project, 'themes');
|
|
69
71
|
// A raw light-dark() token value is what triggers the color-scheme
|
|
70
|
-
// injection in build-theme.mjs
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// the string directly so this test stays focused on the CLI's own
|
|
72
|
+
// injection in build-theme.mjs. `defineTheme.test.ts` already covers the
|
|
73
|
+
// separate [light, dark] tuple -> light-dark() conversion; this fixture
|
|
74
|
+
// writes the string directly so this test stays focused on the CLI's own
|
|
74
75
|
// color-scheme decision, not on that conversion.
|
|
75
76
|
const themeFile = writeTheme(themesDir, 'with-light-dark', {
|
|
76
77
|
'--color-accent': 'light-dark(#0077B6, #48CAE4)',
|
|
@@ -123,7 +124,7 @@ describe('theme build color-scheme output', () => {
|
|
|
123
124
|
expect(fs.existsSync(cssPath)).toBe(true);
|
|
124
125
|
const css = fs.readFileSync(cssPath, 'utf-8');
|
|
125
126
|
|
|
126
|
-
expect(css).
|
|
127
|
+
expect(css).toContain('--color-accent: #0077B6');
|
|
127
128
|
expect(css).not.toContain(COLOR_SCHEME_ROOT_DECL);
|
|
128
129
|
expect(css).not.toContain(COLOR_SCHEME_LIGHT_DECL);
|
|
129
130
|
expect(css).not.toContain(COLOR_SCHEME_DARK_DECL);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Asserts `astryx theme build` emits the `--color-data-*` defaults the
|
|
5
|
+
* way the `<Theme>` runtime does — once, at `:root`, in `@layer astryx-base`.
|
|
6
|
+
*
|
|
7
|
+
* Seeding them into the theme's own `@scope` block instead makes every nested
|
|
8
|
+
* `<Theme>` re-declare the default and shadow a parent theme's override, which
|
|
9
|
+
* no other token family does. Layer order is fixed by first declaration, so
|
|
10
|
+
* the base block also has to sit after `@layer reset` and before
|
|
11
|
+
* `@layer astryx-theme` for a consumer who imports this stylesheet alone.
|
|
12
|
+
*
|
|
13
|
+
* Building requires a compiled @astryxdesign/core, so this suite builds core
|
|
14
|
+
* once in beforeAll via the shared ensureCoreBuilt() helper.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import * as os from 'node:os';
|
|
21
|
+
import {ensureCoreBuilt} from './ensure-core-built.mjs';
|
|
22
|
+
import {runCli} from '../../../test-utils/run-cli.mjs';
|
|
23
|
+
|
|
24
|
+
function writeTheme(dir, name, theme) {
|
|
25
|
+
fs.mkdirSync(dir, {recursive: true});
|
|
26
|
+
const file = path.join(dir, `${name}.mjs`);
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
file,
|
|
29
|
+
`export default ${JSON.stringify({name, ...theme})};\n`,
|
|
30
|
+
);
|
|
31
|
+
return file;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function buildTheme(tmpDir, name, theme) {
|
|
35
|
+
const project = path.join(tmpDir, 'project');
|
|
36
|
+
const themesDir = path.join(project, 'themes');
|
|
37
|
+
const themeFile = writeTheme(themesDir, name, theme);
|
|
38
|
+
|
|
39
|
+
const result = await runCli(
|
|
40
|
+
['theme', 'build', path.relative(project, themeFile)],
|
|
41
|
+
project,
|
|
42
|
+
);
|
|
43
|
+
expect(result.code).toBe(0);
|
|
44
|
+
|
|
45
|
+
return fs.readFileSync(path.join(themesDir, `${name}.css`), 'utf-8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeAll(() => {
|
|
49
|
+
ensureCoreBuilt();
|
|
50
|
+
}, 200_000);
|
|
51
|
+
|
|
52
|
+
let tmpDir;
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-build-theme-data-'));
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('theme build data token output', () => {
|
|
61
|
+
it('emits the defaults once, at :root, in @layer astryx-base', async () => {
|
|
62
|
+
const css = await buildTheme(tmpDir, 'charts-untouched', {
|
|
63
|
+
tokens: {'--color-accent': '#0077B6'},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const baseStart = css.indexOf('@layer astryx-base');
|
|
67
|
+
expect(baseStart).toBeGreaterThanOrEqual(0);
|
|
68
|
+
|
|
69
|
+
const baseBlock = css.slice(baseStart, css.indexOf('@layer astryx-theme'));
|
|
70
|
+
expect(baseBlock).toContain(':root {');
|
|
71
|
+
expect(baseBlock).toContain('--color-data-categorical-blue:');
|
|
72
|
+
expect(baseBlock).toContain('--color-data-gray-1:');
|
|
73
|
+
expect(css.match(/--color-data-categorical-blue:/g)).toHaveLength(1);
|
|
74
|
+
expect(css.match(/@layer astryx-base/g)).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('declares astryx-base after reset and before astryx-theme', async () => {
|
|
78
|
+
const css = await buildTheme(tmpDir, 'charts-order', {
|
|
79
|
+
tokens: {'--color-accent': '#0077B6'},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const reset = css.indexOf('@layer reset');
|
|
83
|
+
const base = css.indexOf('@layer astryx-base');
|
|
84
|
+
const theme = css.indexOf('@layer astryx-theme');
|
|
85
|
+
|
|
86
|
+
expect(reset).toBeGreaterThanOrEqual(0);
|
|
87
|
+
expect(base).toBeGreaterThan(reset);
|
|
88
|
+
expect(theme).toBeGreaterThan(base);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("puts only the theme's own data token in its scope block", async () => {
|
|
92
|
+
const css = await buildTheme(tmpDir, 'charts-override', {
|
|
93
|
+
tokens: {'--color-data-categorical-blue': '#00A3FF'},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const themeBlock = css.slice(css.indexOf('@layer astryx-theme'));
|
|
97
|
+
expect(themeBlock).toContain('--color-data-categorical-blue: #00A3FF;');
|
|
98
|
+
expect(themeBlock).not.toContain('--color-data-categorical-orange');
|
|
99
|
+
expect(themeBlock).not.toContain('--color-data-gray-1');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('emits the bytes the runtime generator emits', async () => {
|
|
103
|
+
const tokens = {'--color-accent': '#0077B6'};
|
|
104
|
+
const css = await buildTheme(tmpDir, 'charts-parity', {tokens});
|
|
105
|
+
|
|
106
|
+
const {defineTheme, generateThemeCSS} = await import(
|
|
107
|
+
'@astryxdesign/core/theme'
|
|
108
|
+
);
|
|
109
|
+
const {base} = generateThemeCSS(
|
|
110
|
+
defineTheme({name: 'charts-parity', tokens}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(css).toContain(`@layer astryx-base {\n${base}\n}`);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -164,6 +164,7 @@ describe('theme build custom-variant augmentations', () => {
|
|
|
164
164
|
},
|
|
165
165
|
breadcrumbs: { 'variant:customBreadcrumbs': { color: 'currentColor' } },
|
|
166
166
|
button: { 'variant:customButton': { backgroundColor: 'transparent' } },
|
|
167
|
+
card: { 'variant:customCard': { backgroundColor: 'transparent' } },
|
|
167
168
|
dialog: { 'variant:customDialog': { backgroundColor: 'transparent' } },
|
|
168
169
|
divider: { 'variant:customDivider': { borderColor: 'currentColor' } },
|
|
169
170
|
'field-status': { 'variant:customFieldStatus': { color: 'currentColor' } },
|
|
@@ -204,6 +205,7 @@ describe('theme build custom-variant augmentations', () => {
|
|
|
204
205
|
`import {Banner} from '@astryxdesign/core/Banner';\n` +
|
|
205
206
|
`import {Breadcrumbs} from '@astryxdesign/core/Breadcrumbs';\n` +
|
|
206
207
|
`import {Button} from '@astryxdesign/core/Button';\n` +
|
|
208
|
+
`import {Card} from '@astryxdesign/core/Card';\n` +
|
|
207
209
|
`import {Dialog} from '@astryxdesign/core/Dialog';\n` +
|
|
208
210
|
`import {Divider} from '@astryxdesign/core/Divider';\n` +
|
|
209
211
|
`import {FieldStatus} from '@astryxdesign/core/FieldStatus';\n` +
|
|
@@ -222,6 +224,7 @@ describe('theme build custom-variant augmentations', () => {
|
|
|
222
224
|
` <Banner title="Banner" status="customBannerStatus" container="customBannerContainer" />\n` +
|
|
223
225
|
` <Breadcrumbs variant="customBreadcrumbs">Crumbs</Breadcrumbs>\n` +
|
|
224
226
|
` <Button label="Button" variant="customButton" />\n` +
|
|
227
|
+
` <Card variant="customCard">Card</Card>\n` +
|
|
225
228
|
` <Dialog isOpen onOpenChange={() => {}} variant="customDialog">Body</Dialog>\n` +
|
|
226
229
|
` <Divider variant="customDivider" />\n` +
|
|
227
230
|
` <FieldStatus type="success" message="Ok" variant="customFieldStatus" />\n` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.5.0-canary.
|
|
3
|
+
"version": "0.5.0-canary.408cef2",
|
|
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",
|
|
@@ -87,10 +87,10 @@
|
|
|
87
87
|
"zod": "^4.4.3"
|
|
88
88
|
},
|
|
89
89
|
"peerDependencies": {
|
|
90
|
-
"@astryxdesign/charts": "0.5.0-canary.
|
|
91
|
-
"@astryxdesign/core": "0.5.0-canary.
|
|
92
|
-
"@astryxdesign/lab": "0.5.0-canary.
|
|
93
|
-
"@astryxdesign/theme-neutral": "0.5.0-canary.
|
|
90
|
+
"@astryxdesign/charts": "0.5.0-canary.408cef2",
|
|
91
|
+
"@astryxdesign/core": "0.5.0-canary.408cef2",
|
|
92
|
+
"@astryxdesign/lab": "0.5.0-canary.408cef2",
|
|
93
|
+
"@astryxdesign/theme-neutral": "0.5.0-canary.408cef2",
|
|
94
94
|
"gpt-tokenizer": "^3.4.0"
|
|
95
95
|
},
|
|
96
96
|
"peerDependenciesMeta": {
|
|
@@ -108,10 +108,10 @@
|
|
|
108
108
|
}
|
|
109
109
|
},
|
|
110
110
|
"devDependencies": {
|
|
111
|
-
"@astryxdesign/charts": "0.5.0-canary.
|
|
112
|
-
"@astryxdesign/core": "0.5.0-canary.
|
|
113
|
-
"@astryxdesign/lab": "0.5.0-canary.
|
|
114
|
-
"@astryxdesign/theme-neutral": "0.5.0-canary.
|
|
111
|
+
"@astryxdesign/charts": "0.5.0-canary.408cef2",
|
|
112
|
+
"@astryxdesign/core": "0.5.0-canary.408cef2",
|
|
113
|
+
"@astryxdesign/lab": "0.5.0-canary.408cef2",
|
|
114
|
+
"@astryxdesign/theme-neutral": "0.5.0-canary.408cef2",
|
|
115
115
|
"gpt-tokenizer": "^3.4.0"
|
|
116
116
|
},
|
|
117
117
|
"scripts": {
|