@astryxdesign/cli 0.4.7-canary.fe95cd5 → 0.5.0-canary.009bcb3

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/api/search/search.mjs +50 -4
  3. package/api/search/search.test.mjs +71 -0
  4. package/api/template/data-token-fallbacks.test.mjs +85 -0
  5. package/api/theme/build/build.mjs +35 -6
  6. package/api/theme/targets/targets.test.mjs +1 -0
  7. package/api/theme/themeTargets.doc.mjs +3 -3
  8. package/assets/codemods/__tests__/registry.test.mjs +1 -0
  9. package/assets/codemods/registry.mjs +1 -0
  10. package/assets/codemods/transforms/v0.5.0/__tests__/next-codemods.test.mjs +127 -0
  11. package/assets/codemods/transforms/v0.5.0/banner-collapsible-content.mjs +171 -0
  12. package/assets/codemods/transforms/v0.5.0/index.mjs +20 -0
  13. package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.doc.mjs +1 -1
  14. package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.tsx +1 -1
  15. package/assets/templates/blocks/components/DateInput/DateInputClearable.tsx +5 -1
  16. package/assets/templates/blocks/components/DateInput/DateInputDateRange.tsx +6 -1
  17. package/assets/templates/blocks/components/DateInput/DateInputFormats.tsx +5 -1
  18. package/assets/templates/blocks/components/DateInput/DateInputShowcase.tsx +4 -1
  19. package/assets/templates/blocks/components/DateInput/DateInputWithDescription.tsx +5 -1
  20. package/assets/templates/blocks/components/DateInput/DateInputWithValidation.tsx +5 -1
  21. package/assets/templates/blocks/components/HoverCard/HoverCardHookUsage.tsx +4 -1
  22. package/assets/templates/blocks/components/Step/StepContent.doc.mjs +1 -1
  23. package/assets/templates/blocks/components/Step/StepIndicator.doc.mjs +1 -1
  24. package/assets/templates/blocks/components/Step/StepShowcase.doc.mjs +1 -1
  25. package/assets/templates/blocks/components/Stepper/StepperCustomContent.doc.mjs +1 -1
  26. package/assets/templates/blocks/components/Stepper/StepperShowcase.doc.mjs +1 -1
  27. package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +1 -1
  28. package/assets/templates/pages/dashboard-data/page.tsx +1 -1
  29. package/assets/templates/pages/dashboard-portfolio/page.tsx +5 -5
  30. package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1 -1
  31. package/assets/templates/pages/settings-dialog/page.tsx +2543 -757
  32. package/assets/templates/pages/settings-dialog/template.doc.mjs +1 -2
  33. package/assets/templates/pages/table-filter/page.tsx +4093 -0
  34. package/assets/templates/pages/table-filter/template.doc.mjs +12 -0
  35. package/assets/templates/pages/table-grouped/page.tsx +151 -144
  36. package/assets/templates/pages/theme-showcase/page.tsx +36 -17
  37. package/assets/templates/themes/chocolate/chocolateTheme.ts +3 -1
  38. package/assets/templates/themes/matcha/matchaTheme.ts +3 -1
  39. package/assets/templates/themes/neutral/neutralTheme.ts +3 -1
  40. package/assets/templates/themes/stone/stoneTheme.ts +3 -1
  41. package/clients/cli/commands/build-theme.color-scheme.test.mjs +7 -6
  42. package/clients/cli/commands/build-theme.data-tokens.test.mjs +144 -0
  43. package/clients/cli/commands/build-theme.variants.test.mjs +3 -0
  44. package/clients/cli/commands/theme-targets.behavior.test.mjs +1 -1
  45. package/clients/cli/commands/theme-targets.doc.mjs +1 -1
  46. package/clients/cli/lib/component-format.mjs +2 -2
  47. package/foundation/discovery/component-discovery.mjs +10 -9
  48. package/foundation/discovery/hook-discovery.mjs +2 -1
  49. package/foundation/discovery/hook-discovery.test.mjs +156 -37
  50. package/foundation/discovery/theming-targets.d.mts +38 -0
  51. package/foundation/discovery/theming-targets.mjs +67 -0
  52. package/foundation/discovery/theming-targets.test.mjs +119 -1
  53. package/foundation/fs/paths.d.mts +16 -0
  54. package/foundation/fs/paths.mjs +36 -0
  55. package/foundation/fs/paths.test.mjs +29 -1
  56. package/package.json +9 -9
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # @xds/cli
2
2
 
3
+ # 0.5.0
4
+
5
+ #### Breaking Changes
6
+
7
+ - Banner: the collapse axis moves onto one `collapsible` prop, and content can opt out of collapsing (#5255)
8
+ Banner inferred its disclosure from its content: any `children` got a chevron in the header and were hidden until it was pressed. There was no way to show content without a toggle — the case a banner most often wants, a list of the three fields that failed validation — and `defaultIsExpanded` was the only knob, with no controlled mode.
9
+
10
+ The whole axis is now one `boolean | CollapsibleConfig` prop, following the boolean-or-config convention `SideNav.collapsible` set, and backed by the shared `useCollapsible` hook rather than Banner's own state:
11
+
12
+ ```tsx
13
+ <Banner status="error" title="3 fields need attention">…</Banner> // unchanged: collapsible, starts closed
14
+ <Banner collapsible={false}>…</Banner> // new: always visible, no toggle
15
+ <Banner collapsible={{defaultIsOpen: true}}>…</Banner> // replaces defaultIsExpanded
16
+ <Banner collapsible={{isOpen, onOpenChange}}>…</Banner> // new: controlled
17
+ ```
18
+
19
+ **The default is unchanged** — a banner that never mentioned `defaultIsExpanded` behaves exactly as it did. The breaking part is the prop itself: `defaultIsExpanded` is removed in favour of the config, which is a type error at every JSX call site that names it.
20
+
21
+ **Codemod:** `npx astryx upgrade --codemod banner-collapsible-content`
22
+
23
+ It rewrites `defaultIsExpanded` to `collapsible={{defaultIsOpen: true}}` and drops `defaultIsExpanded={false}`, which is now the default. Banners that never set the prop are left alone.
24
+
25
+ **One case the codemod and the compiler both miss: a spread.** `defaultIsExpanded` inside a props object is out of the transform's scope. A props object in a typed position still fails to compile — but an inferred one that is spread, `<Banner {...args} />`, does not, because TypeScript does not excess-property-check a spread. The prop then falls through to the DOM and the banner quietly starts collapsed. **Grep for `defaultIsExpanded` after running the codemod** and migrate any spread sites by hand.
26
+
27
+ #### New Components
28
+
29
+ - Promote `Stepper` and `Step` from the canary-only Lab package to Core. The stable package now ships their existing horizontal/vertical layouts, separated and on-track indicators, semantic status, density, and non-linear navigation, plus Core documentation and rendered examples. The default `aria-label` is now localized.
30
+ Advancing one step now animates the connector. Every connector the four layouts draw — the separated bars and the on-track segments alike — grows its accent fill out of the segment's leading edge instead of swapping a background color, so moving forward reads as progress travelling the track. That one gesture is the only thing that animates: going back, jumping forward by more than one step, and mounting mid-flow all apply at once, as does any change under `prefers-reduced-motion`. Retreats are deliberately instant — run in reverse the same transition ends on a shrinking stub of accent, and a remnant still on the track reads as unfinished where the identical curve growing forward reads as arrived — and multi-step jumps are instant because a jump is a navigation rather than a progression, so sweeping a front across the crossed segments only makes the user sit out a journey they asked to skip. Where one span is drawn by several segments (the on-track layouts split a span between two steps, three when a content slot sits between them) the segments take abutting slices of the span's time and run linearly, so the fill reads as one line growing at a constant speed rather than pieces lighting in turn.
31
+
32
+ Five visual fixes land with the promotion. Horizontal steps now divide the track evenly instead of sizing to their own labels, so every progress segment is the same width regardless of how long a step is named. Number indicators shrink from 20px to 16px to match the check, ring, and custom-icon indicators, so a step swapping its number for a check as it completes no longer nudges the label beside it. A step description now occupies a 16px box rather than a 24px one — it previously inherited the page's line box instead of applying its own leading, which opened an 8px gap under the label. A step's content slot now starts flush with the label above it at every density: the slot renders outside the density-padded label area, so it was hanging one pad short of it. And a vertical on-track step carrying content keeps its connector unbroken — the content renders below the row that draws the line, so the track used to split open around any step with content (#5201).
33
+
34
+ #### New Features
35
+
36
+ - AspectRatio: emit `ratio` as a class-level declaration instead of a hard inline style, so the ratio can be overridden responsively: StyleX consumers pass an `aspect-ratio` rule via `xstyle` (including under `@media`/`@container` conditions), and plain-CSS/Tailwind consumers override `aspect-ratio` from their own unlayered rules, which beat the `astryx-base` cascade layer regardless of specificity. The mixed-gallery template's hero now switches 3:1 to 3:2 when the grid stacks with a one-line override on a single element, replacing the duplicated hero markup the fixed inline ratio previously forced (#3883, closes #2798)
37
+ - CLI: `astryx theme targets` lists every component theming target — the `defineTheme` key, the class it paints, and the props and states it accepts — for one component or the whole system, with `--json` for lint and audit scripts. `astryx theme --help` now points at component overrides instead of reading as a build-tool menu. The listing and `theme build`'s override validation share one enumeration of the component docs, so neither can drift from the components (#5115).
38
+
39
+ #### Fixes
40
+
41
+ - neutral theme: darken the light-mode error red from `#e33f4a` to `#c9303a` so the filled `Badge variant="error"` label clears WCAG 2.1 AA. White on `#e33f4a` is 4.14:1 and the badge label is 12px/weight 500, so the 4.5:1 normal-text threshold applies rather than the 3:1 large-text allowance; `#c9303a` gives 5.29:1 while holding the hue (OKLCH H 21.9 -> 22.8, C 0.200 -> 0.189). StatusDot and the ProgressBar `--color-error` rebinding move with it — both are documented as tracking the badge fill so the dot and its badge read as one status language. Dark mode is untouched (dark text on `#ff705d`, 6.60:1). Adds `scripts/check-badge-contrast.test.mjs`, which resolves every theme's badge label/fill pair through `light-dark()`, `var()` indirection and alpha compositing, and holds all of them to 4.5:1 (#4446).
42
+ - Unified search and build now include components contributed by integrations, so a component registered through an integration is findable and buildable alongside the built-in set instead of silently missing from both (#5259).
43
+ - Table - Grouped page template: wrap the rows in `TableBody`
44
+ The template rendered `<TableRow>` straight into `<Table>`, so the emitted DOM was `<table><tr>`. `<table>` cannot contain a row directly: the HTML parser inserts an implied `<tbody>` when it parses server-rendered markup and React does not when it renders on the client, so anyone who copied the template into an app as a server-rendered page inherited a hydration mismatch in their own app. Client-only the DOM is still invalid — nothing reparents the rows, so the table ends up with `<tr>` children and no `<tbody>` at all, and any CSS or query aimed at `tbody` silently misses.
45
+
46
+ The rows now sit in `<TableBody>`, the same element the data-driven `data={...}` path renders, so styling, dividers, and column widths are unchanged (#5278).
47
+
48
+ #### Other Changes
49
+
50
+ - Public component theming vars are enumerable, and guarded against being documented but unsettable
51
+ `collectThemingVars` joins `collectThemingTargets` as part of the one enumeration the theming surface is read from. Two guards ride on it: a documented public var no component reads compiles to a declaration that never applies, and a var the component writes inline outranks every cascade layer, so no theme can reach it. Both had shipped; neither is visible in the generated theme CSS the jsdom suites assert on (#5409).
52
+
53
+ #### Contributors
54
+
55
+ Thanks to everyone who contributed to this release:
56
+
57
+ - @AKnassa
58
+ - @andrskr
59
+ - @cixzhang
60
+ - @ernestt
61
+ - @freddymeta
62
+ - @jiunshinn
63
+ - @rubyycheung
64
+
65
+ ---
66
+
3
67
  # 0.4.7
4
68
 
5
69
  ---
@@ -34,10 +34,12 @@ import {pathToFileURL} from 'node:url';
34
34
  import {findCoreDir} from '../../foundation/fs/paths.mjs';
35
35
  import {
36
36
  discoverComponents,
37
+ discoverIntegrationComponents,
37
38
  findComponentReadme,
38
39
  resolveImportPath,
39
40
  } from '../../foundation/discovery/component-discovery.mjs';
40
41
  import {discoverHooks, findHookDoc} from '../../foundation/discovery/hook-discovery.mjs';
42
+ import {loadIntegrationsSafely} from '../component/_adapter.mjs';
41
43
  import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
42
44
  import {discoverTemplates, extractComponents} from '../template/template.mjs';
43
45
  import {loadDocsCatalog, loadTopicDoc} from '../docs/_adapter.mjs';
@@ -350,12 +352,12 @@ async function loadModuleDoc(docPath, exportName = 'docs') {
350
352
  }
351
353
 
352
354
  /**
353
- * Build component candidates: name + keywords + usage/description from the
354
- * component's .doc.mjs.
355
+ * Build component candidates from core's own tree: name + keywords +
356
+ * usage/description from the component's .doc.mjs.
355
357
  * @param {string} coreDir
356
358
  * @returns {Promise<Candidate[]>}
357
359
  */
358
- async function gatherComponents(coreDir) {
360
+ async function gatherCoreComponents(coreDir) {
359
361
  const grouped = discoverComponents(coreDir);
360
362
  const names = Object.values(grouped).flat();
361
363
  /** @type {Candidate[]} */
@@ -383,6 +385,50 @@ async function gatherComponents(coreDir) {
383
385
  return candidates;
384
386
  }
385
387
 
388
+ /**
389
+ * Build component candidates contributed by the project's configured
390
+ * integrations (astryx.config's `integrations`): name + keywords +
391
+ * usage/description from each component's .doc.mjs, same as core. Without
392
+ * this, an integration component is invisible to `search`/`build` even
393
+ * though `component --list`/`component <Name>` already resolve it — the two
394
+ * discovery paths silently disagreed.
395
+ * @param {string} cwd
396
+ * @returns {Promise<Candidate[]>}
397
+ */
398
+ async function gatherIntegrationComponents(cwd) {
399
+ const loadedIntegrations = await loadIntegrationsSafely(cwd);
400
+ /** @type {Candidate[]} */
401
+ const candidates = [];
402
+ for (const integration of loadedIntegrations) {
403
+ for (const rec of discoverIntegrationComponents(integration)) {
404
+ const doc = await loadModuleDoc(rec.docPath);
405
+ candidates.push({
406
+ domain: 'component',
407
+ name: rec.name,
408
+ keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [],
409
+ description: doc ? doc.usage?.description || doc.description || '' : '',
410
+ _import: rec.package,
411
+ });
412
+ }
413
+ }
414
+ return candidates;
415
+ }
416
+
417
+ /**
418
+ * Build component candidates: core's own tree plus every configured
419
+ * integration's components.
420
+ * @param {string} coreDir
421
+ * @param {string} cwd
422
+ * @returns {Promise<Candidate[]>}
423
+ */
424
+ async function gatherComponents(coreDir, cwd) {
425
+ const [core, integrations] = await Promise.all([
426
+ gatherCoreComponents(coreDir),
427
+ gatherIntegrationComponents(cwd),
428
+ ]);
429
+ return [...core, ...integrations];
430
+ }
431
+
386
432
  /**
387
433
  * Build hook candidates: name + keywords + usage/description from the hook's
388
434
  * .doc.mjs.
@@ -611,7 +657,7 @@ export async function search(query, options = {}) {
611
657
  /** @param {string} d */
612
658
  const wants = d => !type || type === d;
613
659
  const [components, hooks, docTopics, templates] = await Promise.all([
614
- wants('component') ? gatherComponents(coreDir) : [],
660
+ wants('component') ? gatherComponents(coreDir, cwd) : [],
615
661
  wants('hook') ? gatherHooks(coreDir) : [],
616
662
  wants('doc') ? gatherDocs(cwd) : [],
617
663
  wants('template') ? gatherTemplates(cwd) : [],
@@ -10,9 +10,17 @@
10
10
  * `limit`, an empty query, and a bad `--type` all throw AstryxError with the
11
11
  * ERR_INVALID_ARGUMENT code, so a direct `@astryxdesign/cli/api` caller gets the
12
12
  * same contract as `astryx search` on the command line.
13
+ *
14
+ * The last describe block covers integration-contributed components, using the
15
+ * same temp-consumer harness as template-integration.test.mjs. Before this,
16
+ * `search`/`build` only ever scanned @astryxdesign/core — an integration's own
17
+ * components were invisible to both, even though `component --list` and
18
+ * `component <Name>` already resolved them. The two discovery paths silently
19
+ * disagreed.
13
20
  */
14
21
 
15
22
  import {describe, it, expect} from 'vitest';
23
+ import * as fs from 'node:fs';
16
24
  import * as path from 'node:path';
17
25
  import {fileURLToPath} from 'node:url';
18
26
  import {search, SEARCH_DOMAINS} from './search.mjs';
@@ -92,3 +100,66 @@ describe('search leaf — limit validation (API matches the CLI contract)', () =
92
100
  });
93
101
  }, SLOW);
94
102
  });
103
+
104
+ describe('search leaf — integration components', () => {
105
+ /**
106
+ * A minimal consumer project: a stub `@astryxdesign/core` (so `findCoreDir`
107
+ * resolves without needing the real package) plus an installed
108
+ * `@acme/widgets` integration that contributes one component.
109
+ */
110
+ function makeConsumerWithIntegrationComponent() {
111
+ const dir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-search-it-'));
112
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({name: 'consumer'}));
113
+ fs.writeFileSync(
114
+ path.join(dir, 'astryx.config.mjs'),
115
+ `export default { integrations: ['@acme/widgets'] };\n`,
116
+ );
117
+
118
+ // Stub core: just needs to exist with an (empty) src/ so discoverComponents
119
+ // doesn't throw. Its own component list is irrelevant to this test.
120
+ const coreDir = path.join(dir, 'node_modules', '@astryxdesign', 'core');
121
+ fs.mkdirSync(path.join(coreDir, 'src'), {recursive: true});
122
+
123
+ const widgetsDir = path.join(dir, 'node_modules', '@acme', 'widgets');
124
+ fs.mkdirSync(path.join(widgetsDir, 'components'), {recursive: true});
125
+ fs.writeFileSync(
126
+ path.join(widgetsDir, 'package.json'),
127
+ JSON.stringify({name: '@acme/widgets', version: '1.0.0'}),
128
+ );
129
+ fs.writeFileSync(
130
+ path.join(widgetsDir, 'astryx.integration.mjs'),
131
+ `export default { components: './components' };\n`,
132
+ );
133
+ fs.writeFileSync(
134
+ path.join(widgetsDir, 'components', 'FancyGizmo.doc.mjs'),
135
+ `export const docs = {
136
+ name: 'FancyGizmo',
137
+ keywords: ['gizmo', 'widget'],
138
+ usage: {description: 'A fancy gizmo widget.'},
139
+ };\n`,
140
+ );
141
+
142
+ return dir;
143
+ }
144
+
145
+ it('includes a component contributed by a configured integration', async () => {
146
+ const dir = makeConsumerWithIntegrationComponent();
147
+ try {
148
+ const r = await search('gizmo', {cwd: dir, type: 'component'});
149
+ expect(r.data.results.some(x => x.name === 'FancyGizmo')).toBe(true);
150
+ } finally {
151
+ fs.rmSync(dir, {recursive: true, force: true});
152
+ }
153
+ }, SLOW);
154
+
155
+ it('reports the contributing package as the import hint', async () => {
156
+ const dir = makeConsumerWithIntegrationComponent();
157
+ try {
158
+ const r = await search('FancyGizmo', {cwd: dir, type: 'component'});
159
+ const hit = r.data.results.find(x => x.name === 'FancyGizmo');
160
+ expect(hit?.import).toBe('@acme/widgets');
161
+ } finally {
162
+ fs.rmSync(dir, {recursive: true, force: true});
163
+ }
164
+ }, SLOW);
165
+ });
@@ -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 _dataTokenDefaults = 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
+ _dataTokenDefaults = coreTheme.dataTokenDefaults;
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
@@ -349,8 +351,8 @@ const _augmentationTargetCache = new Map();
349
351
  * Resolve a rendered theme class token (the key without `astryx-`) to candidate
350
352
  * public core subpaths and interface prefixes that may own its augmentable prop
351
353
  * maps. Some tokens are subtargets documented by a parent component
352
- * (`avatar-status-dot` augments `@astryxdesign/core/Avatar`), and some stable
353
- * class tokens intentionally omit word separators (`progressbar`, `statusdot`)
354
+ * (`avatar-status-dot` augments `@astryxdesign/core/Avatar`), and some
355
+ * deprecated tokens still omit word separators (`progressbar`, `statusdot`)
354
356
  * while the public API keeps `ProgressBar`/`StatusDot` casing. Component docs
355
357
  * are the source of truth for the target token → owning component relationship.
356
358
  *
@@ -428,8 +430,8 @@ async function resolveAugmentationTargetCandidates(componentName) {
428
430
 
429
431
  // Try the exact rendered token first for documented subtargets such as
430
432
  // avatar-status-dot → AvatarStatusDotVariantMap, then the owning public
431
- // component name for unhyphenated public casings such as progressbar →
432
- // ProgressBarVariantMap/statusdot → StatusDotVariantMap.
433
+ // component name for the deprecated unhyphenated tokens such as
434
+ // progressbar → ProgressBarVariantMap/statusdot → StatusDotVariantMap.
433
435
  addCandidate(moduleName, toPascalCase(componentName));
434
436
  addCandidate(moduleName, moduleName);
435
437
  if (Array.isArray(doc?.components)) {
@@ -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
- const colorSchemeDecl = componentScope.includes('light-dark(')
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,26 @@ 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. Formatted here from the
1199
+ // public `dataTokenDefaults` export, byte for byte as the `<Theme>`
1200
+ // runtime emits it — build-theme.data-tokens.test.mjs is the drift guard.
1201
+ // Placed after the reset block and before the theme block: a layer's order
1202
+ // is fixed by where it is first declared, so emitting it anywhere else in
1203
+ // the file would invert reset < astryx-base < astryx-theme for a consumer
1204
+ // who imports this stylesheet on its own.
1205
+ const baseCss = _dataTokenDefaults
1206
+ ? `:root {\n${Object.entries(_dataTokenDefaults)
1207
+ .map(([name, value]) => ` ${name}: ${value};`)
1208
+ .join('\n')}\n}`
1209
+ : '';
1210
+ if (baseCss) {
1211
+ cssParts.splice(
1212
+ prose.length > 0 ? 1 : 0,
1213
+ 0,
1214
+ `@layer astryx-base {\n${baseCss}\n}`,
1215
+ );
1216
+ }
1188
1217
  css = cssParts.join('\n\n') + '\n';
1189
1218
  }
1190
1219
 
@@ -34,6 +34,7 @@ describe('themeTargets (api/theme/targets)', () => {
34
34
  expect(data.targets.map(t => t.key)).toEqual([
35
35
  'switch',
36
36
  'switch-field',
37
+ 'switch-label',
37
38
  'switch-thumb',
38
39
  ]);
39
40
  }, 60_000);
@@ -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
- 'the component docs so the list cannot drift from the components, and `theme build` ' +
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\u2019s set; anything else is a substring search over the keys.',
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 projects @astryxdesign/core is resolved from.',
38
+ description: 'Directory the project\'s @astryxdesign/core is resolved from.',
39
39
  },
40
40
  ],
41
41
  returns: [
@@ -26,6 +26,7 @@ describe('registry', () => {
26
26
  '0.2.1',
27
27
  '0.3.0',
28
28
  '0.4.0',
29
+ '0.5.0',
29
30
  ]);
30
31
  });
31
32
  });
@@ -27,6 +27,7 @@ const registry = new Map([
27
27
  ['0.2.1', () => import('./transforms/v0.2.1/index.mjs')],
28
28
  ['0.3.0', () => import('./transforms/v0.3.0/index.mjs')],
29
29
  ['0.4.0', () => import('./transforms/v0.4.0/index.mjs')],
30
+ ['0.5.0', () => import('./transforms/v0.5.0/index.mjs')],
30
31
  ]);
31
32
 
32
33
  // Re-export from the shared utility so registry callers and other consumers
@@ -0,0 +1,127 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Unit tests for the staged (next-release) codemods.
5
+ *
6
+ * Mirrors v0.4.0/__tests__/next-codemods.test.mjs, which covers the codemods
7
+ * after promotion. Keeping a copy here means a staged transform is tested from
8
+ * the day it is written rather than the day it is released.
9
+ */
10
+
11
+ import {describe, expect, it} from 'vitest';
12
+ import jscodeshift from 'jscodeshift';
13
+
14
+ const j = jscodeshift.withParser('tsx');
15
+ const api = {jscodeshift: j, stats: () => {}, report: () => {}};
16
+
17
+ async function apply(name, source) {
18
+ const {default: transform} = await import(`../${name}.mjs`);
19
+ return transform({source, path: 'test.tsx'}, api) ?? source;
20
+ }
21
+
22
+ const TRANSFORM = 'banner-collapsible-content';
23
+
24
+ const IMPORT = "import {Banner} from '@astryxdesign/core/Banner';\n";
25
+
26
+ describe('banner-collapsible-content', () => {
27
+ it('rewrites a bare defaultIsExpanded to a starts-open config', async () => {
28
+ const output = await apply(
29
+ TRANSFORM,
30
+ `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`,
31
+ );
32
+ expect(output).toContain('defaultIsOpen: true');
33
+ expect(output).not.toContain('defaultIsExpanded');
34
+ });
35
+
36
+ it('rewrites defaultIsExpanded={true} the same way', async () => {
37
+ const output = await apply(
38
+ TRANSFORM,
39
+ `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={true}><p>d</p></Banner>;`,
40
+ );
41
+ expect(output).toContain('defaultIsOpen: true');
42
+ expect(output).not.toContain('defaultIsExpanded');
43
+ });
44
+
45
+ it('drops defaultIsExpanded={false}, which is the default', async () => {
46
+ const output = await apply(
47
+ TRANSFORM,
48
+ `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={false}><p>d</p></Banner>;`,
49
+ );
50
+ expect(output).not.toContain('defaultIsExpanded');
51
+ // No config needed: starting collapsed is what a Banner does by default.
52
+ expect(output).not.toContain('collapsible');
53
+ // Untouched attributes keep their original text (recast only reprints
54
+ // what changed), so the element is exactly the base minus the prop.
55
+ expect(output).toContain('<Banner status="info" title="T">');
56
+ });
57
+
58
+ it('keeps a dynamic default dynamic', async () => {
59
+ const output = await apply(
60
+ TRANSFORM,
61
+ `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={isOpen}><p>d</p></Banner>;`,
62
+ );
63
+ expect(output).toContain('defaultIsOpen: isOpen');
64
+ expect(output).not.toContain('defaultIsExpanded');
65
+ });
66
+
67
+ it('leaves a banner that never set the prop alone', async () => {
68
+ // The default is unchanged, so this banner still behaves as it did. The
69
+ // migration must not touch it — that is the whole point of the shape.
70
+ const source = `${IMPORT}const el = <Banner status="error" title="T"><ul><li>a</li></ul></Banner>;`;
71
+ const output = await apply(TRANSFORM, source);
72
+ expect(output).toBe(source);
73
+ });
74
+
75
+ it('leaves a childless banner alone', async () => {
76
+ const source = `${IMPORT}const el = <Banner status="info" title="T" />;`;
77
+ const output = await apply(TRANSFORM, source);
78
+ expect(output).toBe(source);
79
+ });
80
+
81
+ it('leaves a banner that already uses collapsible alone', async () => {
82
+ const source = `${IMPORT}const el = <Banner status="info" title="T" collapsible={false} defaultIsExpanded><p>d</p></Banner>;`;
83
+ const output = await apply(TRANSFORM, source);
84
+ expect(output).toBe(source);
85
+ });
86
+
87
+ it('does not guess around a spread', async () => {
88
+ const source = `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded {...rest}><p>d</p></Banner>;`;
89
+ const output = await apply(TRANSFORM, source);
90
+ expect(output).toBe(source);
91
+ });
92
+
93
+ it("leaves another component's defaultIsExpanded alone", async () => {
94
+ // ChatToolCalls has a prop of the same name that this migration must not
95
+ // touch.
96
+ const source = `import {ChatToolCalls} from '@astryxdesign/core/Chat';
97
+ const el = <ChatToolCalls calls={calls} defaultIsExpanded />;`;
98
+ const output = await apply(TRANSFORM, source);
99
+ expect(output).toBe(source);
100
+ });
101
+
102
+ it('leaves a Banner that is not the core Banner alone', async () => {
103
+ const source = `import {Banner} from './ui/Banner';
104
+ const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`;
105
+ const output = await apply(TRANSFORM, source);
106
+ expect(output).toBe(source);
107
+ });
108
+
109
+ it('leaves the prop inside a props object alone', async () => {
110
+ // Out of scope by design: which component the object feeds is a guess,
111
+ // and the removed prop makes those sites a type error anyway.
112
+ const source = `${IMPORT}const args = {status: 'info', title: 'T', defaultIsExpanded: true};
113
+ const el = <Banner {...args} />;`;
114
+ const output = await apply(TRANSFORM, source);
115
+ expect(output).toBe(source);
116
+ });
117
+
118
+ it('migrates a Banner imported from the package root', async () => {
119
+ const output = await apply(
120
+ TRANSFORM,
121
+ `import {Banner, Button} from '@astryxdesign/core';
122
+ const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`,
123
+ );
124
+ expect(output).toContain('defaultIsOpen: true');
125
+ expect(output).not.toContain('defaultIsExpanded');
126
+ });
127
+ });