@astryxdesign/cli 0.1.6-canary.fec872b → 0.1.6-canary.ff5dfca

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.
@@ -16,7 +16,7 @@ export const docs = {
16
16
  content: [
17
17
  {
18
18
  type: 'prose',
19
- text: 'Internationalization ships with `@astryxdesign/core`. There is nothing to install. Wrap your app in `<InternationalizationProvider>` and set a `locale` — astryx components pick up localized strings automatically.',
19
+ text: 'Internationalization ships with `@astryxdesign/core`. There is nothing to install. Wrap your app in `<InternationalizationProvider>` and set a `locale`, and astryx components pick up localized strings automatically.',
20
20
  },
21
21
  {
22
22
  type: 'code',
@@ -45,11 +45,11 @@ function SaveButton() {
45
45
  },
46
46
  {
47
47
  type: 'prose',
48
- text: 'The hook is available to consumer components too, but using it is entirely optional — many teams keep their app strings on their existing i18n library (react-intl, i18next, next-intl, LinguiJS) and only use `useTranslator` when reading astryx keys. If you do route your own strings through it, we recommend namespacing them (`@myapp.*` or your npm scope) to keep them separated from `@astryx.*`, but this is a convention, not a requirement — the resolver treats every key as an opaque string.',
48
+ text: 'The hook is available to consumer components too, but using it is entirely optional: many teams keep their app strings on their existing i18n library (react-intl, i18next, next-intl, LinguiJS) and only use `useTranslator` when reading astryx keys. If you do route your own strings through it, we recommend namespacing them (`@myapp.*` or your npm scope) to keep them separated from `@astryx.*`, but this is a convention, not a requirement; the resolver treats every key as an opaque string.',
49
49
  },
50
50
  {
51
51
  type: 'prose',
52
- text: "Astryx ships translations only for English today. First-party translations for other locales are on the roadmap and coming soon — track https://github.com/facebook/astryx/issues/3641. In the meantime, if you want astryx UI translated into another locale, you can ship your own catalog through the `messages` prop (covered in the next section). If you're using `useTranslator` for your own strings, you'll want to ship your own catalog either way — astryx only carries the fallback for `@astryx.*` keys, not the ones you author.",
52
+ text: "Astryx ships translations only for English today. First-party translations for other locales are on the roadmap; track https://github.com/facebook/astryx/issues/3641. In the meantime, if you want astryx UI translated into another locale, you can ship your own catalog through the `messages` prop (covered in the next section). If you're using `useTranslator` for your own strings, you'll want to ship your own catalog either way, since astryx only carries the fallback for `@astryx.*` keys, not the ones you author.",
53
53
  },
54
54
  ],
55
55
  },
@@ -59,7 +59,7 @@ function SaveButton() {
59
59
  content: [
60
60
  {
61
61
  type: 'prose',
62
- text: 'Astryx bundles only the English catalog today. To render in any other locale, provide a translation catalog through the `messages` prop and set `locale` accordingly. This matches how MUI, Ant Design, and AG Grid work — the consumer app supplies the catalogs it actually needs so unused translations stay out of the bundle.',
62
+ text: 'Astryx bundles only the English catalog today. To render in any other locale, provide a translation catalog through the `messages` prop and set `locale` accordingly. This matches how MUI, Ant Design, and AG Grid work: the consumer app supplies the catalogs it actually needs so unused translations stay out of the bundle.',
63
63
  },
64
64
  {
65
65
  type: 'code',
@@ -74,11 +74,11 @@ import fr from './locales/astryx/fr.json';
74
74
  },
75
75
  {
76
76
  type: 'prose',
77
- text: "See `@astryxdesign/core/locales/en.json` for the full inventory of keys to translate. Copy it as the starting point — every key you translate replaces the English default; anything you omit falls back through the locale chain to English (e.g. `pt-BR` walks to `pt` then to shipped `en`), so a partial translation renders as a mix rather than empty text or raw key names.",
77
+ text: "See `@astryxdesign/core/locales/en.json` for the full inventory of keys to translate. Copy it as the starting point: every key you translate replaces the English default; anything you omit falls back through the locale chain to English (e.g. `pt-BR` walks to `pt` then to shipped `en`), so a partial translation renders as a mix rather than empty text or raw key names.",
78
78
  },
79
79
  {
80
80
  type: 'prose',
81
- text: 'A community-maintained set of astryx translations is on the roadmap but not shipped yet. For now, consumer apps that ship in multiple languages own their astryx catalogs alongside their app catalogs. Contributions to a first-party set are welcome — track discussion at https://github.com/facebook/astryx/issues/3641.',
81
+ text: 'A community-maintained set of astryx translations is on the roadmap but not shipped yet. For now, consumer apps that ship in multiple languages own their astryx catalogs alongside their app catalogs. Contributions to a first-party set are welcome; track discussion at https://github.com/facebook/astryx/issues/3641.',
82
82
  },
83
83
  ],
84
84
  },
@@ -113,7 +113,7 @@ import fr from './locales/astryx/fr.json';
113
113
  content: [
114
114
  {
115
115
  type: 'prose',
116
- text: "Astryx components render astryx strings through astryx's provider. Consumer components render consumer strings through whatever i18n library you already use — react-intl, i18next, next-intl, LinguiJS, and so on. The two systems coexist and read from the same source of truth for the active locale.",
116
+ text: "Astryx components render astryx strings through astryx's provider. Consumer components render consumer strings through whatever i18n library you already use: react-intl, i18next, next-intl, LinguiJS, and so on. The two systems coexist and read from the same source of truth for the active locale.",
117
117
  },
118
118
  {
119
119
  type: 'code',
@@ -165,11 +165,11 @@ export default function App() {
165
165
  },
166
166
  {
167
167
  type: 'prose',
168
- text: 'Keep the two providers in sync on locale, and each library owns its own catalog. Astryx never sees your app strings, and your i18n library never sees astryx internals. Runtime locale swap works the same way — re-render both providers with a new `locale` prop and the whole tree updates live.',
168
+ text: 'Keep the two providers in sync on locale, and each library owns its own catalog. Astryx never sees your app strings, and your i18n library never sees astryx internals. Runtime locale swap works the same way: re-render both providers with a new `locale` prop and the whole tree updates live.',
169
169
  },
170
170
  {
171
171
  type: 'prose',
172
- text: "Single-catalog usage — where an external i18n runtime like react-intl or i18next resolves both your app strings AND astryx's strings through one provider — is on the roadmap via a `Translator` adapter. Track https://github.com/facebook/astryx/issues/4029. For now, run the two providers side by side as shown above.",
172
+ text: "Single-catalog usage (where an external i18n runtime like react-intl or i18next resolves both your app strings AND astryx's strings through one provider) is on the roadmap via a `Translator` adapter. Track https://github.com/facebook/astryx/issues/4029. For now, run the two providers side by side as shown above.",
173
173
  },
174
174
  ],
175
175
  },
@@ -225,7 +225,7 @@ export default function App() {
225
225
  },
226
226
  {
227
227
  type: 'prose',
228
- text: "Pseudoloc also has a subtle caveat worth knowing: the pseudo catalog is complete (astryx generates it from every shipped key), so a component using an astryx-shipped key will always render its pseudo version. Your handwritten translation catalogs, on the other hand, only cover the keys you translated — anything missing falls back to English. That means \"looks perfect in pseudo\" is not the same guarantee as \"looks perfect in French.\" Check each real locale by hand for coverage gaps.",
228
+ text: "Pseudoloc also has a subtle caveat worth knowing: the pseudo catalog is complete (astryx generates it from every shipped key), so a component using an astryx-shipped key will always render its pseudo version. Your handwritten translation catalogs, on the other hand, only cover the keys you translated; anything missing falls back to English. That means \"looks perfect in pseudo\" is not the same guarantee as \"looks perfect in French.\" Check each real locale by hand for coverage gaps.",
229
229
  },
230
230
  ],
231
231
  },
@@ -235,7 +235,7 @@ export default function App() {
235
235
  content: [
236
236
  {
237
237
  type: 'prose',
238
- text: "Astryx's own strings live in `packages/core/locales/en.json`. New user-facing strings must go through `useTranslator` — this is enforced by the `@astryx/no-hardcoded-i18n-string` ESLint rule. See the AI contribution guide for the alias-and-resolve pattern used when adding new keys.",
238
+ text: "Astryx's own strings live in `packages/core/locales/en.json`. New user-facing strings must go through `useTranslator`; this is enforced by the `@astryx/no-hardcoded-i18n-string` ESLint rule. See the AI contribution guide for the alias-and-resolve pattern used when adding new keys.",
239
239
  },
240
240
  ],
241
241
  },
@@ -7,6 +7,7 @@ export const docsDense = {
7
7
  'frame-first app layout: shell choice, region budgets, cards vs rows',
8
8
  sections: [
9
9
  {
10
+ section: 'Frame First',
10
11
  title: 'Frame First',
11
12
  content: [
12
13
  {
@@ -26,6 +27,7 @@ export const docsDense = {
26
27
  ],
27
28
  },
28
29
  {
30
+ section: 'App Archetypes',
29
31
  title: 'App Archetypes',
30
32
  content: [
31
33
  {
@@ -40,6 +42,7 @@ export const docsDense = {
40
42
  ],
41
43
  },
42
44
  {
45
+ section: 'Cards vs Rows',
43
46
  title: 'Cards vs Rows',
44
47
  content: [
45
48
  {
@@ -67,6 +70,7 @@ export const docsDense = {
67
70
  ],
68
71
  },
69
72
  {
73
+ section: 'Panels and Inspectors',
70
74
  title: 'Panels and Inspectors',
71
75
  content: [
72
76
  {
@@ -77,6 +81,7 @@ export const docsDense = {
77
81
  ],
78
82
  },
79
83
  {
84
+ section: 'Responsive Contract',
80
85
  title: 'Responsive Contract',
81
86
  content: [
82
87
  {
@@ -5,10 +5,10 @@
5
5
  export const docsDense = {
6
6
  description: 'core design principles + rules for the design system',
7
7
  sections: [
8
- { title: 'Philosophy', content: [{ type: 'list', items: ['components over primitives', 'semantic tokens over hardcoded values', 'theme-agnostic code', 'open internals'] }] },
9
- { title: 'Rules', content: [{ type: 'list', items: ['use components', 'frame-first layout: shell + region budgets before content (astryx docs layout)', 'dense data = rows (Table, List/Item) not Cards; Card = widgets/galleries/settings groups', 'StyleX or Tailwind for styling', 'semantic tokens only', 'CSS vars for colors', 'controlled form inputs', 'useLinkComponent() for navigation'] }] },
10
- { title: 'Styling', content: [{ type: 'prose', text: 'xstyle prop for component overrides. StyleX or Tailwind for layout. See astryx docs styling.' }] },
11
- { title: 'Anti-Patterns', content: [{ type: 'list', items: ['no inline styles on raw elements', 'no hardcoded colors — use tokens or Tailwind semantic classes', 'no hardcoded spacing', 'no hardcoded <a> — use useLinkComponent()', 'no Card-wrapped list items — frame first, rows for dense data (astryx docs layout)', 'no decorative Badge — StatusDot/Token for status', 'read docs before inventing props'] }] },
12
- { title: 'Tokens', content: [{ type: 'prose', text: 'run npx astryx docs tokens for full reference' }] },
8
+ { section: 'Design Philosophy', title: 'Philosophy', content: [{ type: 'list', items: ['components over primitives', 'semantic tokens over hardcoded values', 'theme-agnostic code', 'open internals'] }] },
9
+ { section: 'Rules', title: 'Rules', content: [{ type: 'list', items: ['use components', 'frame-first layout: shell + region budgets before content (astryx docs layout)', 'dense data = rows (Table, List/Item) not Cards; Card = widgets/galleries/settings groups', 'StyleX or Tailwind for styling', 'semantic tokens only', 'CSS vars for colors', 'controlled form inputs', 'useLinkComponent() for navigation'] }] },
10
+ { section: 'Styling Approach', title: 'Styling', content: [{ type: 'prose', text: 'xstyle prop for component overrides. StyleX or Tailwind for layout. See astryx docs styling.' }] },
11
+ { section: 'Anti-Patterns', title: 'Anti-Patterns', content: [{ type: 'list', items: ['no inline styles on raw elements', 'no hardcoded colors — use tokens or Tailwind semantic classes', 'no hardcoded spacing', 'no hardcoded <a> — use useLinkComponent()', 'no Card-wrapped list items — frame first, rows for dense data (astryx docs layout)', 'no decorative Badge — StatusDot/Token for status', 'read docs before inventing props'] }] },
12
+ { section: 'Design Tokens', title: 'Tokens', content: [{ type: 'prose', text: 'run npx astryx docs tokens for full reference' }] },
13
13
  ],
14
14
  };
@@ -5,10 +5,10 @@
5
5
  export const docsZh = {
6
6
  description: 'XDS 核心设计原则和规则。',
7
7
  sections: [
8
- { title: '设计哲学', content: [{ type: 'list', items: ['组件优于原始元素 — 优先使用 XDS 组件', '语义化令牌优于硬编码值', '主题无关的代码 — 深色模式自动生效', '开放的内部机制 — 所有基础组件均可导出和组合'] }] },
9
- { title: '规则', content: [{ type: 'list', items: ['所有支持的场景都使用 XDS 组件', '布局采用框架优先:先选定外壳并规划区域尺寸,再编写内容(见 astryx docs layout)', '密集数据使用行(Table、List/Item)通栏渲染;Card 用于小部件、画廊和设置分组', '使用 StyleX 或 Tailwind 进行样式设置', '使用语义化令牌,不使用硬编码值', '使用 CSS 变量设置颜色,不使用十六进制值', '表单输入为受控组件(value + onChange)', '使用 useLinkComponent() 进行导航'] }] },
10
- { title: '样式方法', content: [{ type: 'prose', text: '组件覆盖使用 xstyle 属性。布局使用 StyleX 或 Tailwind。详见 astryx docs styling。' }] },
11
- { title: '反模式', content: [{ type: 'list', items: ['不要在原始元素上使用内联样式', '不要硬编码颜色 — 使用令牌或 Tailwind 语义类', '不要硬编码间距', '不要硬编码 <a> 元素 — 使用 useLinkComponent()', '不要把每个列表项都包在 Card 里 — 先定框架,密集数据用行渲染(见 astryx docs layout)', '不要把 Badge 当装饰 — 状态请使用 StatusDot 或 Token', '不要自创属性。先阅读组件文档'] }] },
12
- { title: '设计令牌', content: [{ type: 'prose', text: '运行 npx astryx docs tokens 查看完整参考' }] },
8
+ { section: 'Design Philosophy', title: '设计哲学', content: [{ type: 'list', items: ['组件优于原始元素 — 优先使用 XDS 组件', '语义化令牌优于硬编码值', '主题无关的代码 — 深色模式自动生效', '开放的内部机制 — 所有基础组件均可导出和组合'] }] },
9
+ { section: 'Rules', title: '规则', content: [{ type: 'list', items: ['所有支持的场景都使用 XDS 组件', '布局采用框架优先:先选定外壳并规划区域尺寸,再编写内容(见 astryx docs layout)', '密集数据使用行(Table、List/Item)通栏渲染;Card 用于小部件、画廊和设置分组', '使用 StyleX 或 Tailwind 进行样式设置', '使用语义化令牌,不使用硬编码值', '使用 CSS 变量设置颜色,不使用十六进制值', '表单输入为受控组件(value + onChange)', '使用 useLinkComponent() 进行导航'] }] },
10
+ { section: 'Styling Approach', title: '样式方法', content: [{ type: 'prose', text: '组件覆盖使用 xstyle 属性。布局使用 StyleX 或 Tailwind。详见 astryx docs styling。' }] },
11
+ { section: 'Anti-Patterns', title: '反模式', content: [{ type: 'list', items: ['不要在原始元素上使用内联样式', '不要硬编码颜色 — 使用令牌或 Tailwind 语义类', '不要硬编码间距', '不要硬编码 <a> 元素 — 使用 useLinkComponent()', '不要把每个列表项都包在 Card 里 — 先定框架,密集数据用行渲染(见 astryx docs layout)', '不要把 Badge 当装饰 — 状态请使用 StatusDot 或 Token', '不要自创属性。先阅读组件文档'] }] },
12
+ { section: 'Design Tokens', title: '设计令牌', content: [{ type: 'prose', text: '运行 npx astryx docs tokens 查看完整参考' }] },
13
13
  ],
14
14
  };
@@ -5,17 +5,17 @@
5
5
  export const docsDense = {
6
6
  description: 'Theme provider, custom themes, light/dark, component overrides',
7
7
  sections: [
8
- { title: 'Quick Start', content: [null, null, null, null, { type: 'prose', text: 'default import = runtime injection. /built import = pre-compiled CSS (pair with theme.css).' }] },
9
- { title: 'Themes', content: [null, null, { type: 'prose', text: 'published: neutral (start here), butter, chocolate, gothic (dark-only), matcha, stone, y2k. @astryxdesign/theme-{name} = source (runtime). @astryxdesign/theme-{name}/built = optimized (+ theme.css).' }] },
10
- { title: 'Props', content: [null] },
11
- { title: 'Custom Theme', content: [{ type: 'prose', text: 'CLI wizard or manual defineTheme. only override tokens that differ.' }, null] },
12
- { title: 'defineTheme', content: [{ type: 'prose', text: 'scale configs (color, typography, radius, motion) + explicit token overrides + component overrides. color derives full palette from accent hex via HCT.' }, null, null] },
13
- { title: 'Component Overrides', content: [{ type: 'prose', text: 'components field uses semantic component keys + style keys (base, variant:value, stateName), not raw selectors. for external CSS, prefer data-* selectors from `astryx docs styling`. write standard CSS (borderRadius, padding) — pipeline expands to internal vars. public vars (--button-press-scale etc) set directly. private vars (--_*) cannot be set — use CSS properties. run `npx astryx component <Name>` for details.' }, null, null, null, null] },
14
- { title: 'Custom Variants', content: [{ type: 'prose', text: 'any unknown prop:value in components becomes a new variant. astryx theme build generates TS augmentations. works on any extensible prop axis (variant, status, etc).' }, null, null, null, null] },
15
- { title: 'Build for Production', content: [{ type: 'prose', text: 'npx astryx theme build compiles defineTheme to static CSS. outputs .css + .js (__built:true) + .d.ts.' }, null, null, null, null] },
16
- { title: 'Runtime vs Built', content: [{ type: 'prose', text: 'runtime: useInsertionEffect injects styles client-side. built: static CSS on first paint. USE /built + theme.css FOR SSR.' }, null, null, null] },
17
- { title: 'Light/Dark', content: [{ type: 'prose', text: 'light-dark() in token values via [light, dark] tuples. mode=system follows OS.' }, null, null] },
18
- { title: 'Nesting', content: [{ type: 'prose', text: 'wrap sections in separate <Theme> providers' }, null] },
19
- { title: 'useTheme', content: [null, { type: 'prose', text: 'read-only. manage state at app level.' }] },
8
+ { section: 'Quick Start', title: 'Quick Start', content: [null, null, null, null, { type: 'prose', text: 'default import = runtime injection. /built import = pre-compiled CSS (pair with theme.css).' }] },
9
+ { section: 'Available Themes', title: 'Themes', content: [null, null, { type: 'prose', text: 'published: neutral (start here), butter, chocolate, gothic (dark-only), matcha, stone, y2k. @astryxdesign/theme-{name} = source (runtime). @astryxdesign/theme-{name}/built = optimized (+ theme.css).' }] },
10
+ { section: 'Theme Props', title: 'Props', content: [null] },
11
+ { section: 'Creating a Custom Theme', title: 'Custom Theme', content: [{ type: 'prose', text: 'CLI wizard or manual defineTheme. only override tokens that differ.' }, null] },
12
+ { section: 'defineTheme', title: 'defineTheme', content: [{ type: 'prose', text: 'scale configs (color, typography, radius, motion) + explicit token overrides + component overrides. color derives full palette from accent hex via HCT.' }, null, null] },
13
+ { section: 'Component Style Overrides', title: 'Component Overrides', content: [{ type: 'prose', text: 'components field uses semantic component keys + style keys (base, variant:value, stateName), not raw selectors. for external CSS, prefer data-* selectors from `astryx docs styling`. write standard CSS (borderRadius, padding) — pipeline expands to internal vars. public vars (--button-press-scale etc) set directly. private vars (--_*) cannot be set — use CSS properties. run `npx astryx component <Name>` for details.' }, null, null, null, null] },
14
+ { section: 'Custom Variants', title: 'Custom Variants', content: [{ type: 'prose', text: 'any unknown prop:value in components becomes a new variant. astryx theme build generates TS augmentations. works on any extensible prop axis (variant, status, etc).' }, null, null, null, null] },
15
+ { section: 'Building Themes for Production', title: 'Build for Production', content: [{ type: 'prose', text: 'npx astryx theme build compiles defineTheme to static CSS. outputs .css + .js (__built:true) + .d.ts.' }, null, null, null, null] },
16
+ { section: 'Runtime vs Built Themes', title: 'Runtime vs Built', content: [{ type: 'prose', text: 'runtime: useInsertionEffect injects styles client-side. built: static CSS on first paint. USE /built + theme.css FOR SSR.' }, null, null, null] },
17
+ { section: 'Light/Dark Mode', title: 'Light/Dark', content: [{ type: 'prose', text: 'light-dark() in token values via [light, dark] tuples. mode=system follows OS.' }, null, null] },
18
+ { section: 'Nesting Themes', title: 'Nesting', content: [{ type: 'prose', text: 'wrap sections in separate <Theme> providers' }, null] },
19
+ { section: 'useTheme Hook', title: 'useTheme', content: [null, { type: 'prose', text: 'read-only. manage state at app level.' }] },
20
20
  ],
21
21
  };
@@ -5,15 +5,15 @@
5
5
  export const docsZh = {
6
6
  description: 'Theme 提供者、自定义主题、亮/暗模式和组件样式覆盖。',
7
7
  sections: [
8
- { title: '快速开始', content: [null, null, null, null, { type: 'prose', text: '默认导入使用运行时样式注入。/built 导入使用预编译 CSS(需配合 theme.css)。' }] },
9
- { title: '可用主题', content: [null, null, { type: 'prose', text: '已发布主题:neutral(推荐起点)、butter、chocolate、gothic(仅暗色)、matcha、stone、y2k。@astryxdesign/theme-{name} = 源码版(运行时注入)。@astryxdesign/theme-{name}/built = 优化版(配合 theme.css)。' }] },
10
- { title: 'Theme 属性', content: [null] },
11
- { title: '创建自定义主题', content: [{ type: 'prose', text: '使用 CLI 向导(推荐)或手动 defineTheme。只覆盖与默认值不同的令牌。' }, null] },
12
- { title: 'defineTheme', content: [{ type: 'prose', text: '支持比例配置(typography、radius、motion)+ 显式令牌覆盖 + 组件覆盖。' }, null, null] },
13
- { title: '生产构建', content: [{ type: 'prose', text: 'npx astryx theme build 将 defineTheme 编译为静态 CSS。输出 .css + .js(__built:true)+ .d.ts。' }, null, null, null, null] },
14
- { title: '运行时 vs 构建', content: [{ type: 'prose', text: '运行时:useInsertionEffect 在客户端注入样式。构建:静态 CSS 在首次渲染时就存在。SSR 应用请使用 /built + theme.css。' }, null, null, null] },
15
- { title: '亮/暗模式', content: [{ type: 'prose', text: "令牌值使用 [light, dark] 元组实现自动模式切换。Theme 上 mode='system'(默认)跟随系统偏好。" }, null, null] },
16
- { title: '嵌套主题', content: [{ type: 'prose', text: '将不同部分包裹在独立的 <Theme> 提供者中。' }, null] },
17
- { title: 'useTheme 钩子', content: [null, { type: 'prose', text: '这是只读的。要更改主题/模式,在应用层管理状态并传递给 <Theme>。' }] },
8
+ { section: 'Quick Start', title: '快速开始', content: [null, null, null, null, { type: 'prose', text: '默认导入使用运行时样式注入。/built 导入使用预编译 CSS(需配合 theme.css)。' }] },
9
+ { section: 'Available Themes', title: '可用主题', content: [null, null, { type: 'prose', text: '已发布主题:neutral(推荐起点)、butter、chocolate、gothic(仅暗色)、matcha、stone、y2k。@astryxdesign/theme-{name} = 源码版(运行时注入)。@astryxdesign/theme-{name}/built = 优化版(配合 theme.css)。' }] },
10
+ { section: 'Theme Props', title: 'Theme 属性', content: [null] },
11
+ { section: 'Creating a Custom Theme', title: '创建自定义主题', content: [{ type: 'prose', text: '使用 CLI 向导(推荐)或手动 defineTheme。只覆盖与默认值不同的令牌。' }, null] },
12
+ { section: 'defineTheme', title: 'defineTheme', content: [{ type: 'prose', text: '支持比例配置(typography、radius、motion)+ 显式令牌覆盖 + 组件覆盖。' }, null, null] },
13
+ { section: 'Building Themes for Production', title: '生产构建', content: [{ type: 'prose', text: 'npx astryx theme build 将 defineTheme 编译为静态 CSS。输出 .css + .js(__built:true)+ .d.ts。' }, null, null, null, null] },
14
+ { section: 'Runtime vs Built Themes', title: '运行时 vs 构建', content: [{ type: 'prose', text: '运行时:useInsertionEffect 在客户端注入样式。构建:静态 CSS 在首次渲染时就存在。SSR 应用请使用 /built + theme.css。' }, null, null, null] },
15
+ { section: 'Light/Dark Mode', title: '亮/暗模式', content: [{ type: 'prose', text: "令牌值使用 [light, dark] 元组实现自动模式切换。Theme 上 mode='system'(默认)跟随系统偏好。" }, null, null] },
16
+ { section: 'Nesting Themes', title: '嵌套主题', content: [{ type: 'prose', text: '将不同部分包裹在独立的 <Theme> 提供者中。' }, null] },
17
+ { section: 'useTheme Hook', title: 'useTheme 钩子', content: [null, { type: 'prose', text: '这是只读的。要更改主题/模式,在应用层管理状态并传递给 <Theme>。' }] },
18
18
  ],
19
19
  };
@@ -5,12 +5,11 @@
5
5
  export const docsDense = {
6
6
  description: 'spacing/color/radius/type/shadow token ref',
7
7
  sections: [
8
- { title: 'Spacing', content: [{ type: 'prose', text: 'defined in tokens.stylex.ts. gap props use space0-space12.' }, null] },
9
- { title: 'Size', content: [{ type: 'prose', text: 'control heights for buttons/inputs/selectors.' }, null] },
10
- { title: 'Color', content: [{ type: 'prose', text: 'semantic colors, support light-dark() auto switching.' }, null, null, null] },
11
- { title: 'Radius', content: [null] },
12
- { title: 'Elevation', content: [null] },
13
- { title: 'Typography', content: [null, null, null, null] },
14
- { title: 'StyleX Usage', content: [null, null] },
8
+ { section: 'Color Tokens', title: 'Color', content: [{ type: 'prose', text: 'semantic colors, support light-dark() auto switching.' }, null, null, null] },
9
+ { section: 'Spacing Tokens', title: 'Spacing', content: [{ type: 'prose', text: 'defined in tokens.stylex.ts. gap props use space0-space12.' }, null] },
10
+ { section: 'Size Tokens', title: 'Size', content: [{ type: 'prose', text: 'control heights for buttons/inputs/selectors.' }, null] },
11
+ { section: 'Radius Tokens', title: 'Radius', content: [null] },
12
+ { section: 'Shadow Tokens', title: 'Elevation', content: [null] },
13
+ { section: 'Usage in StyleX', title: 'StyleX Usage', content: [null, null] },
15
14
  ],
16
15
  };
@@ -5,12 +5,11 @@
5
5
  export const docsZh = {
6
6
  description: '间距、颜色、圆角、排版和阴影设计令牌参考。',
7
7
  sections: [
8
- { title: '间距令牌', content: [{ type: 'prose', text: '所有设计令牌定义在 packages/core/src/theme/tokens.stylex.ts 中。组件的 gap 属性使用 space0-space12 映射到这些令牌。' }, null] },
9
- { title: '尺寸令牌', content: [{ type: 'prose', text: '控制按钮、输入框和选择器的一致高度。' }, null] },
10
- { title: '颜色令牌', content: [{ type: 'prose', text: '语义化颜色,支持 light-dark() 自动切换模式。' }, null, null, null] },
11
- { title: '圆角令牌', content: [null] },
12
- { title: '阴影令牌', content: [null] },
13
- { title: '排版令牌', content: [null, null, null, null] },
14
- { title: 'StyleX 用法', content: [null, null] },
8
+ { section: 'Color Tokens', title: '颜色令牌', content: [{ type: 'prose', text: '语义化颜色,支持 light-dark() 自动切换模式。' }, null, null, null] },
9
+ { section: 'Spacing Tokens', title: '间距令牌', content: [{ type: 'prose', text: '所有设计令牌定义在 packages/core/src/theme/tokens.stylex.ts 中。组件的 gap 属性使用 space0-space12 映射到这些令牌。' }, null] },
10
+ { section: 'Size Tokens', title: '尺寸令牌', content: [{ type: 'prose', text: '控制按钮、输入框和选择器的一致高度。' }, null] },
11
+ { section: 'Radius Tokens', title: '圆角令牌', content: [null] },
12
+ { section: 'Shadow Tokens', title: '阴影令牌', content: [null] },
13
+ { section: 'Usage in StyleX', title: 'StyleX 用法', content: [null, null] },
15
14
  ],
16
15
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.1.6-canary.fec872b",
3
+ "version": "0.1.6-canary.ff5dfca",
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",
@@ -79,10 +79,10 @@
79
79
  "zod": "^4.4.3"
80
80
  },
81
81
  "peerDependencies": {
82
- "@astryxdesign/charts": "0.1.6-canary.fec872b",
83
- "@astryxdesign/core": "0.1.6-canary.fec872b",
84
- "@astryxdesign/lab": "0.1.6-canary.fec872b",
85
- "@astryxdesign/theme-neutral": "0.1.6-canary.fec872b",
82
+ "@astryxdesign/charts": "0.1.6-canary.ff5dfca",
83
+ "@astryxdesign/core": "0.1.6-canary.ff5dfca",
84
+ "@astryxdesign/lab": "0.1.6-canary.ff5dfca",
85
+ "@astryxdesign/theme-neutral": "0.1.6-canary.ff5dfca",
86
86
  "gpt-tokenizer": "^3.4.0"
87
87
  },
88
88
  "peerDependenciesMeta": {
@@ -100,10 +100,10 @@
100
100
  }
101
101
  },
102
102
  "devDependencies": {
103
- "@astryxdesign/charts": "0.1.6-canary.fec872b",
104
- "@astryxdesign/core": "0.1.6-canary.fec872b",
105
- "@astryxdesign/lab": "0.1.6-canary.fec872b",
106
- "@astryxdesign/theme-neutral": "0.1.6-canary.fec872b",
103
+ "@astryxdesign/charts": "0.1.6-canary.ff5dfca",
104
+ "@astryxdesign/core": "0.1.6-canary.ff5dfca",
105
+ "@astryxdesign/lab": "0.1.6-canary.ff5dfca",
106
+ "@astryxdesign/theme-neutral": "0.1.6-canary.ff5dfca",
107
107
  "gpt-tokenizer": "^3.4.0"
108
108
  },
109
109
  "scripts": {
@@ -0,0 +1,133 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Guards the --dense / --zh reference-doc overlays against drift (#2182).
5
+ * @input packages/cli/docs/{topic}.doc.mjs and its .doc.dense.mjs / .doc.zh.mjs overlays.
6
+ * @output Vitest failures naming any overlay section that does not anchor to a
7
+ * real base section, or whose content overrides land on the wrong block type.
8
+ * @position Regression gate for the docs API. Sits with the loader it guards.
9
+ *
10
+ * The overlays used to be applied BY ARRAY POSITION, so an overlay whose
11
+ * sections were ordered differently from the base grafted every title onto the
12
+ * wrong body. `docs tokens --dense` printed the entire colour table under a
13
+ * heading that said "## Spacing", above prose reading "gap props use
14
+ * space0-space12" — teaching any agent that spacing tokens are named
15
+ * `--color-*`. CLAUDE.md tells every agent to run that exact command at
16
+ * bootstrap, which is what made a doc bug into a codegen bug.
17
+ *
18
+ * Overlays now anchor to a base section by title, so a reordered or
19
+ * partial overlay is correct by construction. These tests keep it that way.
20
+ */
21
+
22
+ import {describe, it, expect} from 'vitest';
23
+ import * as fs from 'node:fs';
24
+ import * as path from 'node:path';
25
+ import {pathToFileURL} from 'node:url';
26
+ import {docs} from './docs.mjs';
27
+
28
+ const DOCS_DIR = path.join(import.meta.dirname, '..', '..', 'docs');
29
+
30
+ /** Every base reference doc that has at least one overlay. */
31
+ function overlayPairs() {
32
+ const pairs = [];
33
+ for (const file of fs.readdirSync(DOCS_DIR)) {
34
+ const m = file.match(/^(.+)\.doc\.(dense|zh)\.mjs$/);
35
+ if (!m) continue;
36
+ const [, topic, variant] = m;
37
+ pairs.push({
38
+ topic,
39
+ variant,
40
+ basePath: path.join(DOCS_DIR, `${topic}.doc.mjs`),
41
+ overlayPath: path.join(DOCS_DIR, file),
42
+ });
43
+ }
44
+ return pairs;
45
+ }
46
+
47
+ async function load(p) {
48
+ return await import(pathToFileURL(p).href);
49
+ }
50
+
51
+ describe('reference doc overlays (#2182)', () => {
52
+ const pairs = overlayPairs();
53
+
54
+ it('finds overlays to check', () => {
55
+ // A rename that stops overlays being discovered must not silently pass.
56
+ expect(pairs.length).toBeGreaterThan(0);
57
+ });
58
+
59
+ for (const {topic, variant, basePath, overlayPath} of pairs) {
60
+ it(`${topic} --${variant}: every overlay section anchors to a real base section`, async () => {
61
+ const base = await load(basePath);
62
+ const overlayMod = await load(overlayPath);
63
+ const overlay = overlayMod.docsDense || overlayMod.docsZh;
64
+
65
+ const baseTitles = base.docs.sections.map(s => s.title);
66
+ const unanchored = (overlay.sections || [])
67
+ .filter(s => s.section == null || !baseTitles.includes(s.section))
68
+ .map(s => s.section ?? `(no anchor) "${s.title}"`);
69
+
70
+ expect(
71
+ unanchored,
72
+ `${topic}.doc.${variant}.mjs has section entries that do not name a ` +
73
+ `base section via \`section:\`. Without an anchor the overlay is ` +
74
+ `applied by array position, which silently grafts each title onto ` +
75
+ `the wrong body. Base sections are: ${baseTitles.join(', ')}.`,
76
+ ).toEqual([]);
77
+ });
78
+
79
+ it(`${topic} --${variant}: no base section is overridden twice`, async () => {
80
+ const overlayMod = await load(overlayPath);
81
+ const overlay = overlayMod.docsDense || overlayMod.docsZh;
82
+ const anchors = (overlay.sections || []).map(s => s.section);
83
+ const dupes = anchors.filter((a, i) => anchors.indexOf(a) !== i);
84
+ expect(dupes, `${topic}.doc.${variant}.mjs overrides the same base section twice`).toEqual([]);
85
+ });
86
+ }
87
+ });
88
+
89
+ describe('the reported defect: docs tokens --dense (#2182)', () => {
90
+ it('does not print the colour table under the Spacing heading', async () => {
91
+ const result = await docs('tokens', null, {dense: true});
92
+ const spacing = result.data.sections.find(s => /spacing/i.test(s.title));
93
+ expect(spacing, 'tokens docs should have a spacing section').toBeTruthy();
94
+
95
+ const text = JSON.stringify(spacing);
96
+ expect(
97
+ text.includes('--color-'),
98
+ `The "${spacing.title}" section of \`docs tokens --dense\` contains ` +
99
+ `colour tokens. An agent reading this learns that spacing tokens are ` +
100
+ `named --color-*.`,
101
+ ).toBe(false);
102
+ expect(text).toContain('--spacing-');
103
+ });
104
+
105
+ it('keeps every base section reachable, even without an overlay entry', async () => {
106
+ // The tokens overlay compresses only 6 of 13 sections. The other 7 must
107
+ // still render (in English), not vanish or absorb a neighbour's title.
108
+ const full = await docs('tokens', null, {});
109
+ const dense = await docs('tokens', null, {dense: true});
110
+ expect(dense.data.sections.length).toBe(full.data.sections.length);
111
+ });
112
+
113
+ it('does not lose the Extending a Theme section from docs theme --dense', async () => {
114
+ const dense = await docs('theme', null, {dense: true});
115
+ const titles = dense.data.sections.map(s => s.title);
116
+ expect(titles).toContain('Extending a Theme');
117
+ });
118
+
119
+ it('does not emit a duplicate useTheme heading in docs theme --dense', async () => {
120
+ const dense = await docs('theme', null, {dense: true});
121
+ const titles = dense.data.sections.map(s => s.title.toLowerCase());
122
+ const useTheme = titles.filter(t => t.includes('usetheme'));
123
+ expect(useTheme.length).toBe(1);
124
+ });
125
+
126
+ it('does not leak English headings into docs theme --zh', async () => {
127
+ const zh = await docs('theme', null, {zh: true});
128
+ const titles = zh.data.sections.map(s => s.title);
129
+ // Every section the overlay translates must appear once, in Chinese only.
130
+ expect(titles).not.toContain('Light/Dark Mode');
131
+ expect(titles).toContain('亮/暗模式');
132
+ });
133
+ });
package/src/api/docs.mjs CHANGED
@@ -38,11 +38,23 @@ async function loadReferenceDocs(docPath, {lang} = {}) {
38
38
  const translation = translationMod.docsZh || translationMod.docsDense;
39
39
  if (!translation) return docs;
40
40
 
41
+ // Overlays are keyed to a base section by title (`section`), not by array
42
+ // position. Position-keying silently grafted each overlay title onto whatever
43
+ // base section happened to share its index, so an overlay that omitted or
44
+ // reordered a section corrupted every section after it — `docs tokens --dense`
45
+ // printed the colour table under a "Spacing" heading (#2182). An overlay may
46
+ // now cover any subset of sections, in any order; sections it does not name
47
+ // keep their base content.
48
+ const bySection = new Map();
49
+ for (const ts of translation.sections ?? []) {
50
+ if (ts?.section != null) bySection.set(ts.section, ts);
51
+ }
52
+
41
53
  return {
42
54
  ...docs,
43
55
  description: translation.description || docs.description,
44
- sections: docs.sections.map((section, si) => {
45
- const ts = translation.sections?.[si];
56
+ sections: docs.sections.map(section => {
57
+ const ts = bySection.get(section.title);
46
58
  if (!ts) return section;
47
59
  return {
48
60
  ...section,
@@ -0,0 +1,112 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, it, expect} from 'vitest';
4
+
5
+ async function applyTransform(source) {
6
+ const {default: transform} = await import(
7
+ '../rename-table-renderprops-styles-to-xstyle.mjs'
8
+ );
9
+ const jscodeshift = (await import('jscodeshift')).default;
10
+ const j = jscodeshift.withParser('tsx');
11
+ const api = {jscodeshift: j, stats: () => {}, report: () => {}};
12
+ const file = {source, path: 'test.tsx'};
13
+ const result = transform(file, api);
14
+ return result ?? source;
15
+ }
16
+
17
+ describe('rename-table-renderprops-styles-to-xstyle', () => {
18
+ it('renames `props.styles` reads when the param is typed TableRenderProps', async () => {
19
+ const input = `function transformTable(props: TableRenderProps) {
20
+ return {...props, styles: [...props.styles, tableStyles.base]};
21
+ }`;
22
+ const output = await applyTransform(input);
23
+ expect(output).toContain('...props.xstyle');
24
+ expect(output).toContain('xstyle: [');
25
+ expect(output).not.toMatch(/\bstyles:/);
26
+ expect(output).not.toContain('props.styles');
27
+ });
28
+
29
+ it('renames reads/writes for BodyCellRenderProps', async () => {
30
+ const input = `const transformBodyCell = (props: BodyCellRenderProps) => ({
31
+ ...props,
32
+ styles: [...props.styles, cellStyles.padded],
33
+ });`;
34
+ const output = await applyTransform(input);
35
+ expect(output).toContain('...props.xstyle');
36
+ expect(output).toContain('xstyle: [');
37
+ expect(output).not.toContain('props.styles');
38
+ });
39
+
40
+ it('handles all six render-prop interface names as param types', async () => {
41
+ for (const typeName of [
42
+ 'TableRenderProps',
43
+ 'HeaderRowRenderProps',
44
+ 'HeaderCellRenderProps',
45
+ 'BodyRowRenderProps',
46
+ 'BodyCellRenderProps',
47
+ 'ScrollWrapperRenderProps',
48
+ ]) {
49
+ const input = `function t(p: ${typeName}) { return p.styles; }`;
50
+ const output = await applyTransform(input);
51
+ expect(output).toContain('p.xstyle');
52
+ expect(output).not.toContain('p.styles');
53
+ }
54
+ });
55
+
56
+ it('renames the `styles:` key on a render-prop-shaped object literal (htmlProps + styles siblings)', async () => {
57
+ const input = `const rp = {htmlProps: {}, styles: []};`;
58
+ const output = await applyTransform(input);
59
+ expect(output).toContain('xstyle: []');
60
+ expect(output).not.toMatch(/\bstyles:/);
61
+ });
62
+
63
+ it('does NOT touch an unrelated `styles` from stylex.create', async () => {
64
+ const input = `import * as stylex from '@stylexjs/stylex';
65
+ const styles = stylex.create({base: {color: 'red'}});
66
+ function useStuff() {
67
+ return stylex.props(styles.base);
68
+ }`;
69
+ const output = await applyTransform(input);
70
+ // No render-prop binding, no htmlProps-shaped object -> unchanged
71
+ expect(output).toContain('const styles = stylex.create');
72
+ expect(output).toContain('styles.base');
73
+ expect(output).not.toContain('xstyle');
74
+ });
75
+
76
+ it('does NOT rename `.styles` on an unrelated (untyped) object even when a render-prop binding exists elsewhere', async () => {
77
+ const input = `function transformTable(props: TableRenderProps) {
78
+ const theme = getTheme();
79
+ return {...props, styles: [...props.styles, theme.styles.base]};
80
+ }`;
81
+ const output = await applyTransform(input);
82
+ // props.styles renamed; theme.styles left alone
83
+ expect(output).toContain('...props.xstyle');
84
+ expect(output).toContain('theme.styles.base');
85
+ });
86
+
87
+ it('returns undefined for files with no render-prop usage', async () => {
88
+ const {default: transform} = await import(
89
+ '../rename-table-renderprops-styles-to-xstyle.mjs'
90
+ );
91
+ const jscodeshift = (await import('jscodeshift')).default;
92
+ const j = jscodeshift.withParser('tsx');
93
+ const api = {jscodeshift: j, stats: () => {}, report: () => {}};
94
+ const source = `const styles = {a: 1};\nconst x = styles.a;`;
95
+ const result = transform({source, path: 'test.tsx'}, api);
96
+ expect(result).toBeUndefined();
97
+ });
98
+
99
+ it('returns undefined for already-migrated code (xstyle only)', async () => {
100
+ const {default: transform} = await import(
101
+ '../rename-table-renderprops-styles-to-xstyle.mjs'
102
+ );
103
+ const jscodeshift = (await import('jscodeshift')).default;
104
+ const j = jscodeshift.withParser('tsx');
105
+ const api = {jscodeshift: j, stats: () => {}, report: () => {}};
106
+ const source = `function transformTable(props: TableRenderProps) {
107
+ return {...props, xstyle: [...props.xstyle]};
108
+ }`;
109
+ const result = transform({source, path: 'test.tsx'}, api);
110
+ expect(result).toBeUndefined();
111
+ });
112
+ });
@@ -9,6 +9,9 @@
9
9
  import migrateTableTablePropsToDirectProps, {
10
10
  meta as migrateTableTablePropsToDirectPropsMeta,
11
11
  } from './migrate-table-tableprops-to-direct-props.mjs';
12
+ import renameTableRenderPropsStylesToXstyle, {
13
+ meta as renameTableRenderPropsStylesToXstyleMeta,
14
+ } from './rename-table-renderprops-styles-to-xstyle.mjs';
12
15
 
13
16
  export default [
14
17
  {
@@ -16,4 +19,9 @@ export default [
16
19
  transform: migrateTableTablePropsToDirectProps,
17
20
  meta: migrateTableTablePropsToDirectPropsMeta,
18
21
  },
22
+ {
23
+ name: 'rename-table-renderprops-styles-to-xstyle',
24
+ transform: renameTableRenderPropsStylesToXstyle,
25
+ meta: renameTableRenderPropsStylesToXstyleMeta,
26
+ },
19
27
  ];
@@ -0,0 +1,197 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Codemod: Rename Table render-prop `styles` field to `xstyle`
5
+ *
6
+ * As of v0.1.7, the Table plugin render-prop interfaces
7
+ * (`TableRenderProps`, `HeaderRowRenderProps`, `HeaderCellRenderProps`,
8
+ * `BodyRowRenderProps`, `BodyCellRenderProps`, `ScrollWrapperRenderProps`)
9
+ * and the `scrollWrapper` component contract renamed their StyleX array
10
+ * field `styles` -> `xstyle`, matching the prop name sub-components
11
+ * receive it under.
12
+ *
13
+ * Custom plugin authors read `props.styles` and write `styles: [...]`
14
+ * inside their transform functions (`transformTable`, `transformHeaderRow`,
15
+ * `transformHeaderCell`, `transformBodyRow`, `transformBodyCell`,
16
+ * `transformScrollWrapper`). This codemod renames those reads and writes.
17
+ *
18
+ * A blind global `styles` rename is unsafe — `styles` is also the
19
+ * conventional local name for a `stylex.create({...})` bag, which is
20
+ * unrelated. So this transform only rewrites `styles` when it is
21
+ * *scoped to a render-prop object*: a function parameter (or a variable)
22
+ * whose TypeScript type annotation is one of the render-prop interfaces.
23
+ * Within such a scope it rewrites:
24
+ *
25
+ * - `<param>.styles` -> `<param>.xstyle` (member reads/writes)
26
+ * - `{ ..., styles: [...] }` -> `{ ..., xstyle: [...] }`
27
+ * for object literals that carry the render-prop shape (a sibling
28
+ * `htmlProps` key), and object literals assigned/spread from the
29
+ * tracked render-prop binding.
30
+ *
31
+ * Ambiguous cases that can't be resolved from types alone (e.g. a
32
+ * render-prop object passed through an untyped variable, or a `styles`
33
+ * bag from `stylex.create`) are left untouched, so the transform never
34
+ * renames an unrelated `styles`. This mirrors the conservative,
35
+ * scope-limited approach of the sibling
36
+ * `migrate-table-tableprops-to-direct-props` codemod.
37
+ */
38
+
39
+ export const meta = {
40
+ title: 'Rename Table render-prop `styles` field to `xstyle`',
41
+ description:
42
+ 'Renames the `styles` StyleX-array field to `xstyle` on Table plugin ' +
43
+ 'render-prop objects (TableRenderProps, HeaderRowRenderProps, ' +
44
+ 'HeaderCellRenderProps, BodyRowRenderProps, BodyCellRenderProps, ' +
45
+ 'ScrollWrapperRenderProps) inside plugin transform functions. Reads ' +
46
+ '(`props.styles`) and writes (`styles: [...]`) are renamed; unrelated ' +
47
+ '`styles` bindings (e.g. from stylex.create) are left untouched.',
48
+ };
49
+
50
+ /** The render-prop interface type names whose `styles` field became `xstyle`. */
51
+ const RENDER_PROP_TYPES = new Set([
52
+ 'TableRenderProps',
53
+ 'HeaderRowRenderProps',
54
+ 'HeaderCellRenderProps',
55
+ 'BodyRowRenderProps',
56
+ 'BodyCellRenderProps',
57
+ 'ScrollWrapperRenderProps',
58
+ ]);
59
+
60
+ /** Resolve the base type name from a TS type annotation node. */
61
+ function typeNameOf(typeAnnotation) {
62
+ // `x: TableRenderProps`
63
+ const t = typeAnnotation?.typeAnnotation ?? typeAnnotation;
64
+ if (!t) return null;
65
+ if (t.type === 'TSTypeReference' && t.typeName?.type === 'Identifier') {
66
+ return t.typeName.name;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ export default function transformer(file, api) {
72
+ const j = api.jscodeshift;
73
+ const root = j(file.source);
74
+ let hasChanges = false;
75
+
76
+ // --- 1. Collect binding names typed as a render-prop interface. ---
77
+ // Function/arrow params: (props: TableRenderProps) => ...
78
+ const renderPropBindings = new Set();
79
+
80
+ function collectParam(param) {
81
+ if (
82
+ param.type === 'Identifier' &&
83
+ RENDER_PROP_TYPES.has(typeNameOf(param.typeAnnotation))
84
+ ) {
85
+ renderPropBindings.add(param.name);
86
+ }
87
+ }
88
+
89
+ root
90
+ .find(j.Function)
91
+ .forEach((p) => (p.node.params ?? []).forEach(collectParam));
92
+ root
93
+ .find(j.FunctionDeclaration)
94
+ .forEach((p) => (p.node.params ?? []).forEach(collectParam));
95
+ root
96
+ .find(j.FunctionExpression)
97
+ .forEach((p) => (p.node.params ?? []).forEach(collectParam));
98
+ root
99
+ .find(j.ArrowFunctionExpression)
100
+ .forEach((p) => (p.node.params ?? []).forEach(collectParam));
101
+
102
+ // Variable declarations: `const rp: BodyCellRenderProps = ...`
103
+ root.find(j.VariableDeclarator).forEach((p) => {
104
+ const id = p.node.id;
105
+ if (
106
+ id?.type === 'Identifier' &&
107
+ RENDER_PROP_TYPES.has(typeNameOf(id.typeAnnotation))
108
+ ) {
109
+ renderPropBindings.add(id.name);
110
+ }
111
+ });
112
+
113
+ const hasTypedBinding = renderPropBindings.size > 0;
114
+
115
+ // Also detect object literals that are structurally a render-prop object.
116
+ // Two shapes qualify:
117
+ // 1. An `htmlProps` sibling key alongside a `styles`/`xstyle` key — the
118
+ // literal shape a transform function returns from scratch.
119
+ // 2. A spread of a tracked render-prop binding (`{...props, styles: [...]}`)
120
+ // — the common "carry the render-prop object forward, override styles"
121
+ // shape. (Only counts when `renderPropBindings` is non-empty.)
122
+ function isRenderPropShapedObject(objExpr) {
123
+ if (objExpr.type !== 'ObjectExpression') return false;
124
+ const keyNames = objExpr.properties
125
+ .map((pr) =>
126
+ pr.type === 'ObjectProperty' || pr.type === 'Property'
127
+ ? pr.key?.name ?? pr.key?.value
128
+ : null,
129
+ )
130
+ .filter(Boolean);
131
+ if (keyNames.includes('htmlProps') && keyNames.includes('styles')) {
132
+ return true;
133
+ }
134
+ // Spread of a tracked render-prop binding.
135
+ const spreadsRenderProp = objExpr.properties.some(
136
+ (pr) =>
137
+ (pr.type === 'SpreadElement' || pr.type === 'ExperimentalSpreadProperty') &&
138
+ pr.argument?.type === 'Identifier' &&
139
+ renderPropBindings.has(pr.argument.name),
140
+ );
141
+ return spreadsRenderProp && keyNames.includes('styles');
142
+ }
143
+
144
+ if (!hasTypedBinding) {
145
+ // Nothing is typed as a render-prop interface. Only rewrite clearly
146
+ // render-prop-shaped object literals (htmlProps + styles siblings);
147
+ // never touch bare `styles` in this file (too ambiguous).
148
+ let touched = false;
149
+ root.find(j.ObjectExpression).forEach((p) => {
150
+ if (!isRenderPropShapedObject(p.node)) return;
151
+ for (const prop of p.node.properties) {
152
+ if (
153
+ (prop.type === 'ObjectProperty' || prop.type === 'Property') &&
154
+ !prop.computed &&
155
+ prop.key?.type === 'Identifier' &&
156
+ prop.key.name === 'styles'
157
+ ) {
158
+ prop.key.name = 'xstyle';
159
+ touched = true;
160
+ }
161
+ }
162
+ });
163
+ if (!touched) return undefined;
164
+ return root.toSource({quote: 'single'});
165
+ }
166
+
167
+ // --- 2. Rewrite `<binding>.styles` member access -> `.xstyle`. ---
168
+ root.find(j.MemberExpression).forEach((p) => {
169
+ const {object, property, computed} = p.node;
170
+ if (computed) return;
171
+ if (property?.type !== 'Identifier' || property.name !== 'styles') return;
172
+ if (object?.type !== 'Identifier' || !renderPropBindings.has(object.name)) {
173
+ return;
174
+ }
175
+ property.name = 'xstyle';
176
+ hasChanges = true;
177
+ });
178
+
179
+ // --- 3. Rewrite `styles:` keys on render-prop-shaped object literals. ---
180
+ root.find(j.ObjectExpression).forEach((p) => {
181
+ if (!isRenderPropShapedObject(p.node)) return;
182
+ for (const prop of p.node.properties) {
183
+ if (
184
+ (prop.type === 'ObjectProperty' || prop.type === 'Property') &&
185
+ !prop.computed &&
186
+ prop.key?.type === 'Identifier' &&
187
+ prop.key.name === 'styles'
188
+ ) {
189
+ prop.key.name = 'xstyle';
190
+ hasChanges = true;
191
+ }
192
+ }
193
+ });
194
+
195
+ if (!hasChanges) return undefined;
196
+ return root.toSource({quote: 'single'});
197
+ }
@@ -197,6 +197,20 @@ export function generateCompressedIndex(version, {coreDir, runPrefix = getRunPre
197
197
  lines.push("- Custom styling: component props first; else style/className with tokens — var(--color-*|--spacing-*|--radius-*). No raw hex/px. (No StyleX/Tailwind compiler here — don't use xstyle/utility classes.)");
198
198
  }
199
199
  lines.push('- Tokens for every value (`astryx docs tokens`). Brand/accent via `astryx theme` — never override --color-* in :root.');
200
+ // Self-check — post-generation pass. Validated via vibe tests (internal/vibe-tests/
201
+ // prompt-purity-test): on complex multi-step UIs the rules above alone still leave raw
202
+ // CSS in ~11-13% of runs; a re-read-and-fix pass cuts that ~4x at negligible token cost.
203
+ // The fix names the sanctioned escape hatch for the configured system.
204
+ const selfCheckFix = {
205
+ stylex:
206
+ 'replace any className=, style={{…}}, raw <div>/<span> layout, imported .css/@apply, or hardcoded #hex/px with the component or the xstyle prop + a token',
207
+ tailwind:
208
+ 'replace any style={{…}}, raw <div>/<span> layout, imported .css/@apply, or hardcoded/arbitrary value (e.g. bg-[#fff], p-[13px]) with the component or a token-backed utility',
209
+ css: 'replace any raw <div>/<span> layout, imported .css/@apply, or hardcoded value (#hex, 16px) with the component or a token (var(--color-*|--spacing-*|…))',
210
+ };
211
+ lines.push(
212
+ `- SELF-CHECK before you finish: re-read the file and ${selfCheckFix[stylingSystem] ?? selfCheckFix.css}. If unsure a component/prop exists, run \`astryx component <Name>\` / \`astryx search "<thing>"\`; don't hand-roll CSS.`,
213
+ );
200
214
  lines.push('');
201
215
 
202
216
  // Command reference — build/template/component are covered in WORKFLOW above.
@@ -43,6 +43,26 @@ describe('generateCompressedIndex', () => {
43
43
  expect(result).toMatch(/never override --color-/);
44
44
  });
45
45
 
46
+ it('includes the post-generation self-check rule', () => {
47
+ const result = generateCompressedIndex('1.0.0');
48
+ expect(result).toContain('SELF-CHECK before you finish');
49
+ expect(result).toMatch(/re-read the file/);
50
+ expect(result).toMatch(/don't hand-roll CSS/);
51
+ });
52
+
53
+ it('tailors the self-check to the styling system (xstyle for StyleX, not className for Tailwind)', () => {
54
+ // StyleX path: className/inline style are veers; the fix is the xstyle prop + a token
55
+ const stylex = generateCompressedIndex('1.0.0', {stylingSystem: 'stylex'});
56
+ const stylexSelfCheck = stylex.split('\n').find(l => l.includes('SELF-CHECK'));
57
+ expect(stylexSelfCheck).toMatch(/xstyle/);
58
+ expect(stylexSelfCheck).toMatch(/className=/);
59
+ // className IS the system in Tailwind — it must NOT be flagged
60
+ const tailwind = generateCompressedIndex('1.0.0', {stylingSystem: 'tailwind'});
61
+ const tailwindSelfCheck = tailwind.split('\n').find(l => l.includes('SELF-CHECK'));
62
+ expect(tailwindSelfCheck).toBeDefined();
63
+ expect(tailwindSelfCheck).not.toMatch(/className=/);
64
+ });
65
+
46
66
  it('defaults to the CSS-variable styling path (no compiler)', () => {
47
67
  const result = generateCompressedIndex('1.0.0');
48
68
  expect(result).toMatch(/style\/className with tokens/);