@astryxdesign/core 0.4.2 → 0.4.3-canary.00bed29

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 (214) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +79 -45
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +3 -19
  5. package/dist/Banner/Banner.d.ts +9 -2
  6. package/dist/Banner/Banner.d.ts.map +1 -1
  7. package/dist/Banner/Banner.js +84 -33
  8. package/dist/Banner/index.d.ts +14 -12
  9. package/dist/Banner/index.d.ts.map +1 -1
  10. package/dist/Banner/index.js +10 -8
  11. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  12. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  13. package/dist/BottomSheet/BottomSheet.js +392 -0
  14. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  15. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  16. package/dist/BottomSheet/BottomSheetPanel.js +398 -0
  17. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  18. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  19. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  20. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  21. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  22. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  23. package/dist/BottomSheet/index.d.ts +11 -0
  24. package/dist/BottomSheet/index.d.ts.map +1 -0
  25. package/dist/BottomSheet/index.js +12 -0
  26. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  27. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  28. package/dist/BottomSheet/snapOffsets.js +130 -0
  29. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  30. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  31. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  32. package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
  33. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  34. package/dist/BottomSheet/useSheetGestures.js +942 -0
  35. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  36. package/dist/Chat/ChatComposerInput.js +3 -4
  37. package/dist/Chat/ChatTokenizedText.js +1 -1
  38. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  39. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  40. package/dist/ComplexSelector/ComplexSelector.js +98 -31
  41. package/dist/ComplexSelector/index.d.ts +2 -2
  42. package/dist/ComplexSelector/index.d.ts.map +1 -1
  43. package/dist/ComplexSelector/index.js +1 -1
  44. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  45. package/dist/ContextMenu/ContextMenu.js +4 -2
  46. package/dist/DateInput/DateInput.d.ts.map +1 -1
  47. package/dist/DateInput/DateInput.js +9 -1
  48. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  49. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  50. package/dist/Dialog/Dialog.d.ts.map +1 -1
  51. package/dist/Dialog/Dialog.js +2 -1
  52. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  53. package/dist/Lightbox/Lightbox.js +9 -3
  54. package/dist/Markdown/parser.d.ts.map +1 -1
  55. package/dist/Markdown/parser.js +55 -12
  56. package/dist/MobileNav/MobileNav.d.ts +12 -0
  57. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  58. package/dist/MobileNav/MobileNav.js +102 -30
  59. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  60. package/dist/MultiSelector/MultiSelector.js +21 -7
  61. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  62. package/dist/NumberInput/NumberInput.js +9 -1
  63. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  64. package/dist/PowerSearch/PowerSearch.js +4 -1
  65. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  66. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  67. package/dist/PowerSearch/formatFilterValue.js +2 -4
  68. package/dist/Selector/Selector.d.ts.map +1 -1
  69. package/dist/Selector/Selector.js +14 -3
  70. package/dist/Switch/Switch.d.ts.map +1 -1
  71. package/dist/Switch/Switch.js +5 -1
  72. package/dist/Table/columnUtils.d.ts.map +1 -1
  73. package/dist/Table/columnUtils.js +4 -1
  74. package/dist/TextArea/TextArea.d.ts +6 -3
  75. package/dist/TextArea/TextArea.d.ts.map +1 -1
  76. package/dist/TextArea/TextArea.js +17 -6
  77. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  78. package/dist/TimeInput/TimeInput.js +8 -1
  79. package/dist/Tokenizer/Tokenizer.js +11 -5
  80. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  81. package/dist/Tooltip/useTooltip.js +4 -1
  82. package/dist/TreeList/TreeList.js +2 -1
  83. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  84. package/dist/Typeahead/BaseTypeahead.js +5 -3
  85. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  86. package/dist/Typeahead/Typeahead.js +10 -1
  87. package/dist/astryx.css +25 -2
  88. package/dist/hooks/index.d.ts +8 -1
  89. package/dist/hooks/index.d.ts.map +1 -1
  90. package/dist/hooks/index.js +8 -1
  91. package/dist/hooks/useFocusTrap.d.ts +0 -11
  92. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  93. package/dist/hooks/useFocusTrap.js +1 -11
  94. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  95. package/dist/hooks/useInputStatusIcon.js +1 -0
  96. package/dist/index.d.ts +1 -0
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +1 -0
  99. package/dist/theme/defineTheme.d.ts +30 -7
  100. package/dist/theme/defineTheme.d.ts.map +1 -1
  101. package/dist/theme/defineTheme.js +36 -47
  102. package/dist/theme/expandColorScale.d.ts +17 -2
  103. package/dist/theme/expandColorScale.d.ts.map +1 -1
  104. package/dist/theme/expandColorScale.js +63 -33
  105. package/dist/theme/mergeComponents.d.ts +20 -0
  106. package/dist/theme/mergeComponents.d.ts.map +1 -0
  107. package/dist/theme/mergeComponents.js +56 -0
  108. package/dist/theme/onMediaTokens.d.ts +6 -1
  109. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  110. package/dist/theme/onMediaTokens.js +11 -3
  111. package/dist/utils/characters.d.ts +27 -0
  112. package/dist/utils/characters.d.ts.map +1 -0
  113. package/dist/utils/characters.js +83 -0
  114. package/dist/utils/ime.d.ts +5 -0
  115. package/dist/utils/ime.d.ts.map +1 -0
  116. package/dist/utils/ime.js +63 -0
  117. package/dist/utils/index.d.ts +2 -0
  118. package/dist/utils/index.d.ts.map +1 -1
  119. package/dist/utils/index.js +3 -1
  120. package/locales/en.json +48 -0
  121. package/locales/pseudo.json +36 -0
  122. package/package.json +9 -8
  123. package/src/Avatar/Avatar.test.tsx +6 -1
  124. package/src/Avatar/Avatar.tsx +3 -21
  125. package/src/Banner/Banner.doc.mjs +12 -10
  126. package/src/Banner/Banner.test.tsx +108 -0
  127. package/src/Banner/Banner.tsx +124 -38
  128. package/src/Banner/index.ts +15 -13
  129. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  130. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  131. package/src/BottomSheet/BottomSheet.tsx +545 -0
  132. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  133. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  134. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  135. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  137. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  138. package/src/BottomSheet/index.ts +15 -0
  139. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  140. package/src/BottomSheet/snapOffsets.ts +159 -0
  141. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  142. package/src/BottomSheet/useSheetGestures.test.ts +1014 -0
  143. package/src/BottomSheet/useSheetGestures.ts +1407 -0
  144. package/src/Chat/ChatComposerInput.tsx +3 -4
  145. package/src/Chat/ChatTokenizedText.tsx +1 -1
  146. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  147. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  148. package/src/ComplexSelector/ComplexSelector.tsx +156 -28
  149. package/src/ComplexSelector/index.ts +3 -1
  150. package/src/ContextMenu/ContextMenu.tsx +4 -2
  151. package/src/DateInput/DateInput.test.tsx +21 -0
  152. package/src/DateInput/DateInput.tsx +9 -1
  153. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  154. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  155. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  156. package/src/Dialog/Dialog.tsx +2 -1
  157. package/src/FileInput/FileInput.test.tsx +53 -9
  158. package/src/Lightbox/Lightbox.test.tsx +49 -28
  159. package/src/Lightbox/Lightbox.tsx +7 -3
  160. package/src/Markdown/parser.test.ts +53 -0
  161. package/src/Markdown/parser.ts +53 -12
  162. package/src/MobileNav/MobileNav.tsx +128 -37
  163. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  164. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  165. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  166. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  167. package/src/MultiSelector/MultiSelector.tsx +22 -8
  168. package/src/NumberInput/NumberInput.test.tsx +18 -0
  169. package/src/NumberInput/NumberInput.tsx +9 -1
  170. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  171. package/src/PowerSearch/PowerSearch.tsx +4 -1
  172. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  173. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  174. package/src/PowerSearch/formatFilterValue.ts +2 -4
  175. package/src/Selector/Selector.test.tsx +85 -26
  176. package/src/Selector/Selector.tsx +13 -4
  177. package/src/Switch/Switch.test.tsx +16 -0
  178. package/src/Switch/Switch.tsx +7 -0
  179. package/src/Table/Table.test.tsx +6 -0
  180. package/src/Table/columnUtils.ts +3 -1
  181. package/src/TextArea/TextArea.doc.mjs +1 -1
  182. package/src/TextArea/TextArea.test.tsx +72 -0
  183. package/src/TextArea/TextArea.tsx +26 -8
  184. package/src/TimeInput/TimeInput.test.tsx +23 -0
  185. package/src/TimeInput/TimeInput.tsx +8 -0
  186. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  187. package/src/Tokenizer/Tokenizer.tsx +5 -5
  188. package/src/Tooltip/useTooltip.tsx +4 -1
  189. package/src/TreeList/TreeList.doc.mjs +2 -2
  190. package/src/TreeList/TreeList.tsx +1 -1
  191. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  192. package/src/Typeahead/Typeahead.test.tsx +116 -7
  193. package/src/Typeahead/Typeahead.tsx +10 -1
  194. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  195. package/src/__tests__/stubMatchMedia.ts +95 -0
  196. package/src/hooks/index.ts +9 -5
  197. package/src/hooks/useFocusTrap.ts +1 -14
  198. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  199. package/src/hooks/useInputStatusIcon.tsx +7 -0
  200. package/src/index.ts +1 -0
  201. package/src/theme/defineTheme.test.ts +127 -0
  202. package/src/theme/defineTheme.ts +70 -55
  203. package/src/theme/expandColorScale.test.ts +138 -0
  204. package/src/theme/expandColorScale.ts +89 -44
  205. package/src/theme/extensibleAxes.test.ts +365 -0
  206. package/src/theme/mergeComponents.ts +59 -0
  207. package/src/theme/onMediaTokens.ts +9 -2
  208. package/src/utils/characters.test.ts +141 -0
  209. package/src/utils/characters.ts +88 -0
  210. package/src/utils/ime.test.ts +32 -0
  211. package/src/utils/ime.ts +69 -0
  212. package/src/utils/index.ts +4 -0
  213. package/dist/astryx.umd.js +0 -147
  214. package/dist/astryx.umd.js.map +0 -7
@@ -159,6 +159,25 @@ describe('useInputStatusIcon — no dangling aria-describedby (WCAG 1.3.1)', ()
159
159
  ).not.toBeInTheDocument();
160
160
  expectNoDanglingDescribedBy(container);
161
161
  });
162
+
163
+ it('keeps the tooltip status button interactive inside a non-interactive trailing slot', () => {
164
+ // TextArea positions its trailing slot as an absolute overlay with
165
+ // `pointer-events: none` (correct for the decorative spinner/icon it was
166
+ // built for). The focusable status button owns its own interactivity so
167
+ // hover still opens the tooltip there — keyboard focus always worked, the
168
+ // pointer path did not until the button set `pointer-events: auto`.
169
+ render(
170
+ <TextArea
171
+ label="Field"
172
+ value=""
173
+ onChange={() => {}}
174
+ status={{type: 'error', message: 'Message'}}
175
+ statusVariant="tooltip"
176
+ />,
177
+ );
178
+ const statusButton = screen.getByRole('button', {name: 'Error details'});
179
+ expect(statusButton).toHaveStyle({pointerEvents: 'auto'});
180
+ });
162
181
  });
163
182
 
164
183
  describe('useInputStatusIcon — input-status-icon theme target', () => {
@@ -82,6 +82,13 @@ const styles = stylex.create({
82
82
  background: 'none',
83
83
  color: 'inherit',
84
84
  cursor: 'pointer',
85
+ // Own the interactivity so the tooltip opens on hover even when the field
86
+ // renders this affordance inside a non-interactive trailing slot. TextArea
87
+ // positions its end slot as an absolute overlay with `pointer-events: none`
88
+ // (right for the decorative spinner/icon it was built for), which otherwise
89
+ // swallows this button's hover — keyboard focus still worked, pointer did
90
+ // not.
91
+ pointerEvents: 'auto',
85
92
  borderRadius: radiusVars['--radius-full'],
86
93
  // Not the shared offset: this button sits inside the field, and measured at
87
94
  // the standard 3px its ring crosses the field border.
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export * from './AvatarGroup';
22
22
  export * from './Badge';
23
23
  export * from './Banner';
24
24
  export * from './Blockquote';
25
+ export * from './BottomSheet';
25
26
  export * from './Breadcrumbs';
26
27
  export * from './Button';
27
28
  export * from './ButtonGroup';
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {describe, it, expect, vi} from 'vitest';
4
4
  import type {IconRegistry} from '../Icon/globalIconRegistry';
5
+ import type {DefinedTheme} from './defineTheme';
5
6
  import {defineTheme, generateThemeCSS, isDefinedTheme} from './defineTheme';
6
7
 
7
8
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
@@ -1126,4 +1127,130 @@ describe('defineTheme extends', () => {
1126
1127
  base.tokens['--font-size-base'],
1127
1128
  );
1128
1129
  });
1130
+
1131
+ it('inherits indicators when the child has none', () => {
1132
+ const indicator = (() => null) as unknown as NonNullable<
1133
+ DefinedTheme['indicators']
1134
+ >['check'];
1135
+ const base = defineTheme({name: 'base', indicators: {check: indicator}});
1136
+ const child = defineTheme({name: 'child', extends: base});
1137
+ expect(child.indicators?.check).toBe(indicator);
1138
+ });
1139
+
1140
+ it('inherits onDark token overrides from base theme', () => {
1141
+ const base = defineTheme({
1142
+ name: 'base',
1143
+ onDark: {tokens: {'--color-border': '#ffffff'}},
1144
+ });
1145
+ const child = defineTheme({name: 'child', extends: base});
1146
+ expect(child.__onDark?.tokens['--color-border']).toBe('#ffffff');
1147
+ });
1148
+
1149
+ it('inherits onLight token overrides from base theme', () => {
1150
+ const base = defineTheme({
1151
+ name: 'base',
1152
+ onLight: {tokens: {'--color-border': '#000000'}},
1153
+ });
1154
+ const child = defineTheme({name: 'child', extends: base});
1155
+ expect(child.__onLight?.tokens['--color-border']).toBe('#000000');
1156
+ });
1157
+
1158
+ it('lets the child override an inherited onDark token', () => {
1159
+ const base = defineTheme({
1160
+ name: 'base',
1161
+ onDark: {tokens: {'--color-border': '#ffffff', '--color-track': '#eee'}},
1162
+ });
1163
+ const child = defineTheme({
1164
+ name: 'child',
1165
+ extends: base,
1166
+ onDark: {tokens: {'--color-border': '#cccccc'}},
1167
+ });
1168
+ expect(child.__onDark?.tokens['--color-border']).toBe('#cccccc');
1169
+ expect(child.__onDark?.tokens['--color-track']).toBe('#eee');
1170
+ });
1171
+
1172
+ it('inherits and deep-merges onDark component overrides', () => {
1173
+ const base = defineTheme({
1174
+ name: 'base',
1175
+ onDark: {
1176
+ components: {
1177
+ card: {base: {borderColor: '#fff', backgroundColor: '#111'}},
1178
+ badge: {base: {color: '#fff'}},
1179
+ },
1180
+ },
1181
+ });
1182
+ const child = defineTheme({
1183
+ name: 'child',
1184
+ extends: base,
1185
+ onDark: {components: {card: {base: {borderColor: '#ccc'}}}},
1186
+ });
1187
+ expect(child.__onDark?.components?.card?.base).toEqual({
1188
+ borderColor: '#ccc',
1189
+ backgroundColor: '#111',
1190
+ });
1191
+ expect(child.__onDark?.components?.badge?.base).toEqual({color: '#fff'});
1192
+ });
1193
+
1194
+ it('inherits __inputTokens so inherited [light, dark] tuples survive', () => {
1195
+ const base = defineTheme({
1196
+ name: 'base',
1197
+ tokens: {'--color-accent': ['#111111', '#eeeeee']},
1198
+ });
1199
+ const child = defineTheme({
1200
+ name: 'child',
1201
+ extends: base,
1202
+ tokens: {'--color-border': '#cccccc'},
1203
+ });
1204
+ expect(child.__inputTokens?.['--color-accent']).toEqual([
1205
+ '#111111',
1206
+ '#eeeeee',
1207
+ ]);
1208
+ expect(child.__inputTokens?.['--color-border']).toBe('#cccccc');
1209
+ });
1210
+
1211
+ it('__inputTokens from the child win over the base', () => {
1212
+ const base = defineTheme({
1213
+ name: 'base',
1214
+ tokens: {'--color-accent': ['#111111', '#eeeeee']},
1215
+ });
1216
+ const child = defineTheme({
1217
+ name: 'child',
1218
+ extends: base,
1219
+ tokens: {'--color-accent': '#ff0000'},
1220
+ });
1221
+ expect(child.__inputTokens?.['--color-accent']).toBe('#ff0000');
1222
+ });
1223
+
1224
+ // The failure that motivated these: `extends` resolving to `undefined` (a
1225
+ // named import that silently missed — see the theme-build resolution tests)
1226
+ // used to inherit nothing and build a stylesheet that looked fine.
1227
+ it('throws when extends is present but undefined', () => {
1228
+ expect(() =>
1229
+ defineTheme({
1230
+ name: 'child',
1231
+ extends: undefined,
1232
+ tokens: {'--color-accent': '#ff0000'},
1233
+ }),
1234
+ ).toThrow(/extends/);
1235
+ });
1236
+
1237
+ it('throws when extends is not a theme', () => {
1238
+ expect(() =>
1239
+ defineTheme({
1240
+ name: 'child',
1241
+ // A module namespace object is the shape a bad import hands over.
1242
+ extends: {foo: 'bar'} as unknown as DefinedTheme,
1243
+ }),
1244
+ ).toThrow(/extends/);
1245
+ });
1246
+
1247
+ it('accepts a pre-built theme module as a base', () => {
1248
+ const built = {
1249
+ name: 'built-base',
1250
+ __built: true,
1251
+ tokens: {'--color-accent': '#111111'},
1252
+ } as DefinedTheme;
1253
+ const child = defineTheme({name: 'child', extends: built});
1254
+ expect(child.tokens['--color-accent']).toBe('#111111');
1255
+ });
1129
1256
  });
@@ -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.
@@ -242,20 +248,38 @@ export interface DefineThemeInput {
242
248
  */
243
249
  radius?: RadiusScaleConfig;
244
250
  /**
245
- * Color scale configuration. Generates color token overrides from a
246
- * single accent color using the HCT perceptual color model.
251
+ * Color scale configuration. Generates color token overrides from an
252
+ * accent seed using the HCT perceptual color model.
247
253
  *
248
254
  * Only generates tokens derivable from the accent — status colors,
249
255
  * categorical hues, and fixed tokens (on-dark/on-light) use defaults.
250
- * Explicit `tokens` entries always take precedence.
256
+ *
257
+ * `accent` accepts a single hex (same seed for both color schemes) or a
258
+ * `[light, dark]` tuple, matching `TokenValue`. With a tuple, the light
259
+ * scheme's full palette derives from the light seed and the dark
260
+ * scheme's from the dark seed.
251
261
  *
252
262
  * `accent` is optional — omit it for a neutral-only theme, which keeps
253
263
  * the default accent tokens and only themes the neutrals.
254
264
  *
265
+ * Precedence vs `tokens`: explicit `tokens` entries win over generated
266
+ * values, token by token. Because `--color-accent-muted`,
267
+ * `--color-text-accent` and `--color-icon-accent` are generated as
268
+ * `var(--color-accent)` references, a `tokens['--color-accent']`
269
+ * override re-points them at runtime. `--color-on-accent` does NOT
270
+ * follow: it is baked from the `color.accent` seed (a contrast
271
+ * computation CSS cannot express), so overriding the accent through
272
+ * `tokens` without also overriding `--color-on-accent` leaves the two
273
+ * out of sync. To re-seat the whole palette per scheme, prefer a tuple
274
+ * `color.accent` over the `tokens['--color-accent']` workaround.
275
+ *
255
276
  * @example
256
- * ```tsx
277
+ * ```
257
278
  * color: { accent: '#0064E0', neutralStyle: 'cool', contrast: 'standard' }
258
279
  *
280
+ * // Per-scheme accents — light palette from the first seed, dark from the second
281
+ * color: { accent: ['#0064E0', '#48CAE4'] }
282
+ *
259
283
  * // Neutral-only — accent tokens stay at their defaults
260
284
  * color: { neutralStyle: 'warm' }
261
285
  * ```
@@ -416,49 +440,6 @@ function resolveTokenValue(value: TokenValue): string {
416
440
  return value;
417
441
  }
418
442
 
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
443
  /**
463
444
  * Resolve a FontWeight name to a var() reference.
464
445
  * Named weights map to var(--font-weight-*); raw values pass through.
@@ -491,6 +472,24 @@ function buildFontFamily(
491
472
  return quoted;
492
473
  }
493
474
 
475
+ /**
476
+ * Describe a rejected `extends` value for the error message — enough to tell a
477
+ * missed import (`undefined`) from a module namespace or a plain object.
478
+ */
479
+ function describeBadBase(value: unknown): string {
480
+ if (value === undefined) {
481
+ return 'undefined';
482
+ }
483
+ if (value === null) {
484
+ return 'null';
485
+ }
486
+ if (typeof value !== 'object') {
487
+ return typeof value;
488
+ }
489
+ const keys = Object.keys(value);
490
+ return `an object with keys [${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', …' : ''}]`;
491
+ }
492
+
494
493
  /**
495
494
  * Create an Astryx theme.
496
495
  *
@@ -504,7 +503,19 @@ function buildFontFamily(
504
503
  export function defineTheme(input: DefineThemeInput): DefinedTheme {
505
504
  const tokens: Record<string, string> = {};
506
505
 
507
- // 0. Pre-seed from base theme when `extends` is provided (lowest precedence)
506
+ // 0. Pre-seed from base theme when `extends` is provided (lowest precedence).
507
+ // A base that is not a theme is refused rather than ignored: `extends` used
508
+ // to inherit nothing when its value was undefined, which is what a named
509
+ // import silently resolving to the wrong module hands over, and the theme
510
+ // then built into a plausible-looking stylesheet missing everything it was
511
+ // supposed to inherit.
512
+ if ('extends' in input && !isDefinedTheme(input.extends)) {
513
+ throw new Error(
514
+ `defineTheme("${input.name}"): \`extends\` must be a theme from defineTheme(), got ${describeBadBase(input.extends)}. ` +
515
+ `Check that the import naming your base theme resolves to its source and exports that name — ` +
516
+ `a generated \`<theme>.js\` artifact sitting next to the source exports \`<name>Theme\`, not the source's own export.`,
517
+ );
518
+ }
508
519
  const base = input.extends;
509
520
  if (base) {
510
521
  for (const [key, value] of Object.entries(base.tokens)) {
@@ -640,9 +651,10 @@ export function defineTheme(input: DefineThemeInput): DefinedTheme {
640
651
  components = deepMergeComponents(base.components, components);
641
652
  }
642
653
 
643
- // 4. Resolve on-media token overrides (defaults + user overrides)
644
- const __onDark = resolveOnMedia('dark', input.onDark);
645
- const __onLight = resolveOnMedia('light', input.onLight);
654
+ // 4. Resolve on-media token overrides (base's resolved surface, then
655
+ // defaults, then this theme's own overrides)
656
+ const __onDark = resolveOnMedia('dark', input.onDark, base?.__onDark);
657
+ const __onLight = resolveOnMedia('light', input.onLight, base?.__onLight);
646
658
 
647
659
  // 5. Merge icons — input icons override base icons
648
660
  const icons =
@@ -663,7 +675,10 @@ export function defineTheme(input: DefineThemeInput): DefinedTheme {
663
675
  components,
664
676
  icons,
665
677
  indicators,
666
- __inputTokens: input.tokens,
678
+ __inputTokens:
679
+ base?.__inputTokens || input.tokens
680
+ ? {...base?.__inputTokens, ...input.tokens}
681
+ : undefined,
667
682
  __onDark,
668
683
  __onLight,
669
684
  };
@@ -192,6 +192,144 @@ describe('expandColorScale — neutral-only themes (#2279)', () => {
192
192
  });
193
193
  });
194
194
 
195
+ describe('expandColorScale — tuple accent (#2279)', () => {
196
+ const LIGHT_SEED = '#B7410E';
197
+ const DARK_SEED = '#48CAE4';
198
+
199
+ it('accepts a [light, dark] tuple and generates the accent tokens', () => {
200
+ const tokens = expandColorScale({accent: [LIGHT_SEED, DARK_SEED]});
201
+ for (const key of ACCENT_TOKENS) {
202
+ expect(tokens).toHaveProperty(key);
203
+ }
204
+ expect(tokens['--color-accent']).toMatch(/^light-dark\(#/);
205
+ expect(tokens['--color-on-accent']).toMatch(/^light-dark\(#/);
206
+ });
207
+
208
+ it('a same-halves tuple matches the single-string expansion, token for token', () => {
209
+ // Pins the string path as the tuple fast path: introducing tuples must
210
+ // not re-color any existing single-accent theme.
211
+ const fromString = expandColorScale({accent: LIGHT_SEED});
212
+ const fromTuple = expandColorScale({accent: [LIGHT_SEED, LIGHT_SEED]});
213
+ expect(fromTuple).toEqual(fromString);
214
+ });
215
+
216
+ it('derives the light palette from the light seed and the dark palette from the dark seed', () => {
217
+ // Every generated light-dark() pair takes its light half from the light
218
+ // seed's palettes and its dark half from the dark seed's, so resolving
219
+ // each mode must agree with the matching single-accent expansion.
220
+ const tuple = defineTheme({
221
+ name: 'tuple',
222
+ color: {accent: [LIGHT_SEED, DARK_SEED]},
223
+ });
224
+ const fromLight = defineTheme({name: 'light', color: {accent: LIGHT_SEED}});
225
+ const fromDark = defineTheme({name: 'dark', color: {accent: DARK_SEED}});
226
+
227
+ const generatedKeys = Object.keys(
228
+ expandColorScale({accent: [LIGHT_SEED, DARK_SEED]}),
229
+ );
230
+ const tupleLight = resolveThemeTokens(tuple, {mode: 'light'});
231
+ const refLight = resolveThemeTokens(fromLight, {mode: 'light'});
232
+ const tupleDark = resolveThemeTokens(tuple, {mode: 'dark'});
233
+ const refDark = resolveThemeTokens(fromDark, {mode: 'dark'});
234
+ for (const key of generatedKeys) {
235
+ expect(tupleLight[key], `${key} (light)`).toBe(refLight[key]);
236
+ expect(tupleDark[key], `${key} (dark)`).toBe(refDark[key]);
237
+ }
238
+ });
239
+
240
+ it('differing seeds produce a dark half the light seed would not', () => {
241
+ // Regression guard: the dark half must not silently reuse the light seed.
242
+ const tuple = defineTheme({
243
+ name: 'tuple-differs',
244
+ color: {accent: [LIGHT_SEED, DARK_SEED]},
245
+ });
246
+ const single = defineTheme({
247
+ name: 'single',
248
+ color: {accent: LIGHT_SEED},
249
+ });
250
+ expect(
251
+ resolveThemeTokens(tuple, {mode: 'dark'})['--color-accent'],
252
+ ).not.toBe(resolveThemeTokens(single, {mode: 'dark'})['--color-accent']);
253
+ });
254
+
255
+ it('keeps the derived accent tokens as references with a tuple accent', () => {
256
+ const tokens = expandColorScale({accent: [LIGHT_SEED, DARK_SEED]});
257
+ expect(tokens['--color-text-accent']).toBe('var(--color-accent)');
258
+ expect(tokens['--color-icon-accent']).toBe('var(--color-accent)');
259
+ expect(tokens['--color-accent-muted']).toContain('var(--color-accent)');
260
+ });
261
+
262
+ it('honours neutralStyle with a tuple accent', () => {
263
+ const warm = expandColorScale({
264
+ accent: [LIGHT_SEED, DARK_SEED],
265
+ neutralStyle: 'warm',
266
+ });
267
+ const cool = expandColorScale({
268
+ accent: [LIGHT_SEED, DARK_SEED],
269
+ neutralStyle: 'cool',
270
+ });
271
+ expect(warm['--color-neutral']).not.toBe(cool['--color-neutral']);
272
+ });
273
+ });
274
+
275
+ describe('accent precedence — color config vs tokens overrides (#2279)', () => {
276
+ const CONFIG_ACCENT: [string, string] = ['#B7410E', '#48CAE4'];
277
+ const TOKEN_ACCENT: [string, string] = ['#AA0000', '#FF5555'];
278
+
279
+ it('tokens["--color-accent"] wins over the color-generated accent', () => {
280
+ const theme = defineTheme({
281
+ name: 'precedence-base',
282
+ color: {accent: CONFIG_ACCENT},
283
+ tokens: {'--color-accent': TOKEN_ACCENT},
284
+ });
285
+ expect(theme.tokens['--color-accent']).toBe(
286
+ `light-dark(${TOKEN_ACCENT[0]}, ${TOKEN_ACCENT[1]})`,
287
+ );
288
+ });
289
+
290
+ it('reference tokens follow the tokens override at runtime', () => {
291
+ const theme = defineTheme({
292
+ name: 'precedence-refs',
293
+ color: {accent: CONFIG_ACCENT},
294
+ tokens: {'--color-accent': TOKEN_ACCENT},
295
+ });
296
+ const light = resolveThemeTokens(theme, {mode: 'light'});
297
+ expect(light['--color-text-accent']).toBe(TOKEN_ACCENT[0]);
298
+ expect(light['--color-icon-accent']).toBe(TOKEN_ACCENT[0]);
299
+ const dark = resolveThemeTokens(theme, {mode: 'dark'});
300
+ expect(dark['--color-text-accent']).toBe(TOKEN_ACCENT[1]);
301
+ });
302
+
303
+ it('baked --color-on-accent stays derived from the color.accent seed', () => {
304
+ // Documented divergence: a tokens override of --color-accent does not
305
+ // re-derive --color-on-accent, which is a baked contrast computation
306
+ // against the color.accent seed. Consumers who override the accent via
307
+ // tokens must override --color-on-accent too (or pass the tuple through
308
+ // color.accent so the whole palette re-derives).
309
+ const overridden = defineTheme({
310
+ name: 'precedence-on-accent',
311
+ color: {accent: CONFIG_ACCENT},
312
+ tokens: {'--color-accent': TOKEN_ACCENT},
313
+ });
314
+ const generated = defineTheme({
315
+ name: 'generated-on-accent',
316
+ color: {accent: CONFIG_ACCENT},
317
+ });
318
+ expect(overridden.tokens['--color-on-accent']).toBe(
319
+ generated.tokens['--color-on-accent'],
320
+ );
321
+ });
322
+
323
+ it('an explicit --color-on-accent token wins over the generated one', () => {
324
+ const theme = defineTheme({
325
+ name: 'precedence-on-accent-explicit',
326
+ color: {accent: CONFIG_ACCENT},
327
+ tokens: {'--color-on-accent': '#FFFFFF'},
328
+ });
329
+ expect(theme.tokens['--color-on-accent']).toBe('#FFFFFF');
330
+ });
331
+ });
332
+
195
333
  describe('expandColorScale + defineTheme integration', () => {
196
334
  it('explicit token overrides win over generated values', () => {
197
335
  const theme = defineTheme({