@astryxdesign/core 0.4.2 → 0.4.3

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 (79) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/Avatar/Avatar.d.ts.map +1 -1
  3. package/dist/Avatar/Avatar.js +3 -19
  4. package/dist/Banner/Banner.d.ts +6 -2
  5. package/dist/Banner/Banner.d.ts.map +1 -1
  6. package/dist/Banner/Banner.js +71 -30
  7. package/dist/Banner/index.d.ts +14 -12
  8. package/dist/Banner/index.d.ts.map +1 -1
  9. package/dist/Banner/index.js +10 -8
  10. package/dist/Chat/ChatTokenizedText.js +1 -1
  11. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  12. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  13. package/dist/ComplexSelector/ComplexSelector.js +96 -35
  14. package/dist/ComplexSelector/index.d.ts +2 -2
  15. package/dist/ComplexSelector/index.d.ts.map +1 -1
  16. package/dist/ComplexSelector/index.js +1 -1
  17. package/dist/Markdown/parser.d.ts.map +1 -1
  18. package/dist/Markdown/parser.js +55 -12
  19. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  20. package/dist/PowerSearch/PowerSearch.js +4 -1
  21. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  22. package/dist/PowerSearch/formatFilterValue.js +2 -4
  23. package/dist/Table/columnUtils.d.ts.map +1 -1
  24. package/dist/Table/columnUtils.js +4 -1
  25. package/dist/TextArea/TextArea.d.ts +6 -3
  26. package/dist/TextArea/TextArea.d.ts.map +1 -1
  27. package/dist/TextArea/TextArea.js +17 -6
  28. package/dist/TreeList/TreeList.js +2 -1
  29. package/dist/astryx.css +6 -4
  30. package/dist/astryx.umd.js +51 -51
  31. package/dist/astryx.umd.js.map +4 -4
  32. package/dist/theme/defineTheme.d.ts +8 -3
  33. package/dist/theme/defineTheme.d.ts.map +1 -1
  34. package/dist/theme/defineTheme.js +36 -47
  35. package/dist/theme/mergeComponents.d.ts +20 -0
  36. package/dist/theme/mergeComponents.d.ts.map +1 -0
  37. package/dist/theme/mergeComponents.js +56 -0
  38. package/dist/theme/onMediaTokens.d.ts +6 -1
  39. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  40. package/dist/theme/onMediaTokens.js +11 -3
  41. package/dist/utils/characters.d.ts +27 -0
  42. package/dist/utils/characters.d.ts.map +1 -0
  43. package/dist/utils/characters.js +83 -0
  44. package/dist/utils/index.d.ts +1 -0
  45. package/dist/utils/index.d.ts.map +1 -1
  46. package/dist/utils/index.js +1 -0
  47. package/package.json +2 -2
  48. package/src/Avatar/Avatar.test.tsx +6 -1
  49. package/src/Avatar/Avatar.tsx +3 -21
  50. package/src/Banner/Banner.doc.mjs +9 -7
  51. package/src/Banner/Banner.test.tsx +68 -0
  52. package/src/Banner/Banner.tsx +97 -36
  53. package/src/Banner/index.ts +15 -13
  54. package/src/Chat/ChatTokenizedText.tsx +1 -1
  55. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  56. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  57. package/src/ComplexSelector/ComplexSelector.tsx +153 -32
  58. package/src/ComplexSelector/index.ts +3 -1
  59. package/src/Markdown/parser.test.ts +53 -0
  60. package/src/Markdown/parser.ts +53 -12
  61. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  62. package/src/PowerSearch/PowerSearch.tsx +4 -1
  63. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  64. package/src/PowerSearch/formatFilterValue.ts +2 -4
  65. package/src/Table/Table.test.tsx +6 -0
  66. package/src/Table/columnUtils.ts +3 -1
  67. package/src/TextArea/TextArea.doc.mjs +1 -1
  68. package/src/TextArea/TextArea.test.tsx +72 -0
  69. package/src/TextArea/TextArea.tsx +26 -8
  70. package/src/TreeList/TreeList.doc.mjs +2 -2
  71. package/src/TreeList/TreeList.tsx +1 -1
  72. package/src/theme/defineTheme.test.ts +127 -0
  73. package/src/theme/defineTheme.ts +48 -51
  74. package/src/theme/extensibleAxes.test.ts +365 -0
  75. package/src/theme/mergeComponents.ts +59 -0
  76. package/src/theme/onMediaTokens.ts +9 -2
  77. package/src/utils/characters.test.ts +141 -0
  78. package/src/utils/characters.ts +88 -0
  79. package/src/utils/index.ts +2 -0
@@ -72,6 +72,7 @@ import type {DomainTokenName} from './domainTokens';
72
72
  import {domainTokenDefaults} from './domainTokens';
73
73
  import type {SyntaxThemeDefinition} from './syntax';
74
74
  import {registerTheme} from './themeRegistry';
75
+ import {deepMergeComponents} from './mergeComponents';
75
76
 
76
77
  // =============================================================================
77
78
  // Types
@@ -165,9 +166,14 @@ export interface DefineThemeInput {
165
166
  name: string;
166
167
 
167
168
  /**
168
- * Base theme to extend. When provided, the new theme starts with the
169
- * base theme's tokens, components, and fonts, then applies overrides
170
- * from this input on top. The base theme's values have lowest precedence.
169
+ * Base theme to extend. When provided, the new theme starts with everything
170
+ * the base resolved to — tokens, component overrides, icons, indicators, and
171
+ * its `onDark`/`onLight` surfaces — then applies this input on top. The base
172
+ * theme's values have lowest precedence.
173
+ *
174
+ * The result is flat: an extended theme carries its inheritance in its own
175
+ * resolved output, so `astryx theme build` emits one self-contained
176
+ * stylesheet and the base's CSS does not need to be loaded alongside it.
171
177
  *
172
178
  * Use this to create variant themes that customize only a few aspects
173
179
  * (e.g. icons, accent color) without re-specifying the full theme.
@@ -416,49 +422,6 @@ function resolveTokenValue(value: TokenValue): string {
416
422
  return value;
417
423
  }
418
424
 
419
- /**
420
- * Deep-merge two component style maps.
421
- * Properties in `overrides` take precedence over `base`.
422
- * This allows typeScale-generated rules to be overridden by explicit components.
423
- */
424
- function deepMergeComponents(
425
- base?: ComponentStyleMap,
426
- overrides?: ComponentStyleMap,
427
- ): ComponentStyleMap | undefined {
428
- if (!base && !overrides) {
429
- return undefined;
430
- }
431
- if (!base) {
432
- return overrides;
433
- }
434
- if (!overrides) {
435
- return base;
436
- }
437
-
438
- const result: ComponentStyleMap = {};
439
-
440
- // Start with all base entries
441
- for (const [component, rules] of Object.entries(base)) {
442
- result[component] = {...rules};
443
- }
444
-
445
- // Merge overrides on top
446
- for (const [component, rules] of Object.entries(overrides)) {
447
- if (!result[component]) {
448
- result[component] = {...rules};
449
- } else {
450
- for (const [key, styles] of Object.entries(rules)) {
451
- result[component][key] = {
452
- ...result[component][key],
453
- ...styles,
454
- };
455
- }
456
- }
457
- }
458
-
459
- return result;
460
- }
461
-
462
425
  /**
463
426
  * Resolve a FontWeight name to a var() reference.
464
427
  * Named weights map to var(--font-weight-*); raw values pass through.
@@ -491,6 +454,24 @@ function buildFontFamily(
491
454
  return quoted;
492
455
  }
493
456
 
457
+ /**
458
+ * Describe a rejected `extends` value for the error message — enough to tell a
459
+ * missed import (`undefined`) from a module namespace or a plain object.
460
+ */
461
+ function describeBadBase(value: unknown): string {
462
+ if (value === undefined) {
463
+ return 'undefined';
464
+ }
465
+ if (value === null) {
466
+ return 'null';
467
+ }
468
+ if (typeof value !== 'object') {
469
+ return typeof value;
470
+ }
471
+ const keys = Object.keys(value);
472
+ return `an object with keys [${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', …' : ''}]`;
473
+ }
474
+
494
475
  /**
495
476
  * Create an Astryx theme.
496
477
  *
@@ -504,7 +485,19 @@ function buildFontFamily(
504
485
  export function defineTheme(input: DefineThemeInput): DefinedTheme {
505
486
  const tokens: Record<string, string> = {};
506
487
 
507
- // 0. Pre-seed from base theme when `extends` is provided (lowest precedence)
488
+ // 0. Pre-seed from base theme when `extends` is provided (lowest precedence).
489
+ // A base that is not a theme is refused rather than ignored: `extends` used
490
+ // to inherit nothing when its value was undefined, which is what a named
491
+ // import silently resolving to the wrong module hands over, and the theme
492
+ // then built into a plausible-looking stylesheet missing everything it was
493
+ // supposed to inherit.
494
+ if ('extends' in input && !isDefinedTheme(input.extends)) {
495
+ throw new Error(
496
+ `defineTheme("${input.name}"): \`extends\` must be a theme from defineTheme(), got ${describeBadBase(input.extends)}. ` +
497
+ `Check that the import naming your base theme resolves to its source and exports that name — ` +
498
+ `a generated \`<theme>.js\` artifact sitting next to the source exports \`<name>Theme\`, not the source's own export.`,
499
+ );
500
+ }
508
501
  const base = input.extends;
509
502
  if (base) {
510
503
  for (const [key, value] of Object.entries(base.tokens)) {
@@ -640,9 +633,10 @@ export function defineTheme(input: DefineThemeInput): DefinedTheme {
640
633
  components = deepMergeComponents(base.components, components);
641
634
  }
642
635
 
643
- // 4. Resolve on-media token overrides (defaults + user overrides)
644
- const __onDark = resolveOnMedia('dark', input.onDark);
645
- const __onLight = resolveOnMedia('light', input.onLight);
636
+ // 4. Resolve on-media token overrides (base's resolved surface, then
637
+ // defaults, then this theme's own overrides)
638
+ const __onDark = resolveOnMedia('dark', input.onDark, base?.__onDark);
639
+ const __onLight = resolveOnMedia('light', input.onLight, base?.__onLight);
646
640
 
647
641
  // 5. Merge icons — input icons override base icons
648
642
  const icons =
@@ -663,7 +657,10 @@ export function defineTheme(input: DefineThemeInput): DefinedTheme {
663
657
  components,
664
658
  icons,
665
659
  indicators,
666
- __inputTokens: input.tokens,
660
+ __inputTokens:
661
+ base?.__inputTokens || input.tokens
662
+ ? {...base?.__inputTokens, ...input.tokens}
663
+ : undefined,
667
664
  __onDark,
668
665
  __onLight,
669
666
  };
@@ -0,0 +1,365 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /* eslint-disable @typescript-eslint/no-require-imports */
4
+ /**
5
+ * @file Guards the EXTENSIBLE prop axes — the `*Map` interfaces a theme
6
+ * augments — against the theming surface that has to carry them.
7
+ * @input Component sources (*.tsx/*.ts) and their `{Name}.doc.mjs` files.
8
+ * @output Vitest failures naming each map whose axis a theme cannot reach.
9
+ * @position Third sibling of derivedVarRegistry.test.ts (`vars`, `derived`) and
10
+ * themingTargets.test.ts (`targets`). Those two check what a component
11
+ * RENDERS against what it DOCUMENTS. Neither one looks at the open prop
12
+ * unions, so nothing did.
13
+ *
14
+ * An extensible axis is a promise made in three places at once, and it is only
15
+ * kept if all three agree:
16
+ *
17
+ * 1. `export interface FooVariantMap` in `Foo/index.ts` — the augmentation
18
+ * point. A consumer writes `declare module '@astryxdesign/core/Foo'`
19
+ * against THAT subpath, and `astryx theme build` looks for the literal
20
+ * interface there when it emits `<theme>.variants.d.ts`. A map declared
21
+ * in a sibling file and only re-exported is invisible to both.
22
+ * 2. `themeProps('foo', {variant})` — the axis reaching the DOM. Without it
23
+ * a custom variant renders no selector, so there is nothing to style.
24
+ * 3. `visualProps: ['variant']` on the doc's theming target — discovery, and
25
+ * the reason `theme build` does not reject `'foo': {'variant:custom': …}`
26
+ * as an unknown prop.
27
+ *
28
+ * Miss (2) and the type says yes while the CSS says nothing: TreeList shipped
29
+ * `TreeListVariantMap`, with a module-augmentation example in its own JSDoc,
30
+ * while `themeProps('tree-list', {density})` never passed `variant` — so an
31
+ * augmented variant type-checked, rendered, and could not be themed.
32
+ */
33
+
34
+ import {describe, it, expect} from 'vitest';
35
+ import {readdirSync, readFileSync, existsSync} from 'node:fs';
36
+ import {join, relative} from 'node:path';
37
+ import ts from 'typescript';
38
+ import {stableClassName} from '../naming';
39
+
40
+ const SRC_DIR = join(__dirname, '..');
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Source scanning
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * An extensible axis, keyed by the map that owns it.
48
+ *
49
+ * The OWNER is the component whose index declares the interface — that is the
50
+ * subpath a consumer augments and the component that has to reflect the prop.
51
+ * Other components may declare a prop of the same type (`AlertDialog`'s
52
+ * `actionVariant: ButtonVariant`, every field's `statusVariant`); they forward
53
+ * the value to the owner and are not separately accountable for it.
54
+ */
55
+ interface ExtensibleAxis {
56
+ /** Component directory under src/ whose index declares the map. */
57
+ dir: string;
58
+ /** Interface name, e.g. 'TreeListVariantMap'. */
59
+ mapName: string;
60
+ /** The prop it types, read off the interface name, e.g. 'variant'. */
61
+ prop: string;
62
+ }
63
+
64
+ function sourceFilesUnder(dir: string, out: string[] = []): string[] {
65
+ for (const entry of readdirSync(dir, {withFileTypes: true})) {
66
+ const full = join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ if (entry.name === 'node_modules' || entry.name === '__tests__') {continue;}
69
+ sourceFilesUnder(full, out);
70
+ } else if (/\.tsx?$/.test(entry.name) && !entry.name.includes('.test.')) {
71
+ out.push(full);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ function parse(file: string): ts.SourceFile {
78
+ return ts.createSourceFile(
79
+ file,
80
+ readFileSync(file, 'utf8'),
81
+ ts.ScriptTarget.Latest,
82
+ true,
83
+ file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Every `type Alias = keyof SomethingMap` in the tree, as alias -> map name.
89
+ * This is the shape that makes a union OPEN: augmenting the interface widens
90
+ * the alias, which is the whole mechanism a theme package uses.
91
+ */
92
+ function collectKeyofAliases(files: string[]): Map<string, string> {
93
+ const aliases = new Map<string, string>();
94
+
95
+ /** `keyof FooMap`, and the `keyof FooMap & string` narrowing form. */
96
+ const mapBehind = (type: ts.TypeNode): string | null => {
97
+ if (ts.isIntersectionTypeNode(type)) {
98
+ for (const member of type.types) {
99
+ const found = mapBehind(member);
100
+ if (found != null) {return found;}
101
+ }
102
+ return null;
103
+ }
104
+ if (
105
+ ts.isTypeOperatorNode(type) &&
106
+ type.operator === ts.SyntaxKind.KeyOfKeyword &&
107
+ ts.isTypeReferenceNode(type.type) &&
108
+ ts.isIdentifier(type.type.typeName) &&
109
+ type.type.typeName.text.endsWith('Map')
110
+ ) {
111
+ return type.type.typeName.text;
112
+ }
113
+ return null;
114
+ };
115
+
116
+ for (const file of files) {
117
+ const visit = (node: ts.Node): void => {
118
+ if (ts.isTypeAliasDeclaration(node)) {
119
+ const map = mapBehind(node.type);
120
+ if (map != null) {aliases.set(node.name.text, map);}
121
+ }
122
+ ts.forEachChild(node, visit);
123
+ };
124
+ visit(parse(file));
125
+ }
126
+ return aliases;
127
+ }
128
+
129
+ /**
130
+ * Maps whose alias types a prop on some `*Props` interface — i.e. the open
131
+ * union is a COMPONENT PROP, which is what makes it a theming axis.
132
+ *
133
+ * This is the line between the two kinds of augmentable map in the tree.
134
+ * `ButtonVariantMap` widens `<Button variant>`, a visual prop that reaches the
135
+ * DOM and gets styled. `IndicatorMap` widens the set of registered indicator
136
+ * NAMES — a different extension mechanism (the theme's `indicators` field,
137
+ * swapping a React component), with no selector and no visual prop. Holding
138
+ * the second to the theming contract below would be a category error.
139
+ */
140
+ function mapsTypingAProp(
141
+ files: string[],
142
+ aliases: Map<string, string>,
143
+ ): Set<string> {
144
+ const used = new Set<string>();
145
+ for (const file of files) {
146
+ const visit = (node: ts.Node): void => {
147
+ if (ts.isInterfaceDeclaration(node) && node.name.text.endsWith('Props')) {
148
+ for (const member of node.members) {
149
+ if (!ts.isPropertySignature(member) || member.type == null) {continue;}
150
+ const typeNode = ts.isArrayTypeNode(member.type)
151
+ ? member.type.elementType
152
+ : member.type;
153
+ if (
154
+ ts.isTypeReferenceNode(typeNode) &&
155
+ ts.isIdentifier(typeNode.typeName)
156
+ ) {
157
+ const map = aliases.get(typeNode.typeName.text);
158
+ if (map != null) {used.add(map);}
159
+ }
160
+ }
161
+ }
162
+ ts.forEachChild(node, visit);
163
+ };
164
+ visit(parse(file));
165
+ }
166
+ return used;
167
+ }
168
+
169
+ /**
170
+ * Every map that is (a) an open union — some `type X = keyof <Map>` exists, so
171
+ * augmenting it widens a real type — and (b) declared in a component index.
172
+ *
173
+ * The prop name is read off the interface, which is the same convention
174
+ * `astryx theme build` uses to find an augmentation point:
175
+ * `<Prefix><Prop>Map`. `TextColorMap` -> Text, `color`.
176
+ * `AvatarStatusDotVariantMap` -> Avatar, `variant`.
177
+ */
178
+ function collectExtensibleAxes(
179
+ files: string[],
180
+ aliases: Map<string, string>,
181
+ propMaps: Set<string>,
182
+ ): ExtensibleAxis[] {
183
+ const open = propMaps;
184
+ const axes: ExtensibleAxis[] = [];
185
+ for (const file of files) {
186
+ if (!/\/index\.tsx?$/.test(file)) {continue;}
187
+ const dir = relative(SRC_DIR, file).split('/')[0];
188
+ const visit = (node: ts.Node): void => {
189
+ if (
190
+ ts.isInterfaceDeclaration(node) &&
191
+ node.name.text.endsWith('Map') &&
192
+ open.has(node.name.text)
193
+ ) {
194
+ const bare = node.name.text.slice(0, -'Map'.length);
195
+ // The trailing PascalCase word is the prop.
196
+ const match = /([A-Z][a-z0-9]*)$/.exec(bare);
197
+ if (match == null) {return;}
198
+ const prop = match[1].charAt(0).toLowerCase() + match[1].slice(1);
199
+ axes.push({dir, mapName: node.name.text, prop});
200
+ }
201
+ ts.forEachChild(node, visit);
202
+ };
203
+ visit(parse(file));
204
+ }
205
+ return axes;
206
+ }
207
+
208
+ /** Every `themeProps('name', {...})` site, as class -> the prop keys it passes. */
209
+ function collectThemePropsSites(files: string[]): Map<string, Set<string>> {
210
+ const sites = new Map<string, Set<string>>();
211
+ for (const file of files) {
212
+ const visit = (node: ts.Node): void => {
213
+ if (
214
+ ts.isCallExpression(node) &&
215
+ ts.isIdentifier(node.expression) &&
216
+ node.expression.text === 'themeProps'
217
+ ) {
218
+ const [nameArg, propsArg] = node.arguments;
219
+ if (nameArg != null && ts.isStringLiteralLike(nameArg)) {
220
+ const cls = stableClassName(nameArg.text);
221
+ const keys = sites.get(cls) ?? new Set<string>();
222
+ if (propsArg != null && ts.isObjectLiteralExpression(propsArg)) {
223
+ for (const prop of propsArg.properties) {
224
+ const name = prop.name;
225
+ if (
226
+ name != null &&
227
+ (ts.isIdentifier(name) || ts.isStringLiteralLike(name))
228
+ ) {
229
+ keys.add(name.text);
230
+ }
231
+ }
232
+ }
233
+ sites.set(cls, keys);
234
+ }
235
+ }
236
+ ts.forEachChild(node, visit);
237
+ };
238
+ visit(parse(file));
239
+ }
240
+ return sites;
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Docs
245
+ // ---------------------------------------------------------------------------
246
+
247
+ interface DocTarget {
248
+ className: string;
249
+ visualProps: string[];
250
+ }
251
+
252
+ function collectDocTargets(dir: string): Map<string, DocTarget[]> {
253
+ const byDir = new Map<string, DocTarget[]>();
254
+ const walk = (current: string): void => {
255
+ for (const entry of readdirSync(current, {withFileTypes: true})) {
256
+ const full = join(current, entry.name);
257
+ if (entry.isDirectory()) {
258
+ if (entry.name === 'node_modules' || entry.name === '__tests__') {
259
+ continue;
260
+ }
261
+ walk(full);
262
+ } else if (entry.name.endsWith('.doc.mjs')) {
263
+ const componentDir = relative(SRC_DIR, full).split('/')[0];
264
+ let doc;
265
+ try {
266
+ doc = require(full).docs;
267
+ } catch {
268
+ continue;
269
+ }
270
+ const targets = (doc?.theming?.targets ?? [])
271
+ .filter(
272
+ (t: unknown): t is {className: string} =>
273
+ typeof (t as {className?: unknown})?.className === 'string',
274
+ )
275
+ .map((t: {className: string; visualProps?: string[]}) => ({
276
+ className: t.className,
277
+ visualProps: t.visualProps ?? [],
278
+ }));
279
+ byDir.set(componentDir, [
280
+ ...(byDir.get(componentDir) ?? []),
281
+ ...targets,
282
+ ]);
283
+ }
284
+ }
285
+ };
286
+ walk(dir);
287
+ return byDir;
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+
292
+ const files = sourceFilesUnder(SRC_DIR);
293
+ const aliases = collectKeyofAliases(files);
294
+ const propMaps = mapsTypingAProp(files, aliases);
295
+ const axes = collectExtensibleAxes(files, aliases, propMaps);
296
+ const themePropsSites = collectThemePropsSites(files);
297
+ const docTargets = collectDocTargets(SRC_DIR);
298
+
299
+ /** The target a component's own name maps to, e.g. TreeList -> astryx-tree-list. */
300
+ function ownTargets(axis: ExtensibleAxis): DocTarget[] {
301
+ return docTargets.get(axis.dir) ?? [];
302
+ }
303
+
304
+ describe('extensible prop axes are reachable by a theme', () => {
305
+ it('finds the axes at all (guards the guard)', () => {
306
+ // If the AST walk silently stopped matching, every assertion below would
307
+ // pass on an empty list.
308
+ expect(axes.length).toBeGreaterThan(10);
309
+ expect(axes.map(a => a.mapName)).toContain('ButtonVariantMap');
310
+ });
311
+
312
+ it('every open prop union is declared in an index a consumer can augment', () => {
313
+ // Module augmentation only widens the module where the interface is
314
+ // DECLARED, and the CLI greps the public subpath for that literal
315
+ // declaration. A map in a sibling file, re-exported, satisfies neither:
316
+ // `declare module '@astryxdesign/core/<Component>'` would create a new,
317
+ // unrelated interface and `theme build` would emit no augmentation.
318
+ // `collectExtensibleAxes` only reads indexes, so anything open and
319
+ // declared elsewhere is missing from `axes` entirely — compare against
320
+ // every open map in the tree to catch that.
321
+ const declaredInAnIndex = new Set(axes.map(a => a.mapName));
322
+ const notAugmentable = [...propMaps].filter(m => !declaredInAnIndex.has(m));
323
+ expect(
324
+ notAugmentable,
325
+ 'these maps type an open union but are not declared in a component index, ' +
326
+ 'so no consumer can augment them at @astryxdesign/core/<Component>',
327
+ ).toEqual([]);
328
+ });
329
+
330
+ it.each(axes.map(a => [`${a.dir}.${a.prop} (${a.mapName})`, a] as const))(
331
+ '%s reaches the DOM through themeProps',
332
+ (_label, axis) => {
333
+ // The axis is extensible, so a consumer can add a value we have never
334
+ // seen. The only way their CSS can select it is if the component
335
+ // reflects the prop.
336
+ const targets = ownTargets(axis);
337
+ const reflected = targets.some(t =>
338
+ themePropsSites.get(t.className)?.has(axis.prop),
339
+ );
340
+ expect(
341
+ reflected,
342
+ `${axis.dir} lets a theme add \`${axis.prop}\` values via ${axis.mapName}, but no ` +
343
+ `themeProps() call passes \`${axis.prop}\` — a custom value renders no selector, ` +
344
+ `so it cannot be styled. Pass it: themeProps('<target>', {${axis.prop}}).`,
345
+ ).toBe(true);
346
+ },
347
+ );
348
+
349
+ it.each(axes.map(a => [`${a.dir}.${a.prop} (${a.mapName})`, a] as const))(
350
+ '%s is documented as a visual prop',
351
+ (_label, axis) => {
352
+ // Undocumented, it is undiscoverable — and `theme build` rejects
353
+ // `'<target>': {'<prop>:custom': …}` as an unknown prop, because its
354
+ // known-prop set is built from exactly this field.
355
+ const targets = ownTargets(axis);
356
+ const documented = targets.some(t => t.visualProps.includes(axis.prop));
357
+ expect(
358
+ documented,
359
+ `${axis.dir} lets a theme add \`${axis.prop}\` values via ${axis.mapName}, but no ` +
360
+ `theming target documents \`${axis.prop}\` in visualProps — a theme author cannot ` +
361
+ `discover the axis, and \`astryx theme build\` warns "Unknown prop" on it.`,
362
+ ).toBe(true);
363
+ },
364
+ );
365
+ });
@@ -0,0 +1,59 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Component style-map merging
5
+ *
6
+ * One merge rule for component overrides, shared by every layer that composes
7
+ * them: `extends` inheritance, generated type-scale rules, and the on-media
8
+ * (`onDark`/`onLight`) surfaces. Merging is per style key, so a child that
9
+ * restates one property of `button.base` keeps the rest of the base's.
10
+ *
11
+ * @input two ComponentStyleMaps — the base and the overrides that win
12
+ * @output a merged ComponentStyleMap
13
+ * @position packages/core/src/theme/mergeComponents.ts
14
+ */
15
+
16
+ import type {ComponentStyleMap} from './defineTheme';
17
+
18
+ /**
19
+ * Deep-merge component style maps: `overrides` wins per style key, and every
20
+ * component and key the base declared that the overrides do not mention is
21
+ * carried through untouched.
22
+ */
23
+ export function deepMergeComponents(
24
+ base?: ComponentStyleMap,
25
+ overrides?: ComponentStyleMap,
26
+ ): ComponentStyleMap | undefined {
27
+ if (!base && !overrides) {
28
+ return undefined;
29
+ }
30
+ if (!base) {
31
+ return overrides;
32
+ }
33
+ if (!overrides) {
34
+ return base;
35
+ }
36
+
37
+ const result: ComponentStyleMap = {};
38
+
39
+ // Start with all base entries
40
+ for (const [component, rules] of Object.entries(base)) {
41
+ result[component] = {...rules};
42
+ }
43
+
44
+ // Merge overrides on top
45
+ for (const [component, rules] of Object.entries(overrides)) {
46
+ if (!result[component]) {
47
+ result[component] = {...rules};
48
+ } else {
49
+ for (const [key, styles] of Object.entries(rules)) {
50
+ result[component][key] = {
51
+ ...result[component][key],
52
+ ...styles,
53
+ };
54
+ }
55
+ }
56
+ }
57
+
58
+ return result;
59
+ }
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import type {TokenValue, ComponentStyleMap} from './defineTheme';
24
+ import {deepMergeComponents} from './mergeComponents';
24
25
 
25
26
  /**
26
27
  * On-media theme overrides — same shape as the main theme but scoped
@@ -89,15 +90,21 @@ function resolveValue(value: TokenValue): string {
89
90
  /**
90
91
  * Resolve on-media overrides: merge user tokens with defaults,
91
92
  * pass through component overrides.
93
+ *
94
+ * `base` is the already-resolved surface of a theme being extended. It sits
95
+ * between the defaults and this theme's own input, so a child theme inherits
96
+ * the surface customizations of the theme it extends instead of silently
97
+ * reverting them to the defaults.
92
98
  */
93
99
  export function resolveOnMedia(
94
100
  surface: 'dark' | 'light',
95
101
  input?: OnMediaOverrides,
102
+ base?: ResolvedOnMedia,
96
103
  ): ResolvedOnMedia {
97
104
  const defaults =
98
105
  surface === 'dark' ? defaultOnDarkTokens : defaultOnLightTokens;
99
106
 
100
- const tokens = {...defaults};
107
+ const tokens = {...defaults, ...base?.tokens};
101
108
 
102
109
  if (input?.tokens) {
103
110
  for (const [key, value] of Object.entries(input.tokens)) {
@@ -109,6 +116,6 @@ export function resolveOnMedia(
109
116
 
110
117
  return {
111
118
  tokens,
112
- components: input?.components,
119
+ components: deepMergeComponents(base?.components, input?.components),
113
120
  };
114
121
  }