@astryxdesign/cli 0.3.0-canary.e9fc2bb → 0.3.0-canary.ec85ba0

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.
@@ -52,6 +52,32 @@ describe('stripTemplateAssetRefs', () => {
52
52
  const out = stripTemplateAssetRefs(src);
53
53
  expect(out).toBe(src);
54
54
  });
55
+
56
+ it('strips a /template-assets .mp4 source to an empty string, not the image data URI (#4780)', () => {
57
+ const src = "src: '/template-assets/Nature-1.mp4',";
58
+ const out = stripTemplateAssetRefs(src);
59
+ expect(out).toBe("src: '',");
60
+ expect(out).not.toContain('data:image/svg+xml,');
61
+ expect(out).not.toContain('/template-assets/');
62
+ });
63
+
64
+ it('strips other video extensions (.webm, .mov, .ogv) the same way', () => {
65
+ for (const ext of ['webm', 'mov', 'ogv']) {
66
+ const src = `src: '/template-assets/clip.${ext}',`;
67
+ const out = stripTemplateAssetRefs(src);
68
+ expect(out).toBe("src: '',");
69
+ }
70
+ });
71
+
72
+ it('still replaces an adjacent image reference with the placeholder when a video reference is also present', () => {
73
+ const src = [
74
+ "poster: '/template-assets/Nature-1-poster.jpg',",
75
+ "src: '/template-assets/Nature-1.mp4',",
76
+ ].join('\n');
77
+ const out = stripTemplateAssetRefs(src);
78
+ expect(out).toContain('data:image/svg+xml,');
79
+ expect(out).toContain("src: '',");
80
+ });
55
81
  });
56
82
 
57
83
  describe('template --skeleton component extraction (prefix-agnostic)', () => {
@@ -0,0 +1,201 @@
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
+ '../migrate-table-rowexpansion-to-tree.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
+ function normalize(str) {
18
+ return str
19
+ .replace(/\s+/g, ' ')
20
+ .replace(/\{\s+/g, '{')
21
+ .replace(/\s+\}/g, '}')
22
+ .trim();
23
+ }
24
+
25
+ describe('migrate-table-rowexpansion-to-tree', () => {
26
+ it('rewrites the useTableRowExpansionState import to useTableTreeState', async () => {
27
+ const input = `import {Table, useTableRowExpansion, useTableRowExpansionState} from '@astryxdesign/core';
28
+ function C() {
29
+ const {data, expansionConfig} = useTableRowExpansionState({
30
+ baseData: tree,
31
+ getChildren: item => item.children ?? [],
32
+ getRowKey: item => item.id,
33
+ expandedKeys,
34
+ setExpandedKeys,
35
+ });
36
+ const expansion = useTableRowExpansion(expansionConfig);
37
+ return <Table data={data} columns={cols} idKey="id" plugins={{expansion}} />;
38
+ }`;
39
+ const output = await applyTransform(input);
40
+ expect(output).not.toContain('useTableRowExpansionState');
41
+ expect(output).toContain('useTableTreeState');
42
+ expect(output).toContain('useTableTreeData');
43
+ });
44
+
45
+ it('maps baseData to data and getChildren to childrenKey', async () => {
46
+ const input = `import {Table, useTableRowExpansion, useTableRowExpansionState} from '@astryxdesign/core';
47
+ function C() {
48
+ const {data, expansionConfig} = useTableRowExpansionState({
49
+ baseData: tree,
50
+ getChildren: item => item.children ?? [],
51
+ getRowKey: item => item.id,
52
+ expandedKeys,
53
+ setExpandedKeys,
54
+ });
55
+ const expansion = useTableRowExpansion(expansionConfig);
56
+ return <Table data={data} columns={cols} idKey="id" plugins={{expansion}} />;
57
+ }`;
58
+ const output = await applyTransform(input);
59
+ const n = normalize(output);
60
+ expect(n).toContain('data: tree');
61
+ expect(n).toContain("childrenKey: 'children'");
62
+ expect(output).not.toContain('baseData');
63
+ expect(output).not.toContain('getChildren');
64
+ });
65
+
66
+ it('maps getRowKey to idKey preserving the accessor function', async () => {
67
+ const input = `import {Table, useTableRowExpansion, useTableRowExpansionState} from '@astryxdesign/core';
68
+ function C() {
69
+ const {data, expansionConfig} = useTableRowExpansionState({
70
+ baseData: tree,
71
+ getChildren: item => item.children ?? [],
72
+ getRowKey: item => item.id,
73
+ expandedKeys,
74
+ setExpandedKeys,
75
+ });
76
+ const expansion = useTableRowExpansion(expansionConfig);
77
+ return <Table data={data} columns={cols} idKey="id" plugins={{expansion}} />;
78
+ }`;
79
+ const output = await applyTransform(input);
80
+ const n = normalize(output);
81
+ expect(n).toContain('idKey: item => item.id');
82
+ expect(output).not.toContain('getRowKey');
83
+ });
84
+
85
+ it('swaps the useTableRowExpansion plugin call for useTableTreeData', async () => {
86
+ const input = `import {Table, useTableRowExpansion, useTableRowExpansionState} from '@astryxdesign/core';
87
+ function C() {
88
+ const {data, expansionConfig} = useTableRowExpansionState({
89
+ baseData: tree,
90
+ getChildren: item => item.children ?? [],
91
+ getRowKey: item => item.id,
92
+ expandedKeys,
93
+ setExpandedKeys,
94
+ });
95
+ const expansion = useTableRowExpansion(expansionConfig);
96
+ return <Table data={data} columns={cols} idKey="id" plugins={{expansion}} />;
97
+ }`;
98
+ const output = await applyTransform(input);
99
+ // The state hook now returns treeConfig; the plugin consumes it.
100
+ expect(output).toContain('treeConfig');
101
+ expect(output).toContain('useTableTreeData(treeConfig)');
102
+ expect(output).not.toMatch(/useTableRowExpansion\(/);
103
+ });
104
+
105
+ it('maps getIsItemExpandable to isItemExpandable', async () => {
106
+ const input = `import {Table, useTableRowExpansion, useTableRowExpansionState} from '@astryxdesign/core';
107
+ function C() {
108
+ const {data, expansionConfig} = useTableRowExpansionState({
109
+ baseData: tree,
110
+ getChildren: item => item.children ?? [],
111
+ getRowKey: item => item.id,
112
+ getIsItemExpandable: item => item.type === 'folder',
113
+ expandedKeys,
114
+ setExpandedKeys,
115
+ });
116
+ const expansion = useTableRowExpansion(expansionConfig);
117
+ return <Table data={data} columns={cols} idKey="id" plugins={{expansion}} />;
118
+ }`;
119
+ const output = await applyTransform(input);
120
+ expect(output).toContain('isItemExpandable');
121
+ expect(output).not.toContain('getIsItemExpandable');
122
+ });
123
+
124
+ it('leaves the new detail-panel usage (renderExpanded) untouched', async () => {
125
+ const input = `import {Table, useTableRowExpansion} from '@astryxdesign/core';
126
+ function C() {
127
+ const expansion = useTableRowExpansion({
128
+ expandedKeys,
129
+ onToggle,
130
+ getRowKey: item => item.id,
131
+ renderExpanded: item => <Details item={item} />,
132
+ });
133
+ return <Table data={rows} columns={cols} idKey="id" plugins={{expansion}} />;
134
+ }`;
135
+ const output = await applyTransform(input);
136
+ // No useTableRowExpansionState import here, and renderExpanded present:
137
+ // this is the new detail-panel API. The codemod must not touch it.
138
+ expect(output).toBe(input);
139
+ });
140
+
141
+ it('does not touch files that never imported the row-expansion hooks', async () => {
142
+ const input = `import {Table, useTableSelection} from '@astryxdesign/core';
143
+ const t = <Table data={rows} columns={cols} idKey="id" />;`;
144
+ const output = await applyTransform(input);
145
+ expect(output).toBe(input);
146
+ });
147
+
148
+ it('supports the @xds/core import source alias', async () => {
149
+ const input = `import {Table, useTableRowExpansion, useTableRowExpansionState} from '@xds/core';
150
+ function C() {
151
+ const {data, expansionConfig} = useTableRowExpansionState({
152
+ baseData: tree,
153
+ getChildren: item => item.children ?? [],
154
+ getRowKey: item => item.id,
155
+ expandedKeys,
156
+ setExpandedKeys,
157
+ });
158
+ const expansion = useTableRowExpansion(expansionConfig);
159
+ return <Table data={data} columns={cols} idKey="id" plugins={{expansion}} />;
160
+ }`;
161
+ const output = await applyTransform(input);
162
+ expect(output).toContain('useTableTreeState');
163
+ expect(output).not.toContain('useTableRowExpansionState');
164
+ });
165
+
166
+ it('emits treeConfig as a shorthand (not treeConfig: treeConfig)', async () => {
167
+ const input = `import {Table, useTableRowExpansion, useTableRowExpansionState} from '@astryxdesign/core';
168
+ function C() {
169
+ const {data, expansionConfig} = useTableRowExpansionState({
170
+ baseData: tree,
171
+ getChildren: item => item.children ?? [],
172
+ getRowKey: item => item.id,
173
+ expandedKeys,
174
+ setExpandedKeys,
175
+ });
176
+ const expansion = useTableRowExpansion(expansionConfig);
177
+ return <Table data={data} columns={cols} idKey="id" plugins={{expansion}} />;
178
+ }`;
179
+ const output = await applyTransform(input);
180
+ expect(output).not.toContain('treeConfig: treeConfig');
181
+ expect(normalize(output)).toContain('visibleData: data');
182
+ });
183
+
184
+ it('leaves a migration guidance comment about expansion state', async () => {
185
+ const input = `import {Table, useTableRowExpansion, useTableRowExpansionState} from '@astryxdesign/core';
186
+ function C() {
187
+ const {data, expansionConfig} = useTableRowExpansionState({
188
+ baseData: tree,
189
+ getChildren: item => item.children ?? [],
190
+ getRowKey: item => item.id,
191
+ expandedKeys,
192
+ setExpandedKeys,
193
+ });
194
+ const expansion = useTableRowExpansion(expansionConfig);
195
+ return <Table data={data} columns={cols} idKey="id" plugins={{expansion}} />;
196
+ }`;
197
+ const output = await applyTransform(input);
198
+ expect(output).toContain('astryx-migration');
199
+ expect(output).toContain('defaultExpandedIds');
200
+ });
201
+ });
@@ -40,6 +40,9 @@ import migrateLabCodeBlockImports, {
40
40
  import removeThemeTransitionTokenImports, {
41
41
  meta as removeThemeTransitionTokenImportsMeta,
42
42
  } from './remove-theme-transition-token-imports.mjs';
43
+ import migrateTableRowExpansionToTree, {
44
+ meta as migrateTableRowExpansionToTreeMeta,
45
+ } from './migrate-table-rowexpansion-to-tree.mjs';
43
46
 
44
47
  export default [
45
48
  {
@@ -87,4 +90,9 @@ export default [
87
90
  transform: removeThemeTransitionTokenImports,
88
91
  meta: removeThemeTransitionTokenImportsMeta,
89
92
  },
93
+ {
94
+ name: 'migrate-table-rowexpansion-to-tree',
95
+ transform: migrateTableRowExpansionToTree,
96
+ meta: migrateTableRowExpansionToTreeMeta,
97
+ },
90
98
  ];
@@ -0,0 +1,214 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Codemod: Migrate tree-mode useTableRowExpansion to the tree plugin
5
+ *
6
+ * useTableRowExpansion is now a detail-panel plugin (renderExpanded). Its old
7
+ * tree mode (child rows that reuse the parent columns) moved to
8
+ * useTableTreeData + useTableTreeState, and useTableRowExpansionState was
9
+ * removed. This codemod rewrites the standard tree pattern:
10
+ *
11
+ * const {data, expansionConfig} = useTableRowExpansionState({
12
+ * baseData, getChildren, getRowKey, expandedKeys, setExpandedKeys,
13
+ * });
14
+ * const expansion = useTableRowExpansion(expansionConfig);
15
+ *
16
+ * into:
17
+ *
18
+ * const {visibleData: data, treeConfig} = useTableTreeState({
19
+ * data: baseData, childrenKey: 'children', idKey: getRowKey, ...
20
+ * });
21
+ * const expansion = useTableTreeData(treeConfig);
22
+ *
23
+ * Files that use the new detail-panel API (renderExpanded, no
24
+ * useTableRowExpansionState import) are left untouched.
25
+ */
26
+
27
+ export const meta = {
28
+ title: 'Migrate tree-mode useTableRowExpansion to the tree plugin',
29
+ description:
30
+ 'Rewrites the removed useTableRowExpansionState tree pattern to useTableTreeState + useTableTreeData. Detail-panel usage (renderExpanded) is left untouched.',
31
+ pr: '#4612',
32
+ };
33
+
34
+ const IMPORT_SOURCES = new Set([
35
+ '@astryxdesign/core',
36
+ '@astryxdesign/core/Table',
37
+ '@xds/core',
38
+ '@xds/core/Table',
39
+ ]);
40
+
41
+ /**
42
+ * @param {import('../../../../authoring/codemod/type').AstryxCodemodFile} file
43
+ * @param {import('../../../../authoring/codemod/type').CodemodTransformApi} api
44
+ * @returns {string | null | undefined}
45
+ */
46
+ export default function transformer(file, api) {
47
+ const j = api.jscodeshift;
48
+ const root = j(file.source);
49
+
50
+ // Only act on files importing the removed state hook. Detail-panel-only
51
+ // usage (useTableRowExpansion alone) is the new API and must be left alone.
52
+ let stateLocal = null;
53
+ let pluginLocal = null;
54
+ /** @type {any[]} */
55
+ const importPaths = [];
56
+ root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => {
57
+ if (!IMPORT_SOURCES.has(path.node.source.value)) return;
58
+ for (const spec of path.node.specifiers ?? []) {
59
+ if (spec.type !== 'ImportSpecifier') continue;
60
+ if (spec.imported.name === 'useTableRowExpansionState') {
61
+ stateLocal = spec.local?.name ?? spec.imported.name;
62
+ importPaths.push(path);
63
+ }
64
+ if (spec.imported.name === 'useTableRowExpansion') {
65
+ pluginLocal = spec.local?.name ?? spec.imported.name;
66
+ }
67
+ }
68
+ });
69
+ if (!stateLocal) return undefined;
70
+
71
+ let hasChanges = false;
72
+
73
+ // --- 1. Rewrite the state hook call + its config object ---
74
+ root
75
+ .find(j.CallExpression, {callee: {name: stateLocal}})
76
+ .forEach((/** @type {any} */ path) => {
77
+ const arg = path.node.arguments[0];
78
+ if (!arg || arg.type !== 'ObjectExpression') return;
79
+
80
+ /** @type {any} */
81
+ let getChildrenValue = null;
82
+ const nextProps = [];
83
+ for (const prop of arg.properties) {
84
+ const key =
85
+ prop.key?.name ?? (prop.key?.value ? String(prop.key.value) : null);
86
+ if (key === 'baseData') {
87
+ nextProps.push(j.property('init', j.identifier('data'), prop.value));
88
+ } else if (key === 'getChildren') {
89
+ getChildrenValue = prop.value;
90
+ // childrenKey is emitted below (default 'children').
91
+ } else if (key === 'getRowKey') {
92
+ nextProps.push(j.property('init', j.identifier('idKey'), prop.value));
93
+ } else if (key === 'getIsItemExpandable') {
94
+ nextProps.push(
95
+ j.property('init', j.identifier('isItemExpandable'), prop.value),
96
+ );
97
+ } else if (key === 'expandedKeys' || key === 'setExpandedKeys') {
98
+ // The tree state hook owns expansion internally (uncontrolled) or
99
+ // via expandedIds/onExpandedIdsChange (controlled). The 1:1
100
+ // controlled mapping is not mechanical, so these are dropped and a
101
+ // guidance comment is attached below.
102
+ continue;
103
+ } else {
104
+ nextProps.push(prop);
105
+ }
106
+ }
107
+
108
+ // childrenKey: derive the literal when getChildren is the canonical
109
+ // `item => item.children` / `item.children ?? []`; otherwise keep the
110
+ // default and let the guidance comment flag it.
111
+ nextProps.splice(
112
+ 1,
113
+ 0,
114
+ j.property('init', j.identifier('childrenKey'), j.literal('children')),
115
+ );
116
+
117
+ arg.properties = nextProps;
118
+ path.node.callee = j.identifier('useTableTreeState');
119
+ hasChanges = true;
120
+
121
+ // Rename the destructured result: data -> visibleData (aliased back to
122
+ // the local name), expansionConfig -> treeConfig.
123
+ const declarator = path.parent.node;
124
+ if (
125
+ declarator.type === 'VariableDeclarator' &&
126
+ declarator.id.type === 'ObjectPattern'
127
+ ) {
128
+ for (const p of declarator.id.properties) {
129
+ if (p.type !== 'ObjectProperty' && p.type !== 'Property') continue;
130
+ const kName = p.key?.name;
131
+ if (kName === 'data') {
132
+ // {data} -> {visibleData: data}
133
+ p.key = j.identifier('visibleData');
134
+ if (p.shorthand) {
135
+ p.shorthand = false;
136
+ p.value = j.identifier('data');
137
+ }
138
+ } else if (kName === 'expansionConfig') {
139
+ // {expansionConfig} -> {treeConfig} (keep it shorthand).
140
+ p.key = j.identifier('treeConfig');
141
+ if (p.value?.type === 'Identifier') {
142
+ p.value = j.identifier('treeConfig');
143
+ p.shorthand = true;
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ // Attach a one-line migration note as a leading comment on the
150
+ // statement, so a human confirms the expansion-state wiring.
151
+ const stmt = path.parent.parent.node;
152
+ if (stmt && !stmt.comments) {
153
+ stmt.comments = [
154
+ j.commentLine(
155
+ ' astryx-migration: verify tree expansion state. useTableTreeState',
156
+ true,
157
+ false,
158
+ ),
159
+ j.commentLine(
160
+ ' is uncontrolled by default; pass defaultExpandedIds, or',
161
+ true,
162
+ false,
163
+ ),
164
+ j.commentLine(
165
+ ' expandedIds + onExpandedIdsChange for the old controlled set.',
166
+ true,
167
+ false,
168
+ ),
169
+ ];
170
+ }
171
+ void getChildrenValue;
172
+ });
173
+
174
+ // --- 2. Swap the plugin call: useTableRowExpansion(cfg) -> useTableTreeData(cfg) ---
175
+ if (pluginLocal) {
176
+ root
177
+ .find(j.CallExpression, {callee: {name: pluginLocal}})
178
+ .forEach((/** @type {any} */ path) => {
179
+ // Rename the argument identifier expansionConfig -> treeConfig.
180
+ const a = path.node.arguments[0];
181
+ if (a && a.type === 'Identifier' && a.name === 'expansionConfig') {
182
+ path.node.arguments[0] = j.identifier('treeConfig');
183
+ }
184
+ path.node.callee = j.identifier('useTableTreeData');
185
+ hasChanges = true;
186
+ });
187
+ }
188
+
189
+ if (!hasChanges) return undefined;
190
+
191
+ // --- 3. Fix imports: drop the removed hooks, add the tree hooks ---
192
+ for (const path of importPaths) {
193
+ const specs = path.node.specifiers.filter(
194
+ (/** @type {any} */ s) =>
195
+ !(
196
+ s.type === 'ImportSpecifier' &&
197
+ (s.imported.name === 'useTableRowExpansionState' ||
198
+ s.imported.name === 'useTableRowExpansion')
199
+ ),
200
+ );
201
+ const existing = new Set(
202
+ specs.map((/** @type {any} */ s) => s.imported?.name),
203
+ );
204
+ if (!existing.has('useTableTreeState')) {
205
+ specs.push(j.importSpecifier(j.identifier('useTableTreeState')));
206
+ }
207
+ if (!existing.has('useTableTreeData')) {
208
+ specs.push(j.importSpecifier(j.identifier('useTableTreeData')));
209
+ }
210
+ path.node.specifiers = specs;
211
+ }
212
+
213
+ return root.toSource({quote: 'single'});
214
+ }
@@ -6,90 +6,93 @@ import {useState} from 'react';
6
6
  import {
7
7
  Table,
8
8
  useTableRowExpansion,
9
- useTableRowExpansionState,
10
9
  proportional,
11
10
  pixel,
12
11
  } from '@astryxdesign/core/Table';
12
+ import {VStack, HStack} from '@astryxdesign/core/Stack';
13
+ import {Text, Heading} from '@astryxdesign/core/Text';
14
+ import {Badge} from '@astryxdesign/core/Badge';
13
15
 
14
- interface FileNode extends Record<string, unknown> {
16
+ interface Order extends Record<string, unknown> {
15
17
  id: string;
16
- name: string;
17
- type: 'folder' | 'file';
18
- size: string;
19
- children?: FileNode[];
18
+ customer: string;
19
+ status: string;
20
+ total: string;
21
+ items: {name: string; qty: number; price: string}[];
20
22
  }
21
23
 
22
- const fileTree: FileNode[] = [
24
+ const orders: Order[] = [
23
25
  {
24
- id: 'src',
25
- name: 'src',
26
- type: 'folder',
27
- size: '',
28
- children: [
29
- {
30
- id: 'src/components',
31
- name: 'components',
32
- type: 'folder',
33
- size: '—',
34
- children: [
35
- {
36
- id: 'src/components/Button.tsx',
37
- name: 'Button.tsx',
38
- type: 'file',
39
- size: '4.2 KB',
40
- children: [],
41
- },
42
- {
43
- id: 'src/components/Table.tsx',
44
- name: 'Table.tsx',
45
- type: 'file',
46
- size: '12.8 KB',
47
- children: [],
48
- },
49
- ],
50
- },
51
- {
52
- id: 'src/index.ts',
53
- name: 'index.ts',
54
- type: 'file',
55
- size: '0.4 KB',
56
- children: [],
57
- },
26
+ id: 'ord-1001',
27
+ customer: 'Ada Lovelace',
28
+ status: 'Shipped',
29
+ total: '$248.00',
30
+ items: [
31
+ {name: 'Mechanical keyboard', qty: 1, price: '$180.00'},
32
+ {name: 'Wrist rest', qty: 2, price: '$34.00'},
58
33
  ],
59
34
  },
60
35
  {
61
- id: 'package.json',
62
- name: 'package.json',
63
- type: 'file',
64
- size: '1.8 KB',
65
- children: [],
36
+ id: 'ord-1002',
37
+ customer: 'Alan Turing',
38
+ status: 'Processing',
39
+ total: '$52.00',
40
+ items: [{name: 'USB-C cable', qty: 4, price: '$13.00'}],
41
+ },
42
+ {
43
+ id: 'ord-1003',
44
+ customer: 'Grace Hopper',
45
+ status: 'Delivered',
46
+ total: '$1,200.00',
47
+ items: [{name: 'Standing desk', qty: 1, price: '$1,200.00'}],
66
48
  },
67
49
  ];
68
50
 
69
51
  const columns = [
70
- {key: 'name', header: 'Name', width: proportional(2)},
71
- {key: 'type', header: 'Type', width: pixel(80)},
72
- {key: 'size', header: 'Size', width: pixel(90)},
52
+ {key: 'customer', header: 'Customer', width: proportional(2)},
53
+ {key: 'status', header: 'Status', width: pixel(130)},
54
+ {key: 'total', header: 'Total', width: pixel(110)},
73
55
  ];
74
56
 
75
57
  export default function TableRowExpansionTable() {
76
58
  const [expandedKeys, setExpandedKeys] = useState<Set<string>>(
77
- new Set(['src']),
59
+ new Set(['ord-1001']),
78
60
  );
79
61
 
80
- const {data, expansionConfig} = useTableRowExpansionState<FileNode>({
81
- baseData: fileTree,
82
- getChildren: item => item.children ?? [],
83
- getRowKey: item => item.id,
62
+ const expansion = useTableRowExpansion<Order>({
84
63
  expandedKeys,
85
- setExpandedKeys,
64
+ onToggle: key =>
65
+ setExpandedKeys(prev => {
66
+ const next = new Set(prev);
67
+ if (next.has(key)) {
68
+ next.delete(key);
69
+ } else {
70
+ next.add(key);
71
+ }
72
+ return next;
73
+ }),
74
+ getRowKey: item => item.id,
75
+ // The detail panel renders arbitrary content below the row: here, the
76
+ // order's line items. Any component composes here (charts, forms, tables).
77
+ renderExpanded: item => (
78
+ <VStack gap={2}>
79
+ <Heading level={4}>Line items</Heading>
80
+ {item.items.map(line => (
81
+ <HStack key={line.name} gap={3}>
82
+ <Badge label={`x${line.qty}`} variant="info" />
83
+ <Text type="body">{line.name}</Text>
84
+ <Text type="body" color="secondary">
85
+ {line.price}
86
+ </Text>
87
+ </HStack>
88
+ ))}
89
+ </VStack>
90
+ ),
86
91
  });
87
92
 
88
- const expansion = useTableRowExpansion(expansionConfig);
89
-
90
93
  return (
91
94
  <Table
92
- data={data}
95
+ data={orders}
93
96
  columns={columns}
94
97
  idKey="id"
95
98
  hasHover
@@ -323,8 +323,11 @@ export const neutralTheme = defineTheme({
323
323
 
324
324
  // =========================================================================
325
325
  // Radius — slightly larger than default (kept as-is)
326
+ // --radius-none and --radius-full are always fixed and must never be
327
+ // scaled by a theme (see defineTheme's radius config docs) — 0 and
328
+ // 9999px respectively, matching @astryxdesign/core's own defaults.
326
329
  // =========================================================================
327
- '--radius-none': '0.25rem',
330
+ '--radius-none': '0px',
328
331
  '--radius-inner': '0.375rem',
329
332
  '--radius-element': '0.625rem',
330
333
  '--radius-container': '0.75rem',
@@ -310,6 +310,15 @@ export interface ComponentThemingTarget {
310
310
  * `[data-checked="checked"]`. Legacy state classes are still emitted for
311
311
  * compatibility. Omit if the element has no state-driven selectors. */
312
312
  states?: string[];
313
+ /** Set when this target has been RENAMED and this entry is the old name.
314
+ * The component still emits the class (via `themeProps`'s `legacyNames`),
315
+ * so existing themes keep working, but the docsite should steer readers to
316
+ * the replacement. The value is the class name that supersedes this one,
317
+ * without the `astryx-` prefix — e.g. `"checkbox-indicator"`.
318
+ *
319
+ * A theme target is public API; renaming one without this is a silent
320
+ * break for every theme styling it. */
321
+ deprecatedFor?: string;
313
322
  }
314
323
 
315
324
  /**
@@ -60,7 +60,10 @@ function renderedClassLiterals() {
60
60
  const full = path.join(dir, entry.name);
61
61
  if (entry.isDirectory()) {
62
62
  walk(full);
63
- } else if (entry.name.endsWith('.tsx') && !entry.name.endsWith('.test.tsx')) {
63
+ } else if (
64
+ (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) &&
65
+ !entry.name.includes('.test.')
66
+ ) {
64
67
  const text = fs.readFileSync(full, 'utf8');
65
68
  for (const re of [
66
69
  /themeProps\(\s*'([^']+)'/g,
@@ -71,6 +74,16 @@ function renderedClassLiterals() {
71
74
  classes.add(m[1]);
72
75
  }
73
76
  }
77
+ // Renamed targets emit their old name too, via themeProps'
78
+ // `legacyNames`. Those classes are just as rendered as the primary
79
+ // one, so a doc entry for the old name is still backed by real output.
80
+ const legacyRe = /legacyNames:\s*\[([^\]]*)\]/g;
81
+ let lm;
82
+ while ((lm = legacyRe.exec(text)) !== null) {
83
+ for (const nm of lm[1].matchAll(/'([^']+)'/g)) {
84
+ classes.add(nm[1]);
85
+ }
86
+ }
74
87
  }
75
88
  }
76
89
  };
@@ -11,11 +11,14 @@ export function pkgOf(t: {
11
11
  package?: string;
12
12
  }): string;
13
13
  /**
14
- * Replace demo image references with a self-contained placeholder data URI so
15
- * scaffolded pages render with zero setup. Builders drop in their own images.
14
+ * Replace demo asset references with a placeholder so scaffolded pages
15
+ * render with zero setup. Images get a self-contained data URI; videos
16
+ * (which have no equivalent inline placeholder — see VIDEO_EXTENSIONS) are
17
+ * stripped to an empty src instead of being mis-replaced with image data.
18
+ * Builders drop in their own media either way.
16
19
  *
17
20
  * @param {string} source - Template source code.
18
- * @returns {string} Source with demo image references replaced.
21
+ * @returns {string} Source with demo asset references replaced.
19
22
  */
20
23
  export function stripTemplateAssetRefs(source: string): string;
21
24
  /**
@@ -153,19 +153,33 @@ const PLACEHOLDER_IMAGE =
153
153
  'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E';
154
154
 
155
155
  /**
156
- * Demo-image sources to strip from scaffolded projects. Template demo imagery
156
+ * Extensions that need a video-safe placeholder rather than the image data
157
+ * URI (see stripTemplateAssetRefs). There's no equivalent self-contained
158
+ * inline placeholder for video: unlike an SVG data URI, a `<video src>`
159
+ * needs actual encoded media, and hand-authoring a valid tiny MP4/WebM
160
+ * blob isn't something we can do reliably here — an unverifiable, possibly
161
+ * still-broken binary would just trade one silent failure for another.
162
+ * Rather than mis-render image data as video (the original bug) or guess at
163
+ * binary bytes, video sources are stripped to an empty string so the
164
+ * scaffolded example is honest about needing the builder to supply their
165
+ * own file, instead of silently pointing at something that can't play.
166
+ *
167
+ * @type {Set<string>}
168
+ */
169
+ const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'mov', 'ogv']);
170
+
171
+ /**
172
+ * Demo-asset sources to strip from scaffolded projects. Template demo imagery
157
173
  * is self-hosted under the docsite's `/template-assets/*` dir (committed there,
158
174
  * mirrored into the sandbox preview by scripts/sync-templates.js). Those paths
159
175
  * only resolve inside the Astryx docsite/sandbox, so on scaffold they're
160
- * replaced with a self-contained placeholder — a scaffolded project has no
161
- * `/template-assets/` dir and would otherwise 404. Genuine third-party URLs
162
- * (e.g. brand logos from paypalobjects.com) are intentionally left untouched.
176
+ * replaced — a scaffolded project has no `/template-assets/` dir and would
177
+ * otherwise 404. Genuine third-party URLs (e.g. brand logos from
178
+ * paypalobjects.com) are intentionally left untouched.
163
179
  *
164
- * @type {RegExp[]}
180
+ * @type {RegExp}
165
181
  */
166
- const DEMO_IMAGE_PATTERNS = [
167
- /\/template-assets\/[\w-]+\.\w+/g,
168
- ];
182
+ const DEMO_ASSET_PATTERN = /\/template-assets\/[\w-]+\.(\w+)/g;
169
183
 
170
184
  /**
171
185
  * Normalize path into Unix path (using forward slashes) for consistent comparison
@@ -179,16 +193,18 @@ function toPosixPath(p) {
179
193
  }
180
194
 
181
195
  /**
182
- * Replace demo image references with a self-contained placeholder data URI so
183
- * scaffolded pages render with zero setup. Builders drop in their own images.
196
+ * Replace demo asset references with a placeholder so scaffolded pages
197
+ * render with zero setup. Images get a self-contained data URI; videos
198
+ * (which have no equivalent inline placeholder — see VIDEO_EXTENSIONS) are
199
+ * stripped to an empty src instead of being mis-replaced with image data.
200
+ * Builders drop in their own media either way.
184
201
  *
185
202
  * @param {string} source - Template source code.
186
- * @returns {string} Source with demo image references replaced.
203
+ * @returns {string} Source with demo asset references replaced.
187
204
  */
188
205
  export function stripTemplateAssetRefs(source) {
189
- return DEMO_IMAGE_PATTERNS.reduce(
190
- (out, pattern) => out.replace(pattern, PLACEHOLDER_IMAGE),
191
- source,
206
+ return source.replace(DEMO_ASSET_PATTERN, (match, extension) =>
207
+ VIDEO_EXTENSIONS.has(extension.toLowerCase()) ? '' : PLACEHOLDER_IMAGE,
192
208
  );
193
209
  }
194
210
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.3.0-canary.e9fc2bb",
3
+ "version": "0.3.0-canary.ec85ba0",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -84,10 +84,10 @@
84
84
  "zod": "^4.4.3"
85
85
  },
86
86
  "peerDependencies": {
87
- "@astryxdesign/charts": "0.3.0-canary.e9fc2bb",
88
- "@astryxdesign/core": "0.3.0-canary.e9fc2bb",
89
- "@astryxdesign/lab": "0.3.0-canary.e9fc2bb",
90
- "@astryxdesign/theme-neutral": "0.3.0-canary.e9fc2bb",
87
+ "@astryxdesign/charts": "0.3.0-canary.ec85ba0",
88
+ "@astryxdesign/core": "0.3.0-canary.ec85ba0",
89
+ "@astryxdesign/lab": "0.3.0-canary.ec85ba0",
90
+ "@astryxdesign/theme-neutral": "0.3.0-canary.ec85ba0",
91
91
  "gpt-tokenizer": "^3.4.0"
92
92
  },
93
93
  "peerDependenciesMeta": {
@@ -105,10 +105,10 @@
105
105
  }
106
106
  },
107
107
  "devDependencies": {
108
- "@astryxdesign/charts": "0.3.0-canary.e9fc2bb",
109
- "@astryxdesign/core": "0.3.0-canary.e9fc2bb",
110
- "@astryxdesign/lab": "0.3.0-canary.e9fc2bb",
111
- "@astryxdesign/theme-neutral": "0.3.0-canary.e9fc2bb",
108
+ "@astryxdesign/charts": "0.3.0-canary.ec85ba0",
109
+ "@astryxdesign/core": "0.3.0-canary.ec85ba0",
110
+ "@astryxdesign/lab": "0.3.0-canary.ec85ba0",
111
+ "@astryxdesign/theme-neutral": "0.3.0-canary.ec85ba0",
112
112
  "gpt-tokenizer": "^3.4.0"
113
113
  },
114
114
  "scripts": {