@astryxdesign/core 0.4.1 → 0.4.2-canary.356d2f9

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 (201) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/Avatar/Avatar.d.ts +4 -1
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +19 -23
  5. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  6. package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
  7. package/dist/Button/Button.d.ts.map +1 -1
  8. package/dist/Button/Button.js +9 -7
  9. package/dist/Chat/ChatMessage.d.ts +7 -0
  10. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  11. package/dist/Chat/ChatMessageBubble.d.ts +13 -1
  12. package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
  13. package/dist/Chat/ChatMessageBubble.js +19 -1
  14. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  15. package/dist/CheckboxInput/CheckboxInput.js +5 -0
  16. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
  17. package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
  18. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  19. package/dist/DateTimeInput/DateTimeInput.js +2 -2
  20. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  21. package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
  22. package/dist/HoverCard/HoverCard.d.ts +1 -1
  23. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/HoverCard.js +5 -13
  25. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  26. package/dist/HoverCard/useHoverCard.js +6 -5
  27. package/dist/InputGroup/groupStyles.d.ts.map +1 -1
  28. package/dist/InputGroup/groupStyles.js +7 -2
  29. package/dist/Layer/layerHost.d.ts +24 -0
  30. package/dist/Layer/layerHost.d.ts.map +1 -0
  31. package/dist/Layer/layerHost.js +79 -0
  32. package/dist/Layer/useLayer.d.ts +14 -6
  33. package/dist/Layer/useLayer.d.ts.map +1 -1
  34. package/dist/Layer/useLayer.js +158 -30
  35. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  36. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  37. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  38. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  39. package/dist/RadioList/RadioListItem.js +5 -0
  40. package/dist/SideNav/SideNav.d.ts +7 -9
  41. package/dist/SideNav/SideNav.d.ts.map +1 -1
  42. package/dist/SideNav/SideNav.js +32 -5
  43. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  44. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  45. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  46. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  47. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  48. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  49. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  50. package/dist/SideNav/SideNavHeading.js +89 -32
  51. package/dist/SideNav/SideNavItem.d.ts +7 -2
  52. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  53. package/dist/SideNav/SideNavItem.js +119 -75
  54. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  55. package/dist/SideNav/SideNavSection.js +7 -16
  56. package/dist/SideNav/index.d.ts +1 -1
  57. package/dist/SideNav/index.d.ts.map +1 -1
  58. package/dist/Slider/Slider.d.ts.map +1 -1
  59. package/dist/Slider/Slider.js +56 -15
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -0
  62. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  63. package/dist/Thumbnail/Thumbnail.js +5 -0
  64. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  65. package/dist/TopNav/TopNavHeading.js +14 -6
  66. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  67. package/dist/TopNav/TopNavMegaMenu.js +25 -93
  68. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  69. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  70. package/dist/TopNav/TopNavMenu.js +10 -5
  71. package/dist/astryx.css +40 -8
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/containerReveal.stylex.d.ts +71 -4
  75. package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
  76. package/dist/hooks/containerReveal.stylex.js +57 -5
  77. package/dist/hooks/index.d.ts +2 -2
  78. package/dist/hooks/index.d.ts.map +1 -1
  79. package/dist/hooks/index.js +1 -1
  80. package/dist/hooks/useContainerReveal.d.ts +60 -3
  81. package/dist/hooks/useContainerReveal.d.ts.map +1 -1
  82. package/dist/hooks/useContainerReveal.js +27 -5
  83. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  84. package/dist/hooks/useFocusTrap.js +16 -2
  85. package/dist/hooks/useMenuHover.d.ts +50 -5
  86. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  87. package/dist/hooks/useMenuHover.js +171 -51
  88. package/dist/theme/defineTheme.d.ts +16 -5
  89. package/dist/theme/defineTheme.d.ts.map +1 -1
  90. package/dist/theme/defineTheme.js +44 -48
  91. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  92. package/dist/theme/derivedVarRegistry.js +7 -0
  93. package/dist/theme/expandColorScale.d.ts.map +1 -1
  94. package/dist/theme/expandColorScale.js +1 -0
  95. package/dist/theme/expandMotionScale.d.ts +1 -0
  96. package/dist/theme/expandMotionScale.d.ts.map +1 -1
  97. package/dist/theme/expandMotionScale.js +1 -0
  98. package/dist/theme/expandRadiusScale.d.ts +1 -0
  99. package/dist/theme/expandRadiusScale.d.ts.map +1 -1
  100. package/dist/theme/expandRadiusScale.js +1 -0
  101. package/dist/theme/expandTypeScale.d.ts +1 -0
  102. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  103. package/dist/theme/expandTypeScale.js +1 -0
  104. package/dist/theme/mergeComponents.d.ts +20 -0
  105. package/dist/theme/mergeComponents.d.ts.map +1 -0
  106. package/dist/theme/mergeComponents.js +56 -0
  107. package/dist/theme/onMediaTokens.d.ts +6 -1
  108. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  109. package/dist/theme/onMediaTokens.js +11 -3
  110. package/dist/theme/tokens.stylex.d.ts +3 -1
  111. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  112. package/dist/theme/tokens.stylex.js +3 -1
  113. package/locales/en.json +84 -0
  114. package/locales/pseudo.json +63 -0
  115. package/package.json +3 -3
  116. package/src/Avatar/Avatar.doc.mjs +5 -2
  117. package/src/Avatar/Avatar.test.tsx +51 -0
  118. package/src/Avatar/Avatar.tsx +30 -20
  119. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  120. package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
  121. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
  122. package/src/Button/Button.tsx +5 -3
  123. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  124. package/src/Card/Card.doc.mjs +2 -0
  125. package/src/Chat/Chat.doc.mjs +4 -1
  126. package/src/Chat/ChatMessage.doc.mjs +3 -3
  127. package/src/Chat/ChatMessage.tsx +7 -0
  128. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  129. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  130. package/src/Chat/ChatMessageBubble.tsx +25 -0
  131. package/src/CheckboxInput/CheckboxInput.tsx +20 -0
  132. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  133. package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
  134. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  135. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
  136. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
  137. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  138. package/src/HoverCard/HoverCard.test.tsx +283 -55
  139. package/src/HoverCard/HoverCard.tsx +5 -13
  140. package/src/HoverCard/useHoverCard.tsx +8 -11
  141. package/src/InputGroup/groupStyles.ts +7 -8
  142. package/src/Item/Item.doc.mjs +4 -0
  143. package/src/Layer/layerHost.test.ts +99 -0
  144. package/src/Layer/layerHost.ts +141 -0
  145. package/src/Layer/useLayer.doc.mjs +14 -4
  146. package/src/Layer/useLayer.test.tsx +332 -7
  147. package/src/Layer/useLayer.tsx +235 -36
  148. package/src/MobileNav/MobileNav.doc.mjs +7 -0
  149. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  150. package/src/RadioList/RadioListItem.tsx +20 -0
  151. package/src/SideNav/SideNav.doc.mjs +13 -4
  152. package/src/SideNav/SideNav.test.tsx +858 -2
  153. package/src/SideNav/SideNav.tsx +37 -16
  154. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  155. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  156. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  157. package/src/SideNav/SideNavHeading.tsx +53 -25
  158. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  159. package/src/SideNav/SideNavItem.tsx +97 -84
  160. package/src/SideNav/SideNavSection.tsx +6 -20
  161. package/src/SideNav/index.ts +2 -0
  162. package/src/Slider/Slider.test.tsx +69 -33
  163. package/src/Slider/Slider.tsx +65 -13
  164. package/src/Switch/Switch.tsx +20 -0
  165. package/src/TabList/TabList.doc.mjs +3 -0
  166. package/src/Text/Text.doc.mjs +1 -1
  167. package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
  168. package/src/Thumbnail/Thumbnail.tsx +10 -0
  169. package/src/Timestamp/Timestamp.test.tsx +40 -22
  170. package/src/Toolbar/Toolbar.test.tsx +14 -14
  171. package/src/TopNav/TopNav.test.tsx +86 -1
  172. package/src/TopNav/TopNavHeading.tsx +21 -14
  173. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  174. package/src/TopNav/TopNavMegaMenu.tsx +29 -105
  175. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  176. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  177. package/src/TopNav/TopNavMenu.tsx +14 -4
  178. package/src/TreeList/TreeList.doc.mjs +1 -0
  179. package/src/hooks/containerReveal.stylex.ts +140 -9
  180. package/src/hooks/index.ts +6 -1
  181. package/src/hooks/useContainerReveal.doc.mjs +11 -5
  182. package/src/hooks/useContainerReveal.test.tsx +70 -3
  183. package/src/hooks/useContainerReveal.ts +86 -7
  184. package/src/hooks/useFocusTrap.test.tsx +16 -0
  185. package/src/hooks/useFocusTrap.ts +20 -2
  186. package/src/hooks/useMenuHover.test.tsx +364 -0
  187. package/src/hooks/useMenuHover.ts +243 -47
  188. package/src/theme/defineTheme.test.ts +127 -0
  189. package/src/theme/defineTheme.ts +57 -51
  190. package/src/theme/derivedVarRegistry.test.ts +78 -12
  191. package/src/theme/derivedVarRegistry.ts +4 -0
  192. package/src/theme/expandColorScale.ts +1 -0
  193. package/src/theme/expandMotionScale.ts +1 -0
  194. package/src/theme/expandRadiusScale.ts +1 -0
  195. package/src/theme/expandTypeScale.ts +1 -0
  196. package/src/theme/mergeComponents.ts +59 -0
  197. package/src/theme/onMediaTokens.ts +9 -2
  198. package/src/theme/themingTargets.test.ts +152 -26
  199. package/src/theme/tokens.stylex.ts +3 -1
  200. package/src/theme/tokens.test.ts +12 -0
  201. package/src/theme/useTheme.test.tsx +18 -0
@@ -28,6 +28,12 @@
28
28
  * <App />
29
29
  * </Theme>
30
30
  * ```
31
+ *
32
+ * SYNC: `DefineThemeInput` is the theme surface. Adding, removing, or renaming
33
+ * a field means updating:
34
+ * - /packages/cli/assets/theme.template.ts (documents every field; the
35
+ * drift guard is scripts/check-theme-template.test.mjs)
36
+ * - /packages/cli/assets/docs/theme.doc.mjs (`astryx docs theme`)
31
37
  */
32
38
 
33
39
  import type {IconRegistry} from '../Icon/globalIconRegistry';
@@ -42,6 +48,7 @@ import {
42
48
  colorDefaults,
43
49
  spacingDefaults,
44
50
  sizeDefaults,
51
+ borderDefaults,
45
52
  focusDefaults,
46
53
  radiusDefaults,
47
54
  shadowDefaults,
@@ -65,6 +72,7 @@ import type {DomainTokenName} from './domainTokens';
65
72
  import {domainTokenDefaults} from './domainTokens';
66
73
  import type {SyntaxThemeDefinition} from './syntax';
67
74
  import {registerTheme} from './themeRegistry';
75
+ import {deepMergeComponents} from './mergeComponents';
68
76
 
69
77
  // =============================================================================
70
78
  // Types
@@ -75,6 +83,7 @@ export type CoreTokenName =
75
83
  | keyof typeof colorDefaults
76
84
  | keyof typeof spacingDefaults
77
85
  | keyof typeof sizeDefaults
86
+ | keyof typeof borderDefaults
78
87
  | keyof typeof focusDefaults
79
88
  | keyof typeof radiusDefaults
80
89
  | keyof typeof shadowDefaults
@@ -157,9 +166,14 @@ export interface DefineThemeInput {
157
166
  name: string;
158
167
 
159
168
  /**
160
- * Base theme to extend. When provided, the new theme starts with the
161
- * base theme's tokens, components, and fonts, then applies overrides
162
- * 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.
163
177
  *
164
178
  * Use this to create variant themes that customize only a few aspects
165
179
  * (e.g. icons, accent color) without re-specifying the full theme.
@@ -379,6 +393,7 @@ export const tokenDefaults: Record<string, string> = {
379
393
  ...colorDefaults,
380
394
  ...spacingDefaults,
381
395
  ...sizeDefaults,
396
+ ...borderDefaults,
382
397
  ...focusDefaults,
383
398
  ...radiusDefaults,
384
399
  ...shadowDefaults,
@@ -407,49 +422,6 @@ function resolveTokenValue(value: TokenValue): string {
407
422
  return value;
408
423
  }
409
424
 
410
- /**
411
- * Deep-merge two component style maps.
412
- * Properties in `overrides` take precedence over `base`.
413
- * This allows typeScale-generated rules to be overridden by explicit components.
414
- */
415
- function deepMergeComponents(
416
- base?: ComponentStyleMap,
417
- overrides?: ComponentStyleMap,
418
- ): ComponentStyleMap | undefined {
419
- if (!base && !overrides) {
420
- return undefined;
421
- }
422
- if (!base) {
423
- return overrides;
424
- }
425
- if (!overrides) {
426
- return base;
427
- }
428
-
429
- const result: ComponentStyleMap = {};
430
-
431
- // Start with all base entries
432
- for (const [component, rules] of Object.entries(base)) {
433
- result[component] = {...rules};
434
- }
435
-
436
- // Merge overrides on top
437
- for (const [component, rules] of Object.entries(overrides)) {
438
- if (!result[component]) {
439
- result[component] = {...rules};
440
- } else {
441
- for (const [key, styles] of Object.entries(rules)) {
442
- result[component][key] = {
443
- ...result[component][key],
444
- ...styles,
445
- };
446
- }
447
- }
448
- }
449
-
450
- return result;
451
- }
452
-
453
425
  /**
454
426
  * Resolve a FontWeight name to a var() reference.
455
427
  * Named weights map to var(--font-weight-*); raw values pass through.
@@ -482,6 +454,24 @@ function buildFontFamily(
482
454
  return quoted;
483
455
  }
484
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
+
485
475
  /**
486
476
  * Create an Astryx theme.
487
477
  *
@@ -495,7 +485,19 @@ function buildFontFamily(
495
485
  export function defineTheme(input: DefineThemeInput): DefinedTheme {
496
486
  const tokens: Record<string, string> = {};
497
487
 
498
- // 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
+ }
499
501
  const base = input.extends;
500
502
  if (base) {
501
503
  for (const [key, value] of Object.entries(base.tokens)) {
@@ -631,9 +633,10 @@ export function defineTheme(input: DefineThemeInput): DefinedTheme {
631
633
  components = deepMergeComponents(base.components, components);
632
634
  }
633
635
 
634
- // 4. Resolve on-media token overrides (defaults + user overrides)
635
- const __onDark = resolveOnMedia('dark', input.onDark);
636
- 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);
637
640
 
638
641
  // 5. Merge icons — input icons override base icons
639
642
  const icons =
@@ -654,7 +657,10 @@ export function defineTheme(input: DefineThemeInput): DefinedTheme {
654
657
  components,
655
658
  icons,
656
659
  indicators,
657
- __inputTokens: input.tokens,
660
+ __inputTokens:
661
+ base?.__inputTokens || input.tokens
662
+ ? {...base?.__inputTokens, ...input.tokens}
663
+ : undefined,
658
664
  __onDark,
659
665
  __onLight,
660
666
  };
@@ -10,6 +10,21 @@
10
10
  * 2. Doc check: verifies each var is documented in the doc file's vars[]
11
11
  * 3. Registry check: verifies themeable vars have derived[] entries that
12
12
  * match the registry
13
+ *
14
+ * Layers 1 and 3 each used to carry a hole that let real vars through:
15
+ *
16
+ * - The source scan skipped every `--_*` name outright, on the theory that a
17
+ * private var is "internal, not themeable". It is internal, but it is still
18
+ * documented — `theming.vars[]` takes `private: true` for exactly this
19
+ * (`--_card-radius`, `--_dropdown-menu-padding`), and the derived-var
20
+ * pipeline is what theme authors reach it through. Skipping the prefix meant
21
+ * 10 private vars across 12 (component, var) sites were declared in source
22
+ * and documented nowhere.
23
+ * - Layer 3 narrowed its verdict to vars matching `/radius|padding/`, so any
24
+ * var whose name did not happen to contain those two words was exempt from
25
+ * ever needing a derived[] mapping. That is now an explicit allowlist
26
+ * (VARS_WITHOUT_DERIVED_MAPPING) instead of a name heuristic: a new var must
27
+ * be given a derived[] entry or be added to the list on purpose.
13
28
  */
14
29
 
15
30
  import {describe, it, expect} from 'vitest';
@@ -75,7 +90,13 @@ const STRUCTURAL_VARS = new Set([
75
90
  /**
76
91
  * Extract component-specific CSS custom property names from a source file.
77
92
  * Matches patterns like '--_card-radius': or '--_chat-composer-padding':
78
- * Excludes structural/runtime vars and standard token vars (--color-*, --spacing-*, etc.).
93
+ * Excludes structural/runtime vars and standard token vars (--color-*,
94
+ * --spacing-*, etc.).
95
+ *
96
+ * Private (`--_*`) vars are INCLUDED. They are internal in the sense that a
97
+ * theme author does not set them directly, but they are still part of the
98
+ * documented theming surface (`theming.vars[]` with `private: true`) and are
99
+ * how derived[] entries connect a standard CSS property to a component.
79
100
  */
80
101
  function extractComponentVars(filePath: string): string[] {
81
102
  const content = readFileSync(filePath, 'utf-8');
@@ -101,10 +122,6 @@ function extractComponentVars(filePath: string): string[] {
101
122
  if (/^--(container-|layout-|edge-|component-)/.test(varName)) {
102
123
  continue;
103
124
  }
104
- // Skip private vars (--_ prefix = internal, not themeable)
105
- if (varName.startsWith('--_')) {
106
- continue;
107
- }
108
125
  vars.add(varName);
109
126
  }
110
127
  return [...vars];
@@ -190,6 +207,7 @@ const DIR_TO_REGISTRY_KEY: Record<string, string> = {
190
207
  Button: 'button',
191
208
  Card: 'card',
192
209
  Chat: 'chat',
210
+ ContextMenu: 'context-menu',
193
211
  Dialog: 'dialog',
194
212
  DropdownMenu: 'dropdown-menu',
195
213
  Field: 'field',
@@ -213,8 +231,55 @@ const CROSS_COMPONENT_VARS: Record<string, string[]> = {
213
231
  Carousel: ['--_button-radius'],
214
232
  Thumbnail: ['--_button-radius'],
215
233
  Chat: ['--_button-radius'],
234
+ // AvatarGroupOverflow sets the overlap for the Avatars it lays out; Avatar
235
+ // owns and documents it (and sets it itself when it is the group root).
236
+ AvatarGroup: ['--_avatar-group-overlap'],
237
+ // BreadcrumbItem tunes the DropdownMenu it opens.
238
+ Breadcrumbs: ['--_dropdown-menu-radius', '--_dropdown-menu-padding'],
239
+ // SelectableCard draws its selection ring through the Card shadow slot.
240
+ SelectableCard: ['--_card-ring'],
241
+ // Toolbar offsets the TabList indicator it hosts.
242
+ Toolbar: ['--_tab-indicator-bottom'],
243
+ // The destructive item variant recolors the Item it renders; Item owns,
244
+ // documents and reads both slots.
245
+ DropdownMenu: ['--_item-label-color', '--_item-description-color'],
216
246
  };
217
247
 
248
+ /**
249
+ * Documented vars that intentionally have NO derived[] entry — a theme author
250
+ * cannot reach them by writing a standard CSS property, only by targeting the
251
+ * component's own theming surface.
252
+ *
253
+ * This replaces a `/radius|padding/` name test that exempted every var whose
254
+ * name did not contain those words. Each entry is a deliberate classification,
255
+ * so a NEW var has to be argued into the list rather than slipping past on its
256
+ * name. The list should shrink over time, not grow.
257
+ */
258
+ const VARS_WITHOUT_DERIVED_MAPPING = new Set([
259
+ // No standard CSS property maps onto these — they are component behaviors.
260
+ '--button-focus-offset',
261
+ '--button-icon-only-aspect',
262
+ '--_avatar-group-overlap',
263
+ '--_codeblock-gutter-width',
264
+ '--_tab-indicator-bottom',
265
+ // Hit-area outset on a ::after overlay — `inset` on a pseudo-element is not
266
+ // a property a theme author sets on the component.
267
+ '--_thumbnail-hit-inset',
268
+ // Indentation and row-spacing metrics: --tree-list-indent is the authorable
269
+ // step, --_tree-indent the per-row distance TreeListItem computes from it.
270
+ // --tree-list-row-gap is applied as half a padding-block on each row wrapper,
271
+ // not as gap on the list, so no standard property on the tree-list target
272
+ // maps onto it; a theme sets the var directly.
273
+ '--tree-list-indent',
274
+ '--_tree-indent',
275
+ '--tree-list-row-gap',
276
+ // Composed into a single box-shadow list on the card, so neither maps 1:1
277
+ // onto boxShadow — setting one through a derived entry would clobber the
278
+ // other.
279
+ '--_card-elevation',
280
+ '--_card-ring',
281
+ ]);
282
+
218
283
  // ---------------------------------------------------------------------------
219
284
  // Tests
220
285
  // ---------------------------------------------------------------------------
@@ -262,19 +327,20 @@ describe('component CSS vars are documented and themeable', () => {
262
327
  return true;
263
328
  });
264
329
 
265
- // Filter to only vars that look like they map to CSS properties
266
- // (radius → borderRadius, padding → padding). Vars like
267
- // --button-press-scale or --button-disabled-opacity are
268
- // component-specific behaviors, not standard CSS property mappings.
269
- const themeableVars = missingDerived.filter(v =>
270
- /radius|padding/.test(v),
330
+ // Everything that is not explicitly classified as unmappable must have
331
+ // a derived[] entry. (This was a `/radius|padding/` name test, which
332
+ // exempted any var whose name lacked those words.)
333
+ const themeableVars = missingDerived.filter(
334
+ v => !VARS_WITHOUT_DERIVED_MAPPING.has(v),
271
335
  );
272
336
 
273
337
  expect(
274
338
  themeableVars,
275
339
  `${dir} has vars that should be themeable via derived[]: ${themeableVars.join(', ')}. ` +
276
340
  `Add derived[] entries in ${dir}.doc.mjs mapping standard CSS ` +
277
- `properties (borderRadius, padding) to these internal vars.`,
341
+ `properties (borderRadius, padding) to these internal vars — or, if ` +
342
+ `no standard property maps onto them, add them to ` +
343
+ `VARS_WITHOUT_DERIVED_MAPPING with the reason.`,
278
344
  ).toEqual([]);
279
345
  });
280
346
  }
@@ -57,6 +57,10 @@ export const derivedVarRegistry: Record<string, DerivedVarEntry[]> = {
57
57
  {property: 'borderRadius', vars: ['--_dialog-radius']},
58
58
  {property: 'padding', expand: 'container'},
59
59
  ],
60
+ 'context-menu': [
61
+ {property: 'borderRadius', vars: ['--_dropdown-menu-radius']},
62
+ {property: 'padding', vars: ['--_dropdown-menu-padding']},
63
+ ],
60
64
  'dropdown-menu': [
61
65
  {property: 'borderRadius', vars: ['--_dropdown-menu-radius']},
62
66
  {property: 'padding', vars: ['--_dropdown-menu-padding']},
@@ -28,6 +28,7 @@
28
28
  *
29
29
  * SYNC: When modified, update:
30
30
  * - /packages/core/src/theme/defineTheme.ts
31
+ * - /packages/cli/assets/theme.template.ts (the annotated field reference)
31
32
  */
32
33
 
33
34
  import {contrastRatio} from './contrast';
@@ -18,6 +18,7 @@
18
18
  * SYNC: When modified, update:
19
19
  * - /packages/core/src/theme/expandMotionScale.test.ts
20
20
  * - /packages/core/src/theme/defineTheme.ts
21
+ * - /packages/cli/assets/theme.template.ts (the annotated field reference)
21
22
  */
22
23
 
23
24
  // =============================================================================
@@ -22,6 +22,7 @@
22
22
  * SYNC: When modified, update:
23
23
  * - /packages/core/src/theme/expandRadiusScale.test.ts
24
24
  * - /packages/core/src/theme/defineTheme.ts
25
+ * - /packages/cli/assets/theme.template.ts (the annotated field reference)
25
26
  */
26
27
 
27
28
  // =============================================================================
@@ -44,6 +44,7 @@
44
44
  * SYNC: When modified, update:
45
45
  * - /packages/core/src/theme/expandTypeScale.test.ts
46
46
  * - /packages/core/src/theme/defineTheme.ts
47
+ * - /packages/cli/assets/theme.template.ts (the annotated field reference)
47
48
  */
48
49
 
49
50
  // =============================================================================
@@ -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
  }