@astryxdesign/cli 0.4.7-canary.5d351e4 → 0.4.7-canary.63f398d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -419,6 +419,7 @@ Every response has a `type` discriminant. The full set is below (generated from
419
419
  | `theme.list` | Every bundled theme as a ThemeListEntry[]: each with slug, displayName, description, and a maintained flag. |
420
420
  | `theme.add` | A scaffold receipt: resolved slug, displayName, maintained flag, outputDir (relative to cwd), the theme entry file, its exportName, and the files written. |
421
421
  | `theme.template` | A write receipt for the annotated theme template: the path (relative to cwd), whether it was written, and the reason it was not — `exists` when a file was already there, which is a success. |
422
+ | `theme.targets` | The whole themeable surface: the echoed filter, the component count, and one entry per theming target — {key, className, component, props, states}, where props and states are its legal override keys. |
422
423
  | `upgrade.list` | Every available codemod, oldest→newest, as {name, title, version, optional}; returned for --list without running anything. |
423
424
  | `upgrade.status` | A short-circuit outcome with no codemods run (up_to_date, no_codemods, or config_fixable), each carrying the agent-docs summary. |
424
425
  | `upgrade.run` | The run receipt: from/to versions, codemod count, integrations processed, the agent-docs summary, and (apply mode) filesChanged, transformsApplied, and per-codemod errors. |
package/api/index.d.mts CHANGED
@@ -31,6 +31,6 @@ export * from "./doctor/doctor.type.mjs";
31
31
  export * from "./layout/layout.type.mjs";
32
32
  export * from "./integration/validate-integration.type.mjs";
33
33
  export type Logger = import("./logger.mjs").Logger;
34
- export { themeBuild, themeAdd, themeList, listThemes } from "./theme/theme.mjs";
34
+ export { themeBuild, themeAdd, themeList, themeTargets, listThemes } from "./theme/theme.mjs";
35
35
  export { layoutExpand, layoutCheck, layoutGrammar } from "./layout/layout.mjs";
36
36
  export { validateIntegration, summarizeIssues } from "./integration/validate-integration.mjs";
package/api/index.mjs CHANGED
@@ -25,7 +25,7 @@ export {docs} from './docs/docs.mjs';
25
25
  export {blog} from './blog/blog.mjs';
26
26
  export {discover} from './discover/discover.mjs';
27
27
  export {template} from './template/template.mjs';
28
- export {themeBuild, themeAdd, themeList, listThemes} from './theme/theme.mjs';
28
+ export {themeBuild, themeAdd, themeList, themeTargets, listThemes} from './theme/theme.mjs';
29
29
  export {hook} from './hook/hook.mjs';
30
30
  export {search} from './search/search.mjs';
31
31
  export {build} from './build/build.mjs';
@@ -43,6 +43,10 @@ import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
43
43
  import {AstryxError} from '../../error.mjs';
44
44
  import {logger} from '../../logger.mjs';
45
45
  import {loadComponentDoc} from '../../../foundation/discovery/component-loader.mjs';
46
+ import {
47
+ collectThemingTargets,
48
+ targetsByKey,
49
+ } from '../../../foundation/discovery/theming-targets.mjs';
46
50
  import {
47
51
  collectUnloadedFonts,
48
52
  formatFontLoadingHelp,
@@ -882,6 +886,9 @@ ${iconType}export declare const ${toIdentifier(themeDef.name)}Theme: DefinedThem
882
886
  * Returns null when docs are unavailable so validation can skip unknown-key
883
887
  * warnings rather than guessing from a second registry.
884
888
  *
889
+ * Shares its enumeration with `theme targets`, so what a theme author can list
890
+ * is exactly what this validator accepts.
891
+ *
885
892
  * @returns {Promise<Record<string, string[]> | null>}
886
893
  */
887
894
  async function loadKnownComponents() {
@@ -889,44 +896,7 @@ async function loadKnownComponents() {
889
896
  const coreSrc = coreRoot ? path.join(coreRoot, 'src') : null;
890
897
  if (!coreSrc || !fs.existsSync(coreSrc)) return null;
891
898
 
892
- /** @type {Record<string, string[]>} */
893
- const targets = {};
894
-
895
- /** @param {string} dir */
896
- async function scan(dir) {
897
- const entries = fs.readdirSync(dir, {withFileTypes: true});
898
- for (const entry of entries) {
899
- const full = path.join(dir, entry.name);
900
- if (entry.isDirectory()) {
901
- if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
902
- await scan(full);
903
- continue;
904
- }
905
- if (!entry.name.endsWith('.doc.mjs')) continue;
906
-
907
- /** @type {any} */
908
- let doc;
909
- try {
910
- doc = await loadComponentDoc(full);
911
- } catch {
912
- continue;
913
- }
914
-
915
- for (const target of doc?.theming?.targets || []) {
916
- const className = target?.className;
917
- if (typeof className !== 'string') continue;
918
- const key = className.replace(/^astryx-/, '');
919
- if (!key) continue;
920
- const props = [target.visualProps, target.states]
921
- .filter(list => Array.isArray(list))
922
- .flat()
923
- .filter((/** @type {unknown} */ p) => typeof p === 'string');
924
- targets[key] = [...new Set([...(targets[key] || []), ...props])];
925
- }
926
- }
927
- }
928
-
929
- await scan(coreSrc);
899
+ const targets = targetsByKey(await collectThemingTargets(coreSrc));
930
900
  return Object.keys(targets).length > 0 ? targets : null;
931
901
  }
932
902
 
@@ -0,0 +1,18 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * List every component theming target — the `defineTheme` `components` keys,
6
+ * with the props and states each one accepts.
7
+ *
8
+ * A filter naming a component exactly wins over a substring search, so
9
+ * `theme targets Button` is Button's own set (what `astryx component Button`
10
+ * prints) rather than every key that happens to contain "button".
11
+ *
12
+ * @param {string} [filter] - component name, or a substring of a target key
13
+ * @param {{cwd?: string}} [ctx]
14
+ * @returns {Promise<import('../theme.type.mjs').ThemeTargetsResponse>}
15
+ */
16
+ export function themeTargets(filter?: string, { cwd }?: {
17
+ cwd?: string;
18
+ }): Promise<import("../theme.type.mjs").ThemeTargetsResponse>;
@@ -0,0 +1,87 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file `astryx theme targets` leaf — the whole themeable surface, enumerated.
5
+ *
6
+ * @input a cwd (to resolve the project's `@astryxdesign/core`) and an optional
7
+ * component/key filter
8
+ * @output the `theme.targets` envelope: one row per theming target
9
+ * @position api/theme/targets — projection over
10
+ * foundation/discovery/theming-targets.mjs, the same component docs
11
+ * `astryx component <Name>` prints its Theming table from.
12
+ */
13
+
14
+ import * as path from 'node:path';
15
+ import {findCoreDir} from '../../../foundation/fs/paths.mjs';
16
+ import {collectThemingTargets} from '../../../foundation/discovery/theming-targets.mjs';
17
+ import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
18
+ import {AstryxError} from '../../error.mjs';
19
+
20
+ /**
21
+ * Whether a target matches the caller's filter loosely: any target whose key,
22
+ * class, or component contains it — so `theme targets thumb` finds the switch
23
+ * thumb without knowing which component owns it.
24
+ * @param {import('../../../foundation/discovery/theming-targets.mjs').ThemingTarget} target
25
+ * @param {string} filter - already lowercased
26
+ * @returns {boolean}
27
+ */
28
+ function matchesLoosely(target, filter) {
29
+ return (
30
+ target.key.toLowerCase().includes(filter) ||
31
+ target.className.toLowerCase().includes(filter) ||
32
+ target.component.toLowerCase().includes(filter)
33
+ );
34
+ }
35
+
36
+ /**
37
+ * List every component theming target — the `defineTheme` `components` keys,
38
+ * with the props and states each one accepts.
39
+ *
40
+ * A filter naming a component exactly wins over a substring search, so
41
+ * `theme targets Button` is Button's own set (what `astryx component Button`
42
+ * prints) rather than every key that happens to contain "button".
43
+ *
44
+ * @param {string} [filter] - component name, or a substring of a target key
45
+ * @param {{cwd?: string}} [ctx]
46
+ * @returns {Promise<import('../theme.type.mjs').ThemeTargetsResponse>}
47
+ */
48
+ export async function themeTargets(filter, {cwd = process.cwd()} = {}) {
49
+ const coreDir = findCoreDir(cwd);
50
+ if (!coreDir) {
51
+ throw new AstryxError(
52
+ 'Could not find @astryxdesign/core package',
53
+ undefined,
54
+ ERROR_CODES.ERR_CORE_NOT_FOUND,
55
+ );
56
+ }
57
+
58
+ const all = await collectThemingTargets(path.join(coreDir, 'src'));
59
+ const needle = filter ? String(filter).toLowerCase() : null;
60
+ let targets = all;
61
+ if (needle) {
62
+ const named = all.filter(t => t.component.toLowerCase() === needle);
63
+ targets = named.length > 0 ? named : all.filter(t => matchesLoosely(t, needle));
64
+ }
65
+
66
+ if (needle && targets.length === 0) {
67
+ const near = [...new Set(all.map(t => t.component))]
68
+ .filter(name => name.toLowerCase().startsWith(needle.slice(0, 3)))
69
+ .sort()
70
+ .slice(0, 5)
71
+ .map(name => ({name, reason: 'has theming targets'}));
72
+ throw new AstryxError(
73
+ `No theming target matches "${filter}". Run \`theme targets\` with no filter for the whole list.`,
74
+ near.length > 0 ? near : undefined,
75
+ ERROR_CODES.ERR_UNKNOWN_COMPONENT,
76
+ );
77
+ }
78
+
79
+ return {
80
+ type: 'theme.targets',
81
+ data: {
82
+ filter: filter ?? null,
83
+ componentCount: new Set(targets.map(t => t.component)).size,
84
+ targets,
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,65 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Direct-API tests for the `theme targets` leaf. Runs against the real
5
+ * core docs, so it doubles as a guard that the themeable surface stays
6
+ * readable and shaped as `theme.targets` promises.
7
+ */
8
+
9
+ import {describe, it, expect} from 'vitest';
10
+ import {themeTargets} from './targets.mjs';
11
+
12
+ describe('themeTargets (api/theme/targets)', () => {
13
+ it('returns a theme.targets envelope covering the whole surface', async () => {
14
+ const result = await themeTargets();
15
+ expect(result.type).toBe('theme.targets');
16
+ expect(result.data.filter).toBeNull();
17
+ expect(result.data.targets.length).toBeGreaterThan(100);
18
+ expect(result.data.componentCount).toBeGreaterThan(50);
19
+ for (const t of result.data.targets) {
20
+ expect(Object.keys(t).sort()).toEqual([
21
+ 'className',
22
+ 'component',
23
+ 'key',
24
+ 'props',
25
+ 'states',
26
+ ]);
27
+ }
28
+ }, 60_000);
29
+
30
+ it('scopes to one component by name', async () => {
31
+ const {data} = await themeTargets('Switch');
32
+ expect(data.filter).toBe('Switch');
33
+ expect(data.componentCount).toBe(1);
34
+ expect(data.targets.map(t => t.key)).toEqual([
35
+ 'switch',
36
+ 'switch-field',
37
+ 'switch-thumb',
38
+ ]);
39
+ }, 60_000);
40
+
41
+ // Half the system's keys contain "button" (chat-send-button, toggle-button,
42
+ // …). A component name has to mean the component, or `theme targets Button`
43
+ // answers a different question than `component Button` and the two views
44
+ // look like they disagree.
45
+ it('prefers an exact component name over a substring match', async () => {
46
+ const {data} = await themeTargets('Button');
47
+ expect(data.targets.map(t => t.key)).toEqual(['button']);
48
+ }, 60_000);
49
+
50
+ // This command answers "which theme slot paints the switch thumb?" — a
51
+ // question you can only ask by the part, not the component, until you
52
+ // already know which component owns it.
53
+ it('searches keys by substring, across components', async () => {
54
+ const {data} = await themeTargets('thumb');
55
+ expect(data.componentCount).toBeGreaterThan(1);
56
+ expect(data.targets.map(t => t.key)).toContain('switch-thumb');
57
+ for (const t of data.targets) expect(t.key).toContain('thumb');
58
+ }, 60_000);
59
+
60
+ it('rejects a filter that matches nothing, with components to try', async () => {
61
+ await expect(themeTargets('nosuchthing')).rejects.toMatchObject({
62
+ code: 'ERR_UNKNOWN_COMPONENT',
63
+ });
64
+ }, 60_000);
65
+ });
@@ -3,6 +3,7 @@
3
3
 
4
4
  export { themeAdd } from "./add/add.mjs";
5
5
  export { themeTemplate } from "./template/template.mjs";
6
+ export { themeTargets } from "./targets/targets.mjs";
6
7
  export { themeList } from "./list/list.mjs";
7
8
  export { listThemes } from "./_adapter.mjs";
8
9
  export { themeBuild, importSpecifier } from "./build/build.mjs";
@@ -1,7 +1,8 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  /**
4
- * @file `theme` command barrel — re-exports the build/add/list leaves so the CLI
4
+ * @file `theme` command barrel — re-exports the build/add/list/template/targets
5
+ * leaves so the CLI
5
6
  * (cli/commands/build-theme.mjs) and scripted callers import from one place.
6
7
  * Each leaf is also importable directly (e.g. api/theme/add/add.mjs). `theme`
7
8
  * has real subcommands, so there is no flag-dispatch here — the CLI calls the
@@ -11,5 +12,6 @@
11
12
  export {themeBuild, importSpecifier} from './build/build.mjs';
12
13
  export {themeAdd} from './add/add.mjs';
13
14
  export {themeTemplate} from './template/template.mjs';
15
+ export {themeTargets} from './targets/targets.mjs';
14
16
  export {themeList} from './list/list.mjs';
15
17
  export {listThemes} from './_adapter.mjs';
@@ -101,3 +101,26 @@ export type ThemeTemplateResponse = {
101
101
  reason: "exists" | null;
102
102
  };
103
103
  };
104
+ /**
105
+ * One themeable target: the `defineTheme` `components` key, the class it
106
+ * renders as, the component whose doc declares it, and the props and states
107
+ * that are legal override keys under it.
108
+ */
109
+ export type ThemeTargetEntry = {
110
+ key: string;
111
+ className: string;
112
+ component: string;
113
+ props: string[];
114
+ states: string[];
115
+ };
116
+ /**
117
+ * xds --json theme targets [filter]
118
+ */
119
+ export type ThemeTargetsResponse = {
120
+ type: "theme.targets";
121
+ data: {
122
+ filter: string | null;
123
+ componentCount: number;
124
+ targets: ThemeTargetEntry[];
125
+ };
126
+ };
@@ -14,9 +14,10 @@
14
14
  * xds --json theme list -> theme.list
15
15
  * xds --json theme add <slug> -> theme.add
16
16
  * xds --json theme template -> theme.template
17
+ * xds --json theme targets [filter] -> theme.targets
17
18
  * (file not found / parse error) -> CLIError
18
19
  *
19
- * @position api — colocated typedefs for api/theme/{theme,build,add,list,template,_adapter}
20
+ * @position api — colocated typedefs for api/theme/{theme,build,add,list,template,targets,_adapter}
20
21
  */
21
22
 
22
23
  /**
@@ -78,6 +79,25 @@
78
79
  * @property {{path: string, written: boolean, reason: 'exists' | null}} data
79
80
  */
80
81
 
82
+ /**
83
+ * One themeable target: the `defineTheme` `components` key, the class it
84
+ * renders as, the component whose doc declares it, and the props and states
85
+ * that are legal override keys under it.
86
+ * @typedef {object} ThemeTargetEntry
87
+ * @property {string} key
88
+ * @property {string} className
89
+ * @property {string} component
90
+ * @property {string[]} props
91
+ * @property {string[]} states
92
+ */
93
+
94
+ /**
95
+ * xds --json theme targets [filter]
96
+ * @typedef {object} ThemeTargetsResponse
97
+ * @property {'theme.targets'} type
98
+ * @property {{filter: string | null, componentCount: number, targets: ThemeTargetEntry[]}} data
99
+ */
100
+
81
101
  // Make this a module so the @typedefs above are importable as types via
82
102
  // `import('./theme.type.mjs').ThemeBuildResponse` (and re-exportable from a .d.ts).
83
103
  export {};
@@ -0,0 +1,11 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * @file FunctionDoc for `themeTargets()` / `astryx theme targets`. Colocated
6
+ * with the API function it documents; the response-shape source of truth stays
7
+ * in `theme.type.mjs`.
8
+ * @position packages/cli/api/theme — function documentation
9
+ */
10
+ /** @type {import('@astryxdesign/cli/authoring').FunctionDoc} */
11
+ export const doc: import("@astryxdesign/cli/authoring").FunctionDoc;
@@ -0,0 +1,58 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file FunctionDoc for `themeTargets()` / `astryx theme targets`. Colocated
5
+ * with the API function it documents; the response-shape source of truth stays
6
+ * in `theme.type.mjs`.
7
+ * @position packages/cli/api/theme — function documentation
8
+ */
9
+
10
+ /** @type {import('@astryxdesign/cli/authoring').FunctionDoc} */
11
+ export const doc = {
12
+ type: 'function',
13
+ kind: 'api',
14
+ name: 'themeTargets',
15
+ displayName: 'themeTargets()',
16
+ summary: 'List every component theming target a theme can override.',
17
+ description:
18
+ 'Enumerates the whole themeable surface: each `defineTheme` components key, the stable ' +
19
+ 'class it paints, the component that declares it, and the props and states that are legal ' +
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` ' +
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.',
24
+ importPath: '@astryxdesign/cli/api',
25
+ signature:
26
+ 'themeTargets(filter?: string, ctx?: {cwd?: string}): Promise<ThemeTargetsResponse>',
27
+ keywords: ['theme', 'targets', 'defineTheme', 'components', 'override', 'class', 'states', 'audit'],
28
+ params: [
29
+ {
30
+ name: 'filter',
31
+ type: 'string',
32
+ description:
33
+ 'A component name (exact, case-insensitive) or a substring of a target key. Omit for the whole surface.',
34
+ },
35
+ {
36
+ name: 'ctx.cwd',
37
+ type: 'string',
38
+ description: 'Directory the project’s @astryxdesign/core is resolved from.',
39
+ },
40
+ ],
41
+ returns: [
42
+ {
43
+ type: 'theme.targets',
44
+ description:
45
+ 'The echoed filter, how many components are represented, and the targets: each {key, className, component, props, states}.',
46
+ },
47
+ ],
48
+ throws: [
49
+ {code: 'ERR_CORE_NOT_FOUND', when: '@astryxdesign/core cannot be resolved from cwd'},
50
+ {code: 'ERR_UNKNOWN_COMPONENT', when: 'a filter matches no target'},
51
+ ],
52
+ examples: [
53
+ {label: 'The whole themeable surface', code: 'await themeTargets();'},
54
+ {label: "One component's targets", code: "await themeTargets('Switch');"},
55
+ ],
56
+ command: 'theme targets',
57
+ related: ['themeBuild', 'themeTemplate', 'component'],
58
+ };
@@ -0,0 +1,50 @@
1
+ # /packages/cli/assets/docs
2
+
3
+ Reference topics for people **building with** Astryx. Not docs about building Astryx itself.
4
+
5
+ One `{topic}.doc.mjs` per topic, plus optional `{topic}.doc.dense.mjs` / `{topic}.doc.zh.mjs` prose overlays. `foundation/discovery/docs-discovery.mjs` picks up any `{topic}.doc.mjs` here with no registration; `api/docs/_adapter.mjs` merges the overlays.
6
+
7
+ What you add reaches `astryx docs <topic>`, `astryx search`, the `--json` API, the agent-docs block and the doc site — and ships on npm.
8
+
9
+ ## Who you are writing for
10
+
11
+ Someone building a product with Astryx. Their questions:
12
+
13
+ - what a component is for, and when to reach for something else
14
+ - the props, their defaults, and what each does to what they see
15
+ - how to compose it, and the pattern to copy
16
+ - what it costs — bundle size, the a11y obligations they inherit
17
+ - how to theme it, and which targets are stable
18
+
19
+ **The test for anything you add: does a caller act on it?** They are not reviewing a PR, promoting a component out of lab, or attaching evidence to a checklist.
20
+
21
+ ## Tells that you are writing for us instead
22
+
23
+ - second person aimed at the wrong reader — "reviewers should…", "before promoting a component…", "attach evidence for…"
24
+ - **rubric, readiness, gate, audit, checklist, sign-off, promotion, evidence** as things the reader must produce
25
+ - a table of things to verify rather than things to use
26
+ - anything about lab → core, which is our lifecycle, not theirs
27
+ - Storybook, Playwright, CI or the Simulator named as tools the reader runs
28
+
29
+ One subtlety: a statement about the **system's behavior** is caller-facing even when it sounds like process. "A component's theme targets are stable once published" tells a caller what they can rely on; "reviewers must check that theme targets are stable" is ours. Same fact, different reader — **rewrite it rather than move it**.
30
+
31
+ ## Where the rest goes
32
+
33
+ The material is usually good; the finding is placement, not quality. It goes in the [wiki](https://github.com/facebook/astryx/wiki) — **as a section on the page that already covers it, not a new page.** The wiki is at nearly 60 pages, several of them overlapping, because every stray section got its own.
34
+
35
+ | what you wrote | where it goes |
36
+ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
37
+ | how a component is graded — checks, scoring | [Component-Audit-Rubric](https://github.com/facebook/astryx/wiki/Component-Audit-Rubric) |
38
+ | lab → core promotion, what a component must satisfy | [Component-Lifecycle](https://github.com/facebook/astryx/wiki/Component-Lifecycle) |
39
+ | how to build a new component | [Component-Authoring-Guide](https://github.com/facebook/astryx/wiki/Component-Authoring-Guide), [Creating-New-Components](https://github.com/facebook/astryx/wiki/Creating-New-Components) |
40
+ | what a component must be hardened against | [Component-Hardening-Protocol](https://github.com/facebook/astryx/wiki/Component-Hardening-Protocol), [Hardening-Audit-Guide](https://github.com/facebook/astryx/wiki/Hardening-Audit-Guide) |
41
+ | a11y requirements as checks we run | [Accessibility-Checklist](https://github.com/facebook/astryx/wiki/Accessibility-Checklist) |
42
+ | how the system is put together | [System-Architecture](https://github.com/facebook/astryx/wiki/System-Architecture), [Theming-Infrastructure](https://github.com/facebook/astryx/wiki/Theming-Infrastructure) |
43
+ | API naming and shape decisions | [API-Conventions](https://github.com/facebook/astryx/wiki/API-Conventions), [API-Arbitration](https://github.com/facebook/astryx/wiki/API-Arbitration) |
44
+ | contributor workflow, PR process | [Contributing](https://github.com/facebook/astryx/wiki/Contributing), [Contributing-with-AI-Assistants](https://github.com/facebook/astryx/wiki/Contributing-with-AI-Assistants) |
45
+ | release mechanics | [Release-Process](https://github.com/facebook/astryx/wiki/Release-Process) |
46
+ | what a nightly agent role does | the Night-Watch pages, from [Night-Watch-Overview](https://github.com/facebook/astryx/wiki/Night-Watch-Overview) |
47
+
48
+ **Fits no row?** It is still not caller-facing. Default it to [Contributing](https://github.com/facebook/astryx/wiki/Contributing), or `CONTRIBUTING.md` when it is a step someone follows with the repo cloned. Never default it back to this directory.
49
+
50
+ Worked example: a responsive-and-interaction readiness rubric is grading criteria → **Component-Audit-Rubric**, or **Component-Lifecycle** if it is a promotion gate.
@@ -10,7 +10,7 @@ export const docsDense = {
10
10
  { section: 'Theme Props', title: 'Props', content: [null] },
11
11
  { section: 'Creating a Custom Theme', title: 'Custom Theme', content: [{ type: 'prose', text: '`theme list` + `theme add <slug>` to start from a shipped theme, or defineTheme from scratch. only override tokens that differ.' }, null, { type: 'prose', text: '`astryx theme template` writes theme.template.ts: every defineTheme field + token families + override syntax, annotated, with the CLI command that prints each reference.' }] },
12
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 via HCT; accent = hex or [light, dark] tuple (per-scheme palettes). tokens overrides win token-by-token; --color-on-accent stays baked from color.accent, so prefer a tuple accent over overriding --color-accent.' }, 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-focus-offset etc) set directly. private vars (--_*) cannot be set — use CSS properties. run `astryx component <Name>` for details.' }, null, null, 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-focus-offset etc) set directly. private vars (--_*) cannot be set — use CSS properties. run `astryx theme targets [Name]` to enumerate every themeable key (--json for lint), `astryx component <Name>` for one component.' }, null, null, null, null] },
14
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
15
  { section: 'Building Themes for Production', title: 'Build for Production', content: [{ type: 'prose', text: 'astryx theme build compiles defineTheme to static CSS. outputs .css + .js (__built:true) + .d.ts.' }, null, null, null, null] },
16
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] },
@@ -300,7 +300,7 @@ const brandTheme = defineTheme({
300
300
  },
301
301
  {
302
302
  type: 'prose',
303
- text: 'Run `astryx component <Name>` to see a component\'s theming targets, public CSS variables, and which standard CSS properties are supported.',
303
+ text: 'Run `astryx theme targets` for every themeable key in the system (`astryx theme targets <Name>` to scope it, `--json` to lint a theme against it), and `astryx component <Name>` for one component\'s theming targets, public CSS variables, and which standard CSS properties are supported.',
304
304
  },
305
305
  {
306
306
  type: 'list',
@@ -24,7 +24,8 @@
24
24
  * icon = T30 / T80
25
25
  * text = T30 / T80
26
26
  *
27
- * All 9 saturated badge values pass WCAG AA (5.6–9.6 contrast range).
27
+ * All 9 saturated badge values pass WCAG AA against their label (>= 4.5:1);
28
+ * `scripts/check-badge-contrast.test.mjs` holds every theme to that.
28
29
  *
29
30
  * Only overrides tokens that differ from the defaults.
30
31
  */
@@ -432,10 +433,14 @@ export const neutralTheme = defineTheme({
432
433
  color: '#171717',
433
434
  },
434
435
  'variant:error': {
435
- // Light: T55 #e33f4a (palette saturated stop)
436
+ // Light: T58 #c9303a. The T55 stop #e33f4a pairs with white at only
437
+ // 4.14:1 — the label is 12px/500, so AA wants 4.5, not the 3:1
438
+ // large-text allowance. One tonal step down holds the hue
439
+ // (OKLCH H 21.9 -> 22.8, C 0.200 -> 0.189) and reaches 5.29:1.
436
440
  // Dark : T60 stop from dark-mode tonal palette of Tailwind red-600
437
- // source #dc2626 (kept on H=27 alarm-red rather than coral)
438
- backgroundColor: 'light-dark(#e33f4a, #ff705d)',
441
+ // source #dc2626 (kept on H=27 alarm-red rather than coral).
442
+ // Dark text on it is 6.60:1 and unchanged.
443
+ backgroundColor: 'light-dark(#c9303a, #ff705d)',
439
444
  color: 'light-dark(#ffffff, #171717)',
440
445
  },
441
446
 
@@ -500,7 +505,7 @@ export const neutralTheme = defineTheme({
500
505
  //
501
506
  // success → badge success bg (green T45 / dark-ramp T60)
502
507
  // warning → badge warning bg (yellow T85, same hex both modes)
503
- // error → badge error bg (red T55 / dark-ramp T60)
508
+ // error → badge error bg (red T58 / dark-ramp T60)
504
509
  // accent → badge info bg (blue T50 / dark-ramp T60) — the
505
510
  // StatusDot "accent" is the info/attention color, so it
506
511
  // pairs with the info badge rather than --color-accent
@@ -515,7 +520,7 @@ export const neutralTheme = defineTheme({
515
520
  statusdot: {
516
521
  'variant:success': {backgroundColor: 'light-dark(#198100, #64af4c)'},
517
522
  'variant:warning': {backgroundColor: '#ffce2f'},
518
- 'variant:error': {backgroundColor: 'light-dark(#e33f4a, #ff705d)'},
523
+ 'variant:error': {backgroundColor: 'light-dark(#c9303a, #ff705d)'},
519
524
  'variant:accent': {backgroundColor: 'light-dark(#0074e2, #6d9cfe)'},
520
525
  },
521
526
 
@@ -611,8 +616,8 @@ export const neutralTheme = defineTheme({
611
616
  '--color-warning': '#ffce2f',
612
617
  },
613
618
  'variant:error': {
614
- // Red T55 saturated stop (= variant:error badge bg)
615
- '--color-error': '#e33f4a',
619
+ // Red T58 saturated stop (= variant:error badge bg)
620
+ '--color-error': '#c9303a',
616
621
  },
617
622
  },
618
623
 
@@ -38,6 +38,7 @@ import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
38
38
  import {themeAdd} from '../../../api/theme/add/add.mjs';
39
39
  import {themeTemplate} from '../../../api/theme/template/template.mjs';
40
40
  import {themeList} from '../../../api/theme/list/list.mjs';
41
+ import {themeTargets} from '../../../api/theme/targets/targets.mjs';
41
42
  import {themeBuild, importSpecifier} from '../../../api/theme/build/build.mjs';
42
43
  import {defineCommand} from '../lib/define-command.mjs';
43
44
  import {doc as themeGroup} from './theme.doc.mjs';
@@ -45,10 +46,12 @@ import {doc as themeBuildCommand} from './theme-build.doc.mjs';
45
46
  import {doc as themeListCommand} from './theme-list.doc.mjs';
46
47
  import {doc as themeAddCommand} from './theme-add.doc.mjs';
47
48
  import {doc as themeTemplateCommand} from './theme-template.doc.mjs';
49
+ import {doc as themeTargetsCommand} from './theme-targets.doc.mjs';
48
50
  import {doc as themeBuildFn} from '../../../api/theme/themeBuild.doc.mjs';
49
51
  import {doc as themeListFn} from '../../../api/theme/themeList.doc.mjs';
50
52
  import {doc as themeAddFn} from '../../../api/theme/themeAdd.doc.mjs';
51
53
  import {doc as themeTemplateFn} from '../../../api/theme/themeTemplate.doc.mjs';
54
+ import {doc as themeTargetsFn} from '../../../api/theme/themeTargets.doc.mjs';
52
55
 
53
56
  /**
54
57
  * Path to this CLI's real entry (clients/cli/bin/astryx.mjs), resolved from
@@ -194,6 +197,36 @@ function printThemeList(themes) {
194
197
  );
195
198
  }
196
199
 
200
+ /**
201
+ * Render the targets as one greppable line each, under an aligned header. A
202
+ * `records()` block would be five lines per target — over a thousand for the
203
+ * full surface, which is the view this command exists to make readable.
204
+ * @param {import('../../../api/theme/theme.type.mjs').ThemeTargetEntry[]} targets
205
+ * @returns {string}
206
+ */
207
+ function formatTargetsTable(targets) {
208
+ const rows = targets.map(t => ({
209
+ key: t.key,
210
+ component: t.component,
211
+ props: t.props.join(', ') || '-',
212
+ states: t.states.join(', ') || '-',
213
+ }));
214
+ const head = {key: 'key', component: 'component', props: 'props', states: 'states'};
215
+ const width = (/** @type {'key'|'component'|'props'} */ field) =>
216
+ [head, ...rows].reduce((max, r) => Math.max(max, r[field].length), 0);
217
+ const w = {key: width('key'), component: width('component'), props: width('props')};
218
+ const line = (/** @type {typeof head} */ r) =>
219
+ [
220
+ r.key.padEnd(w.key),
221
+ r.component.padEnd(w.component),
222
+ r.props.padEnd(w.props),
223
+ r.states,
224
+ ]
225
+ .join(' ')
226
+ .trimEnd();
227
+ return [line(head), ...rows.map(line)].join('\n');
228
+ }
229
+
197
230
  /**
198
231
  * @param {import('commander').Command} program
199
232
  */
@@ -225,6 +258,18 @@ export function registerTheme(program) {
225
258
  },
226
259
  });
227
260
 
261
+ // Theming questions are asked at `theme`, but per-component overrides live
262
+ // under `component`. Without this pointer the group reads as a build-tool
263
+ // menu, and the component targets are unreachable from the noun the user
264
+ // started at.
265
+ theme.addHelpText(
266
+ 'after',
267
+ `\nComponent style overrides:\n` +
268
+ ` ${getCliInvocation()} theme targets Every themeable class, with its props and states\n` +
269
+ ` ${getCliInvocation()} component <Name> One component's theming table\n` +
270
+ ` ${getCliInvocation()} docs theme How component overrides work\n`,
271
+ );
272
+
228
273
  defineCommand(theme, themeBuildCommand, {
229
274
  fn: themeBuildFn,
230
275
  action: async (
@@ -502,4 +547,44 @@ export function registerTheme(program) {
502
547
  );
503
548
  },
504
549
  });
550
+
551
+ defineCommand(theme, themeTargetsCommand, {
552
+ fn: themeTargetsFn,
553
+ action: async (/** @type {string | undefined} */ filter) => {
554
+ const json = program.opts().json || false;
555
+
556
+ /** @type {import('../../../api/theme/theme.type.mjs').ThemeTargetsResponse} */
557
+ let result;
558
+ try {
559
+ result = await themeTargets(filter, {cwd: process.cwd()});
560
+ } catch (e) {
561
+ const err =
562
+ /** @type {import('../../../api/error.mjs').AstryxError} */ (e);
563
+ cliError(err.message, {
564
+ suggestions: err.suggestions || [],
565
+ code: err.code,
566
+ });
567
+ return;
568
+ }
569
+
570
+ if (json) return jsonOut(result);
571
+
572
+ const run = getCliInvocation();
573
+ const {targets, componentCount} = result.data;
574
+ emit(
575
+ section(
576
+ 'Theming targets',
577
+ `${targets.length} across ${componentCount} component${componentCount === 1 ? '' : 's'}`,
578
+ ),
579
+ text(formatTargetsTable(targets)),
580
+ text(
581
+ [
582
+ `Each key goes under \`components\` in defineTheme; it paints \`.astryx-<key>\`.`,
583
+ `Props take a value (\`variant:secondary\`); states are written bare (\`checked\`).`,
584
+ `One component in full: ${run} component <Name>`,
585
+ ].join('\n'),
586
+ ),
587
+ );
588
+ },
589
+ });
505
590
  }
@@ -0,0 +1,64 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file CLI behavior for `astryx theme targets`.
5
+ *
6
+ * The API leaf is covered by api/theme/targets/targets.test.mjs; what is only
7
+ * reachable here is the terminal binding — the table a human reads, the JSON
8
+ * envelope a lint script reads, and the route into component overrides from
9
+ * `theme --help`, which is where the question gets asked.
10
+ */
11
+
12
+ import {describe, it, expect} from 'vitest';
13
+ import {runCli} from '../../../test-utils/run-cli.mjs';
14
+
15
+ describe('astryx theme targets', () => {
16
+ it('prints one greppable line per target, with props and states', async () => {
17
+ const {status, stdout} = await runCli(['theme', 'targets', 'Switch']);
18
+
19
+ expect(status).toBe(0);
20
+ expect(stdout).toMatch(/^switch\s+Switch\s+size\s+checked, disabled$/m);
21
+ expect(stdout).toMatch(/^switch-thumb\s+Switch\s+size\s+checked$/m);
22
+ expect(stdout).toMatch(/3 across 1 component/);
23
+ });
24
+
25
+ it('lists the whole surface when unfiltered', async () => {
26
+ const {status, stdout} = await runCli(['theme', 'targets']);
27
+
28
+ expect(status).toBe(0);
29
+ const rows = stdout.split('\n').filter(l => /^[a-z][a-z0-9-]*\s{2,}/.test(l));
30
+ expect(rows.length).toBeGreaterThan(100);
31
+ expect(stdout).toMatch(/^button\s/m);
32
+ expect(stdout).toMatch(/^switch-thumb\s/m);
33
+ }, 30_000);
34
+
35
+ it('returns a theme.targets envelope under --json', async () => {
36
+ const {status, stdout} = await runCli(['--json', 'theme', 'targets', 'Switch']);
37
+
38
+ expect(status).toBe(0);
39
+ const payload = JSON.parse(stdout);
40
+ expect(payload.type).toBe('theme.targets');
41
+ expect(payload.data.targets).toContainEqual({
42
+ key: 'switch-thumb',
43
+ className: 'astryx-switch-thumb',
44
+ component: 'Switch',
45
+ props: ['size'],
46
+ states: ['checked'],
47
+ });
48
+ });
49
+
50
+ it('fails a filter that matches nothing', async () => {
51
+ const {status, stderr} = await runCli(['theme', 'targets', 'nosuchthing']);
52
+
53
+ expect(status).toBe(1);
54
+ expect(stderr).toMatch(/No theming target matches "nosuchthing"/);
55
+ });
56
+
57
+ it('routes a theming question from `theme --help` to component overrides', async () => {
58
+ const {stdout} = await runCli(['theme', '--help']);
59
+
60
+ expect(stdout).toMatch(/Component style overrides:/);
61
+ expect(stdout).toMatch(/theme targets/);
62
+ expect(stdout).toMatch(/component <Name>/);
63
+ });
64
+ });
@@ -0,0 +1,38 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file CommandDoc for `astryx theme targets`. The terminal binding of the
5
+ * `themeTargets()` function (referenced via `fn`); its args map to that
6
+ * function's params so a converter can build Commander config + --help from one
7
+ * source of truth.
8
+ * @position packages/cli/clients/cli/commands — command documentation
9
+ */
10
+
11
+ /** @type {import('@astryxdesign/cli/authoring').CommandDoc} */
12
+ export const doc = {
13
+ type: 'command',
14
+ name: 'theme targets',
15
+ displayName: 'astryx theme targets',
16
+ namespace: 'cli',
17
+ summary: 'List the component theming targets a theme can override',
18
+ description:
19
+ 'Prints every `defineTheme` components key across the system: the stable class it paints, ' +
20
+ 'the component that declares it, and the props and states that are legal override keys ' +
21
+ 'under it. This is the whole themeable surface in one command — what auditing a theme, or ' +
22
+ 'answering "which key paints this pixel?", used to need one `astryx component <Name>` per ' +
23
+ 'component to assemble. Pass a component name to scope it; pass any substring to search ' +
24
+ 'keys. `--json` for a list a repo can lint its own theme against.',
25
+ fn: 'themeTargets',
26
+ args: [{name: 'filter', param: 'filter', required: false}],
27
+ examples: [
28
+ {label: 'The whole themeable surface', cli: 'astryx theme targets'},
29
+ {label: "One component's targets", cli: 'astryx theme targets Switch'},
30
+ {label: 'Search keys', cli: 'astryx theme targets thumb'},
31
+ {label: 'For a lint or an audit script', cli: 'astryx --json theme targets'},
32
+ ],
33
+ exitCodes: [
34
+ {code: 0, when: 'success'},
35
+ {code: 1, when: 'a filter matches no target, or core cannot be resolved'},
36
+ ],
37
+ related: ['component', 'theme build', 'theme template'],
38
+ };
@@ -17,11 +17,13 @@ export const doc = {
17
17
  description:
18
18
  'The theme command group. Running astryx theme with no subcommand prints the ' +
19
19
  'subcommand list; the work happens in the subcommands: compile a theme (build), ' +
20
- 'scaffold one into your project (add), start a custom one from the annotated template (template), or list the bundled themes (list).',
21
- subcommands: ['build', 'add', 'list', 'template'],
20
+ 'scaffold one into your project (add), start a custom one from the annotated template (template), ' +
21
+ 'list the bundled themes (list), or list the component theming targets a theme can override (targets).',
22
+ subcommands: ['build', 'add', 'list', 'template', 'targets'],
22
23
  examples: [
23
24
  {label: 'List bundled themes', cli: 'astryx theme list'},
24
25
  {label: 'Scaffold a theme', cli: 'astryx theme add matcha'},
26
+ {label: 'See what a theme can override', cli: 'astryx theme targets'},
25
27
  ],
26
28
  exitCodes: [
27
29
  {code: 0, when: 'success (help shown, or a subcommand succeeded)'},
@@ -70,6 +70,7 @@ export const JSON_SUPPORTED = new Set([
70
70
  'theme list',
71
71
  'theme add',
72
72
  'theme template',
73
+ 'theme targets',
73
74
  'upgrade',
74
75
  'manifest',
75
76
  'doctor',
@@ -77,6 +77,7 @@ export const RESPONSE_TYPES = {
77
77
  'theme list': ['theme.list'],
78
78
  'theme add': ['theme.list', 'theme.add'],
79
79
  'theme template': ['theme.template'],
80
+ 'theme targets': ['theme.targets'],
80
81
  upgrade: ['upgrade.list', 'upgrade.status', 'upgrade.run'],
81
82
  manifest: ['manifest'],
82
83
  doctor: ['doctor'],
@@ -116,6 +117,7 @@ const EXAMPLES = {
116
117
  'astryx theme add matcha ./src/themes/matcha',
117
118
  ],
118
119
  'theme template': ['astryx theme template', 'astryx theme template --json'],
120
+ 'theme targets': ['astryx theme targets Switch', 'astryx --json theme targets'],
119
121
  upgrade: ['astryx upgrade --json'],
120
122
  manifest: ['astryx manifest --json', 'astryx --json'],
121
123
  doctor: ['astryx doctor', 'astryx doctor --json'],
@@ -0,0 +1,48 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * Every theming target declared under a core `src` directory, sorted by key
6
+ * then component. A key can appear more than once: a shared sub-element (the
7
+ * radio indicator, say) is documented by every component that renders it.
8
+ *
9
+ * Unreadable docs are skipped rather than fatal — a single malformed doc must
10
+ * not take out theme validation or the listing.
11
+ *
12
+ * @param {string} coreSrc - absolute path to `<core>/src`
13
+ * @returns {Promise<ThemingTarget[]>}
14
+ */
15
+ export function collectThemingTargets(coreSrc: string): Promise<ThemingTarget[]>;
16
+ /**
17
+ * Collapse the enumeration into the `{key: [props and states]}` map theme
18
+ * validation checks override keys against — both are legal override keys, so
19
+ * they share one list.
20
+ * @param {ThemingTarget[]} targets
21
+ * @returns {Record<string, string[]>}
22
+ */
23
+ export function targetsByKey(targets: ThemingTarget[]): Record<string, string[]>;
24
+ /**
25
+ * One theming target, as a theme author has to write it.
26
+ */
27
+ export type ThemingTarget = {
28
+ /**
29
+ * - the `defineTheme` `components` key (class minus the `astryx-` prefix)
30
+ */
31
+ key: string;
32
+ /**
33
+ * - the stable class the component renders
34
+ */
35
+ className: string;
36
+ /**
37
+ * - the component whose doc declares it
38
+ */
39
+ component: string;
40
+ /**
41
+ * - visual props the target reflects (`variant:value` keys)
42
+ */
43
+ props: string[];
44
+ /**
45
+ * - runtime states the target reflects (bare-name keys)
46
+ */
47
+ states: string[];
48
+ };
@@ -0,0 +1,135 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The one enumeration of component theming targets.
5
+ *
6
+ * @input a core `src` directory
7
+ * @output every `theming.targets` entry authored in a component `.doc.mjs`,
8
+ * flattened into the `defineTheme` component key a theme author writes
9
+ * @position packages/cli/foundation/discovery — shared by `theme targets` (the
10
+ * listing) and `theme build` (override validation). Both read the
11
+ * component docs, which are the source of truth `astryx component
12
+ * <Name>` prints; nothing here is a second registry, so the list a
13
+ * theme author can enumerate and the set the compiler accepts cannot
14
+ * drift from the components or from each other.
15
+ */
16
+
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import {loadComponentDoc} from './component-loader.mjs';
20
+
21
+ const SKIP_DIRS = new Set(['node_modules', '__tests__']);
22
+
23
+ /**
24
+ * One theming target, as a theme author has to write it.
25
+ * @typedef {object} ThemingTarget
26
+ * @property {string} key - the `defineTheme` `components` key (class minus the `astryx-` prefix)
27
+ * @property {string} className - the stable class the component renders
28
+ * @property {string} component - the component whose doc declares it
29
+ * @property {string[]} props - visual props the target reflects (`variant:value` keys)
30
+ * @property {string[]} states - runtime states the target reflects (bare-name keys)
31
+ */
32
+
33
+ /**
34
+ * Strip the namespace prefix to get the `defineTheme` key for a class name.
35
+ *
36
+ * Keep the `astryx-` literal in sync with packages/core/src/naming.ts
37
+ * (NAMESPACE / classPrefix), the same way component-format.mjs does.
38
+ * <!-- SYNC: packages/core/src/naming.ts (namespace prefix source of truth) -->
39
+ * @param {string} className
40
+ * @returns {string}
41
+ */
42
+ function targetKey(className) {
43
+ return className.replace(/^astryx-/, '');
44
+ }
45
+
46
+ /**
47
+ * Every theming target declared under a core `src` directory, sorted by key
48
+ * then component. A key can appear more than once: a shared sub-element (the
49
+ * radio indicator, say) is documented by every component that renders it.
50
+ *
51
+ * Unreadable docs are skipped rather than fatal — a single malformed doc must
52
+ * not take out theme validation or the listing.
53
+ *
54
+ * @param {string} coreSrc - absolute path to `<core>/src`
55
+ * @returns {Promise<ThemingTarget[]>}
56
+ */
57
+ export async function collectThemingTargets(coreSrc) {
58
+ if (!coreSrc || !fs.existsSync(coreSrc)) return [];
59
+
60
+ /** @type {ThemingTarget[]} */
61
+ const targets = [];
62
+
63
+ /** @param {string} dir */
64
+ async function scan(dir) {
65
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
66
+ const full = path.join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ if (SKIP_DIRS.has(entry.name)) continue;
69
+ await scan(full);
70
+ continue;
71
+ }
72
+ if (!entry.name.endsWith('.doc.mjs')) continue;
73
+
74
+ /** @type {any} */
75
+ let doc;
76
+ try {
77
+ doc = await loadComponentDoc(full);
78
+ } catch {
79
+ continue;
80
+ }
81
+
82
+ const component =
83
+ typeof doc?.name === 'string' && doc.name
84
+ ? doc.name
85
+ : path.basename(path.dirname(full));
86
+
87
+ for (const target of doc?.theming?.targets || []) {
88
+ const className = target?.className;
89
+ if (typeof className !== 'string') continue;
90
+ const key = targetKey(className);
91
+ if (!key) continue;
92
+ targets.push({
93
+ key,
94
+ className,
95
+ component,
96
+ props: stringList(target.visualProps),
97
+ states: stringList(target.states),
98
+ });
99
+ }
100
+ }
101
+ }
102
+
103
+ await scan(coreSrc);
104
+
105
+ targets.sort(
106
+ (a, b) => a.key.localeCompare(b.key) || a.component.localeCompare(b.component),
107
+ );
108
+ return targets;
109
+ }
110
+
111
+ /**
112
+ * Collapse the enumeration into the `{key: [props and states]}` map theme
113
+ * validation checks override keys against — both are legal override keys, so
114
+ * they share one list.
115
+ * @param {ThemingTarget[]} targets
116
+ * @returns {Record<string, string[]>}
117
+ */
118
+ export function targetsByKey(targets) {
119
+ /** @type {Record<string, string[]>} */
120
+ const byKey = {};
121
+ for (const t of targets) {
122
+ byKey[t.key] = [...new Set([...(byKey[t.key] || []), ...t.props, ...t.states])];
123
+ }
124
+ return byKey;
125
+ }
126
+
127
+ /**
128
+ * @param {unknown} value
129
+ * @returns {string[]}
130
+ */
131
+ function stringList(value) {
132
+ return Array.isArray(value)
133
+ ? value.filter((/** @type {unknown} */ v) => typeof v === 'string')
134
+ : [];
135
+ }
@@ -0,0 +1,127 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The enumerability guard for component theming targets.
5
+ *
6
+ * A theming target is only useful if a theme author can find it. These tests
7
+ * run against the REAL core docs and fail if any component's targets stop
8
+ * being enumerable — a doc that moves out of the scanned tree, a target shape
9
+ * that stops being read, or a component whose Theming table says one thing
10
+ * while `theme targets` says another. That divergence is the failure the
11
+ * listing exists to prevent: a target list that can drift from the components
12
+ * is worse than no list.
13
+ */
14
+
15
+ import {describe, it, expect} from 'vitest';
16
+ import * as path from 'node:path';
17
+ import {findCoreDir} from '../fs/paths.mjs';
18
+ import {
19
+ discoverComponents,
20
+ findComponentReadme,
21
+ } from './component-discovery.mjs';
22
+ import {loadComponentDoc} from './component-loader.mjs';
23
+ import {collectThemingTargets, targetsByKey} from './theming-targets.mjs';
24
+
25
+ const coreDir = /** @type {string} */ (findCoreDir(process.cwd()));
26
+ const coreSrc = path.join(coreDir, 'src');
27
+
28
+ /** @type {Promise<import('./theming-targets.mjs').ThemingTarget[]>} */
29
+ const enumerated = collectThemingTargets(coreSrc);
30
+
31
+ describe('collectThemingTargets', () => {
32
+ it('enumerates the whole surface, not a handful', async () => {
33
+ const targets = await enumerated;
34
+ expect(targets.length).toBeGreaterThan(100);
35
+ expect(new Set(targets.map(t => t.component)).size).toBeGreaterThan(50);
36
+ });
37
+
38
+ it('drops the namespace prefix so each key is what defineTheme takes', async () => {
39
+ for (const t of await enumerated) {
40
+ expect(t.className).toBe(`astryx-${t.key}`);
41
+ expect(t.component).toBeTruthy();
42
+ }
43
+ });
44
+
45
+ it('carries the props and states a target reflects', async () => {
46
+ const targets = await enumerated;
47
+ expect(targets.find(t => t.key === 'switch-thumb')).toEqual({
48
+ key: 'switch-thumb',
49
+ className: 'astryx-switch-thumb',
50
+ component: 'Switch',
51
+ props: ['size'],
52
+ states: ['checked'],
53
+ });
54
+ });
55
+
56
+ it('is sorted by key, so a diff of two runs is readable', async () => {
57
+ const keys = (await enumerated).map(t => t.key);
58
+ expect(keys).toEqual([...keys].sort((a, b) => a.localeCompare(b)));
59
+ });
60
+
61
+ // The listing and `theme build`'s override validation read this one
62
+ // enumeration. `targetsByKey` is the shape validation wants: props and
63
+ // states merged, because both are legal override keys.
64
+ it('collapses to the override keys, merging the components that share one', async () => {
65
+ const byKey = targetsByKey(await enumerated);
66
+ expect(byKey['switch']).toEqual(['size', 'checked', 'disabled']);
67
+ // `radio` is documented by both Indicator and RadioList.
68
+ const radio = (await enumerated).filter(t => t.key === 'radio');
69
+ expect(radio.length).toBeGreaterThan(1);
70
+ for (const t of radio) {
71
+ for (const name of [...t.props, ...t.states]) {
72
+ expect(byKey['radio']).toContain(name);
73
+ }
74
+ }
75
+ });
76
+
77
+ it('every component doc that declares targets has them enumerated', async () => {
78
+ const targets = await enumerated;
79
+ /** @type {Map<string, Set<string>>} key -> props+states */
80
+ const byKey = new Map(
81
+ Object.entries(targetsByKey(targets)).map(([k, v]) => [k, new Set(v)]),
82
+ );
83
+
84
+ const names = Object.values(discoverComponents(coreDir)).flat();
85
+ /** @type {string[]} */
86
+ const missing = [];
87
+ /** @type {Set<string>} */
88
+ const seenDocs = new Set();
89
+ let checked = 0;
90
+
91
+ for (const name of names) {
92
+ const docPath = findComponentReadme(coreDir, name);
93
+ if (!docPath || seenDocs.has(docPath)) continue;
94
+ seenDocs.add(docPath);
95
+
96
+ /** @type {any} */
97
+ let doc;
98
+ try {
99
+ doc = await loadComponentDoc(docPath);
100
+ } catch {
101
+ continue;
102
+ }
103
+
104
+ for (const target of doc?.theming?.targets || []) {
105
+ if (typeof target?.className !== 'string') continue;
106
+ checked++;
107
+ const key = target.className.replace(/^astryx-/, '');
108
+ const known = byKey.get(key);
109
+ if (!known) {
110
+ missing.push(`${name}: ${target.className} is not enumerable`);
111
+ continue;
112
+ }
113
+ for (const prop of [
114
+ ...(target.visualProps || []),
115
+ ...(target.states || []),
116
+ ]) {
117
+ if (!known.has(prop)) {
118
+ missing.push(`${name}: ${target.className} lost "${prop}"`);
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ expect(checked).toBeGreaterThan(100);
125
+ expect(missing).toEqual([]);
126
+ }, 60_000);
127
+ });
@@ -199,6 +199,11 @@ export const doc = {
199
199
  description:
200
200
  'A write receipt for the annotated theme template: the path (relative to cwd), whether it was written, and the reason it was not — `exists` when a file was already there, which is a success.',
201
201
  },
202
+ {
203
+ value: 'theme.targets',
204
+ description:
205
+ 'The whole themeable surface: the echoed filter, the component count, and one entry per theming target — {key, className, component, props, states}, where props and states are its legal override keys.',
206
+ },
202
207
 
203
208
  // upgrade
204
209
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.4.7-canary.5d351e4",
3
+ "version": "0.4.7-canary.63f398d",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -87,10 +87,10 @@
87
87
  "zod": "^4.4.3"
88
88
  },
89
89
  "peerDependencies": {
90
- "@astryxdesign/charts": "0.4.7-canary.5d351e4",
91
- "@astryxdesign/core": "0.4.7-canary.5d351e4",
92
- "@astryxdesign/lab": "0.4.7-canary.5d351e4",
93
- "@astryxdesign/theme-neutral": "0.4.7-canary.5d351e4",
90
+ "@astryxdesign/charts": "0.4.7-canary.63f398d",
91
+ "@astryxdesign/core": "0.4.7-canary.63f398d",
92
+ "@astryxdesign/lab": "0.4.7-canary.63f398d",
93
+ "@astryxdesign/theme-neutral": "0.4.7-canary.63f398d",
94
94
  "gpt-tokenizer": "^3.4.0"
95
95
  },
96
96
  "peerDependenciesMeta": {
@@ -108,10 +108,10 @@
108
108
  }
109
109
  },
110
110
  "devDependencies": {
111
- "@astryxdesign/charts": "0.4.7-canary.5d351e4",
112
- "@astryxdesign/core": "0.4.7-canary.5d351e4",
113
- "@astryxdesign/lab": "0.4.7-canary.5d351e4",
114
- "@astryxdesign/theme-neutral": "0.4.7-canary.5d351e4",
111
+ "@astryxdesign/charts": "0.4.7-canary.63f398d",
112
+ "@astryxdesign/core": "0.4.7-canary.63f398d",
113
+ "@astryxdesign/lab": "0.4.7-canary.63f398d",
114
+ "@astryxdesign/theme-neutral": "0.4.7-canary.63f398d",
115
115
  "gpt-tokenizer": "^3.4.0"
116
116
  },
117
117
  "scripts": {