@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
@@ -104,6 +104,20 @@ describe('AvatarGroupOverflow', () => {
104
104
  expect(overflow).toHaveTextContent('+5');
105
105
  });
106
106
 
107
+ it('applies the group size class to the overflow chip', () => {
108
+ render(
109
+ <AvatarGroup size="lg">
110
+ <Avatar name="Alice" />
111
+ <AvatarGroupOverflow count={5} />
112
+ </AvatarGroup>,
113
+ );
114
+
115
+ const overflow = screen.getByLabelText('5 more');
116
+ expect(overflow.className).toContain('astryx-avatar-group-overflow');
117
+ expect(overflow.className).toContain('lg');
118
+ expect(overflow).toHaveAttribute('data-size', 'lg');
119
+ });
120
+
107
121
  it('renders as button when onClick is provided', () => {
108
122
  render(
109
123
  <AvatarGroup>
@@ -146,6 +146,7 @@ export function AvatarGroupOverflow({
146
146
  }: AvatarGroupOverflowProps): ReactNode {
147
147
  const t = useTranslator();
148
148
  const group = useAvatarGroup();
149
+ const size = group?.size ?? 'md';
149
150
  const numericSize = group?.numericSize ?? 36;
150
151
  const overlap = group?.overlap ?? 0;
151
152
 
@@ -162,7 +163,7 @@ export function AvatarGroupOverflow({
162
163
  aria-label={label}
163
164
  data-avatar-item=""
164
165
  {...mergeProps(
165
- themeProps('avatar-group-overflow'),
166
+ themeProps('avatar-group-overflow', {size}),
166
167
  focusOutlineProps.focusVisible(
167
168
  styles.base,
168
169
  styles.button,
@@ -186,7 +187,7 @@ export function AvatarGroupOverflow({
186
187
  {...rest}
187
188
  aria-label={label}
188
189
  {...mergeProps(
189
- themeProps('avatar-group-overflow'),
190
+ themeProps('avatar-group-overflow', {size}),
190
191
  stylex.props(
191
192
  styles.base,
192
193
  styles.overlap,
@@ -438,8 +438,10 @@ const loadingStyles = stylex.create({
438
438
  *
439
439
  * Layers always carry the native `popover` attribute (useLayer.tsx), and a
440
440
  * popover is never an in-flow member — it is `display: none` until shown, then
441
- * promoted to the top layer. So "last member" is: no following element sibling
442
- * that isn't a popover.
441
+ * promoted to the top layer. Context layers also retain an inert `<template>`
442
+ * marker so they can re-resolve their JSX position. Neither element is a group
443
+ * member, so "last member" is: no following element sibling besides those two
444
+ * pieces of layer infrastructure.
443
445
  *
444
446
  * Reading it the other way round — marking the *buttons* and testing for a
445
447
  * marked sibling — is the trap: it silently reclassifies anything it doesn't
@@ -455,7 +457,7 @@ const loadingStyles = stylex.create({
455
457
  * The leading edge still uses `:first-child` — a member's button always precedes
456
458
  * its own layer, so the first button is genuinely `:first-child`.
457
459
  */
458
- const IS_LAST_ITEM = ':not(:has(~ *:not([popover])))';
460
+ const IS_LAST_ITEM = ':not(:has(~ *:not([popover]):not(template)))';
459
461
 
460
462
  const groupStyles = stylex.create({
461
463
  horizontal: {
@@ -202,9 +202,9 @@ describe('ButtonGroup', () => {
202
202
  // Trailing radius (issue #2508)
203
203
  //
204
204
  // The trailing end cap cannot be keyed off `:last-child`: members render an
205
- // invisible layer AFTER their button (tooltip'd Button, DropdownMenu), and
206
- // useLayer renders it inline rather than portaling it, so the layer steals the
207
- // slot. See IS_LAST_ITEM in Button.tsx.
205
+ // invisible layer infrastructure AFTER their button (tooltip'd Button,
206
+ // DropdownMenu), and useLayer renders a marker plus the layer inline when the
207
+ // host is safe, so they steal the slot. See IS_LAST_ITEM in Button.tsx.
208
208
  //
209
209
  // HOW THESE TESTS CATCH THE BUG
210
210
  // jsdom applies no StyleX CSS, so a DOM-only test cannot prove which rule
@@ -219,7 +219,9 @@ describe('ButtonGroup', () => {
219
219
  describe('trailing radius (#2508)', () => {
220
220
  /** The group members, in DOM order (excludes invisible layer siblings). */
221
221
  const items = (group: HTMLElement): Element[] =>
222
- Array.from(group.querySelectorAll(':scope > *:not([popover])'));
222
+ Array.from(
223
+ group.querySelectorAll(':scope > *:not([popover]):not(template)'),
224
+ );
223
225
 
224
226
  // -- Compiled CSS, read from the source -----------------------------------
225
227
 
@@ -375,6 +377,7 @@ describe('ButtonGroup', () => {
375
377
  // .stylex.ts file it compiles to a mangled selector like `[x13pbwiz]`
376
378
  // that matches nothing in the DOM.
377
379
  expect(selector).toContain('[popover]');
380
+ expect(selector).toContain('template');
378
381
  }
379
382
  },
380
383
  );
@@ -95,6 +95,8 @@ export const docs = {
95
95
  ],
96
96
  vars: [
97
97
  {name: '--_card-radius', description: 'Border radius of the card', default: 'var(--radius-container)', private: true},
98
+ {name: '--_card-elevation', description: 'Resting shadow of the card, set from the elevation prop. Composed into the card box-shadow list alongside --_card-ring rather than written as boxShadow directly, so a ring and an elevation can coexist.', default: '0 0 transparent', private: true},
99
+ {name: '--_card-ring', description: 'Inset ring drawn in the card box-shadow list. SelectableCard sets it to show selection without taking over the shadow.', default: '0 0 transparent', private: true},
98
100
  ],
99
101
  derived: [
100
102
  {property: 'borderRadius', vars: ['--_card-radius']},
@@ -67,6 +67,7 @@ export const docs = {
67
67
  { guidance: true, description: 'Use the group prop on bubbles (first, middle, last) when a single sender sends multiple consecutive messages; it tightens corner radius to visually connect them.' },
68
68
  { guidance: true, description: 'Use ChatSystemMessage with variant="divider" for date separators and default for inline status notices like joins, leaves, or topic changes.' },
69
69
  { guidance: true, description: 'Put name on the first bubble and metadata on the last bubble in a message so they align with the bubble\'s inline padding.' },
70
+ { guidance: true, description: 'Wrap custom in-message content (cards, attachments, citations) in a ghost bubble so it aligns with the bubble text column; add width="100%" when it should span the full message column instead of the default bubble width cap.' },
70
71
  { guidance: true, description: 'Provide an emptyState prop so new users see a clear prompt to start a conversation instead of a blank screen.' },
71
72
  { guidance: true, description: 'Use the ghost bubble variant for AI-style responses that show rich content like code blocks or markdown without a visible boundary.' },
72
73
  { guidance: false, description: 'Don\'t use ChatSystemMessage for sender content; it has no avatar, alignment, or bubble. Use ChatMessage with a sender role instead.' },
@@ -84,7 +85,7 @@ export const docs = {
84
85
  { name: 'Empty state', required: false, description: 'Centered placeholder shown when no messages exist. Use EmptyState for a consistent look.' },
85
86
  { name: 'Avatar', required: false, description: 'A sender avatar rendered beside the message. Typically Avatar with size="md". Hidden for system messages.' },
86
87
  { name: 'Name', required: false, description: 'Sender name above the message body. Place on the bubble when using bubbles, or on the message wrapper for raw content.' },
87
- { name: 'Content', required: true, description: 'The message body: one or more ChatMessageBubble elements, or any free-form ReactNode like images or tool calls.' },
88
+ { name: 'Content', required: true, description: 'The message body: one or more ChatMessageBubble elements, or any free-form ReactNode like images or tool calls. Wrap non-bubble content in a ghost bubble to align it with the bubble text column.' },
88
89
  { name: 'Metadata', required: false, description: 'Timestamp, delivery status, and footer actions below the message. Place on the last bubble or on the message wrapper.' },
89
90
  ],
90
91
  },
@@ -100,6 +101,7 @@ export const docsZh = {
100
101
  { guidance: true, description: '当同一发送者发送多条连续消息时,使用气泡的 group 属性(first、middle、last)来视觉上连接相关消息。' },
101
102
  { guidance: true, description: '使用 ChatSystemMessage 的 variant="divider" 作为日期分隔线,使用 default 作为内联状态通知。' },
102
103
  { guidance: true, description: '将 name 放在第一个气泡上,metadata 放在最后一个气泡上,使其与气泡的内边距对齐。' },
104
+ { guidance: true, description: '将自定义消息内容(卡片、附件、引用)包裹在 ghost 变体的气泡中,使其与气泡文本列对齐;需要占满消息列宽度时设置 width="100%"。' },
103
105
  { guidance: true, description: '提供 emptyState 属性,让新用户看到开始对话的提示,而不是空白屏幕。' },
104
106
  { guidance: true, description: '使用 ghost 气泡变体显示代码块或 Markdown 等富内容,无需可见边界。' },
105
107
  { guidance: false, description: '不要将 ChatSystemMessage 用于发送者内容,它没有头像、对齐或气泡。请使用带有发送者角色的 ChatMessage。' },
@@ -131,6 +133,7 @@ export const docsDense = {
131
133
  { guidance: true, description: 'Group prop on bubbles (first/middle/last) for consecutive same-sender messages; tightens corner radius.' },
132
134
  { guidance: true, description: 'SystemMessage: divider variant for date breaks, default for status notices.' },
133
135
  { guidance: true, description: 'Name on first bubble, metadata on last; aligns with bubble inline padding.' },
136
+ { guidance: true, description: 'Ghost bubble wraps custom content (cards/attachments); width="100%" spans full message column (default cap max(80%,280px)).' },
134
137
  { guidance: true, description: 'Provide emptyState so new users see a prompt, not a blank screen.' },
135
138
  { guidance: true, description: 'Ghost bubble variant for rich AI content like code/markdown without visible boundary.' },
136
139
  { guidance: false, description: 'SystemMessage for sender content: no avatar/alignment/bubble. Use ChatMessage instead.' },
@@ -17,7 +17,7 @@ export const docs = {
17
17
  {
18
18
  name: 'children',
19
19
  type: 'ReactNode',
20
- description: 'Free-form content: bubbles, asset lists, tool calls, images.',
20
+ description: 'Free-form content: bubbles, asset lists, tool calls, images. Custom (non-bubble) children render flush with the message edge; wrap them in a ghost ChatMessageBubble to align them with the bubble text column.',
21
21
  required: true,
22
22
  },
23
23
  {
@@ -67,7 +67,7 @@ export const docsZh = {
67
67
  description: '发送者上下文包装器,根据发送者角色处理头像、名称、元数据和对齐方式。',
68
68
  propDescriptions: {
69
69
  sender: '消息发送者,控制对齐和布局。',
70
- children: '自由内容:气泡、资源列表、工具调用、图片。',
70
+ children: '自由内容:气泡、资源列表、工具调用、图片。非气泡的自定义子内容默认与消息边缘齐平,可用 ghost 变体的 ChatMessageBubble 包裹使其与气泡文本列对齐。',
71
71
  avatar: '消息旁边渲染的头像元素。通常是 Avatar。',
72
72
  name: '消息正文上方渲染的发送者名称。当第一个子元素不是气泡时使用。',
73
73
  metadata: '消息正文下方渲染的元数据。当最后一个子元素不是气泡时使用。',
@@ -81,7 +81,7 @@ export const docsDense = {
81
81
  description: 'sender context wrapper; handles avatar+name+metadata+alignment by sender role',
82
82
  propDescriptions: {
83
83
  sender: 'who sent; controls alignment+layout',
84
- children: 'free-form: bubbles, assets, tool calls, images',
84
+ children: 'free-form: bubbles, assets, tool calls, images; wrap non-bubble content in a ghost bubble to align with bubble text column',
85
85
  avatar: 'avatar element beside msg; typically Avatar',
86
86
  name: 'sender name above body; use when first child is raw (not bubble)',
87
87
  displayName: 'sender name above body; use when first child is raw (not bubble)',
@@ -41,6 +41,13 @@ import {useTranslator} from '../i18n';
41
41
  export interface ChatMessageProps extends BaseProps<HTMLElement> {
42
42
  ref?: React.Ref<HTMLElement>;
43
43
  sender: ChatMessageSender;
44
+ /**
45
+ * Message body — bubbles, tool calls, images, or any free-form content.
46
+ * Custom (non-bubble) children render flush with the message edge; wrap
47
+ * them in a ghost bubble (`<ChatMessageBubble variant="ghost">`) to align
48
+ * them with the bubble's text column, and add `width="100%"` when they
49
+ * should span the full message column.
50
+ */
44
51
  children: ReactNode;
45
52
  avatar?: ReactNode;
46
53
  /**
@@ -45,6 +45,11 @@ export const docs = {
45
45
  type: "'first' | 'middle' | 'last'",
46
46
  description: 'Position within a multi-bubble group. Controls corner radius reduction on the sender side. Leave unset for standalone bubbles (full radius).',
47
47
  },
48
+ {
49
+ name: 'width',
50
+ type: 'SizeValue',
51
+ description: 'Width of the bubble (number = pixels, string = used as-is). When set, replaces the default max(80%, 280px) width cap. Combine with variant="ghost" to let custom content (an artifact card, attachments) span the full message column.',
52
+ },
48
53
  ],
49
54
  };
50
55
 
@@ -59,6 +64,7 @@ export const docsZh = {
59
64
  name: '气泡上方渲染的发送者名称,与气泡文本内边距对齐。用于消息中的第一个气泡。',
60
65
  metadata: '气泡下方渲染的元数据内容,与气泡文本内边距对齐。用于消息中的最后一个气泡。',
61
66
  group: '多气泡组中的位置。控制发送者侧的圆角缩减。',
67
+ width: '气泡宽度(数字 = 像素,字符串 = 按原样使用)。设置后替代默认的 max(80%, 280px) 宽度上限;与 ghost 变体组合可让自定义内容占满消息列宽度。',
62
68
  },
63
69
  };
64
70
 
@@ -74,5 +80,6 @@ export const docsDense = {
74
80
  displayName: 'sender name above bubble, aligned w/ bubble padding',
75
81
  metadata: 'metadata below bubble, aligned w/ bubble padding',
76
82
  group: 'position in multi-bubble group; controls corner radius reduction',
83
+ width: 'bubble width (number=px, string=as-is); replaces default max(80%,280px) cap; ghost+width="100%" spans full message column',
77
84
  },
78
85
  };
@@ -7,6 +7,16 @@ import {ChatMessageBubble} from './ChatMessageBubble';
7
7
  import {ChatMessageList} from './ChatMessageList';
8
8
  import {ChatMessageMetadata} from './ChatMessageMetadata';
9
9
 
10
+ /**
11
+ * Classes shared between two elements. StyleX emits one atomic class per
12
+ * style declaration, so two elements share a class exactly when they share
13
+ * a declaration — e.g. the same density paddingInline.
14
+ */
15
+ function sharedClasses(a: Element, b: Element): string[] {
16
+ const bClasses = new Set(Array.from(b.classList));
17
+ return Array.from(a.classList).filter(c => bClasses.has(c));
18
+ }
19
+
10
20
  describe('ChatMessageBubble', () => {
11
21
  it('renders children', () => {
12
22
  render(
@@ -29,9 +39,7 @@ describe('ChatMessageBubble', () => {
29
39
 
30
40
  it('defaults to assistant when no context', () => {
31
41
  render(
32
- <ChatMessageBubble data-testid="bubble">
33
- Standalone
34
- </ChatMessageBubble>,
42
+ <ChatMessageBubble data-testid="bubble">Standalone</ChatMessageBubble>,
35
43
  );
36
44
  const el = screen.getByTestId('bubble');
37
45
  expect(el.className).toContain('assistant');
@@ -41,9 +49,7 @@ describe('ChatMessageBubble', () => {
41
49
  render(
42
50
  <ChatMessageList density="compact">
43
51
  <ChatMessage sender="assistant">
44
- <ChatMessageBubble data-testid="bubble">
45
- Compact
46
- </ChatMessageBubble>
52
+ <ChatMessageBubble data-testid="bubble">Compact</ChatMessageBubble>
47
53
  </ChatMessage>
48
54
  </ChatMessageList>,
49
55
  );
@@ -59,6 +65,89 @@ describe('ChatMessageBubble', () => {
59
65
  );
60
66
  expect(screen.getByTestId('my-bubble')).toBeTruthy();
61
67
  });
68
+
69
+ it('ghost variant aligns custom content with the bubble text column (#2574)', () => {
70
+ // Repro from the issue: a raw child renders flush with the message
71
+ // edge — it carries none of the inset the bubble's name slot gets.
72
+ const {container} = render(
73
+ <ChatMessage sender="assistant">
74
+ <ChatMessageBubble name="Navi">Hello</ChatMessageBubble>
75
+ <div data-testid="raw">Artifact card</div>
76
+ <ChatMessageBubble variant="ghost" data-testid="ghost">
77
+ Artifact card
78
+ </ChatMessageBubble>
79
+ </ChatMessage>,
80
+ );
81
+ const nameSlot = container.querySelector('[data-chat-name]')!;
82
+ expect(nameSlot).toBeTruthy();
83
+
84
+ // Unwrapped custom content: no shared inset — the misalignment case.
85
+ expect(sharedClasses(screen.getByTestId('raw'), nameSlot)).toEqual([]);
86
+
87
+ // Ghost-wrapped content: shares the bubble slot's paddingInline
88
+ // declaration, so its text column matches the filled bubble exactly.
89
+ expect(
90
+ sharedClasses(screen.getByTestId('ghost'), nameSlot).length,
91
+ ).toBeGreaterThan(0);
92
+ });
93
+
94
+ it('ghost inset tracks message density', () => {
95
+ const renderAtDensity = (density: 'balanced' | 'spacious') => {
96
+ const {container} = render(
97
+ <ChatMessage sender="assistant" density={density}>
98
+ <ChatMessageBubble name="Navi">Hello</ChatMessageBubble>
99
+ <ChatMessageBubble variant="ghost" data-testid={`ghost-${density}`}>
100
+ Card
101
+ </ChatMessageBubble>
102
+ </ChatMessage>,
103
+ );
104
+ return sharedClasses(
105
+ screen.getByTestId(`ghost-${density}`),
106
+ container.querySelector('[data-chat-name]')!,
107
+ );
108
+ };
109
+
110
+ const balancedInset = renderAtDensity('balanced');
111
+ const spaciousInset = renderAtDensity('spacious');
112
+
113
+ // Both densities align with their own bubble's slot padding...
114
+ expect(balancedInset.length).toBeGreaterThan(0);
115
+ expect(spaciousInset.length).toBeGreaterThan(0);
116
+ // ...and spacious uses a wider inset than balanced.
117
+ expect(spaciousInset).not.toEqual(balancedInset);
118
+ });
119
+
120
+ it('width prop replaces the default width cap', () => {
121
+ render(
122
+ <ChatMessage sender="assistant">
123
+ <ChatMessageBubble data-testid="capped">Text</ChatMessageBubble>
124
+ <ChatMessageBubble variant="ghost" width="100%" data-testid="full">
125
+ Card
126
+ </ChatMessageBubble>
127
+ </ChatMessage>,
128
+ );
129
+ const capped = screen.getByTestId('capped');
130
+ const full = screen.getByTestId('full');
131
+
132
+ // Default bubbles keep the cap; a width bubble replaces it with none.
133
+ expect(getComputedStyle(capped).maxWidth).toMatch(/^max\(80%,\s*280px\)$/);
134
+ expect(getComputedStyle(full).maxWidth).toBe('none');
135
+ // The dynamic width value is set on the element (string passes through).
136
+ expect(full.getAttribute('style')).toContain('100%');
137
+ });
138
+
139
+ it('numeric width is treated as pixels', () => {
140
+ render(
141
+ <ChatMessage sender="assistant">
142
+ <ChatMessageBubble width={420} data-testid="fixed">
143
+ Artifact
144
+ </ChatMessageBubble>
145
+ </ChatMessage>,
146
+ );
147
+ expect(screen.getByTestId('fixed').getAttribute('style')).toContain(
148
+ '420px',
149
+ );
150
+ });
62
151
  });
63
152
 
64
153
  describe('ChatMessageMetadata', () => {
@@ -15,6 +15,9 @@
15
15
  * - If you use bubbles on one side (e.g. assistant), use them consistently
16
16
  * for all messages on that side. Use `ghost` variant for content that
17
17
  * needs alignment without a visual boundary.
18
+ * - For custom content (cards, attachments, citations) that should span
19
+ * the full message column instead of the default width cap, combine
20
+ * `ghost` with `width="100%"` (#2574).
18
21
  * - Put `name` on the first bubble in a message, `metadata` on the last.
19
22
  * - For unbubbled messages, use ChatMessage's `name` and `metadata`
20
23
  * props instead.
@@ -36,6 +39,7 @@ import {
36
39
  } from '../theme/tokens.stylex';
37
40
  import {useChatMessageContext} from './ChatContext';
38
41
  import {mergeProps} from '../utils';
42
+ import type {SizeValue} from '../utils/types';
39
43
  import type {BaseProps} from '../BaseProps';
40
44
  import {themeProps} from '../utils/themeProps';
41
45
 
@@ -83,6 +87,15 @@ export interface ChatMessageBubbleProps extends BaseProps<HTMLDivElement> {
83
87
  * Leave unset for standalone bubbles (full radius).
84
88
  */
85
89
  group?: 'first' | 'middle' | 'last';
90
+
91
+ /**
92
+ * Width of the bubble.
93
+ * Numbers are treated as pixels, strings are used as-is (e.g. "100%").
94
+ * When set, replaces the default `max(80%, 280px)` width cap; leave unset
95
+ * to keep the cap. Combine with `variant="ghost"` to let custom content
96
+ * (an artifact card, attachments) span the full message column.
97
+ */
98
+ width?: SizeValue;
86
99
  }
87
100
 
88
101
  // =============================================================================
@@ -189,6 +202,16 @@ const styles = stylex.create({
189
202
  },
190
203
  });
191
204
 
205
+ // Dynamic styles for sizing props
206
+ const dynamicStyles = stylex.create({
207
+ sizing: (width: SizeValue) => ({
208
+ width,
209
+ // An explicit width replaces the default cap — a full-column or
210
+ // fixed-width bubble shouldn't also be clamped by max(80%, 280px).
211
+ maxWidth: 'none',
212
+ }),
213
+ });
214
+
192
215
  // =============================================================================
193
216
  // Component
194
217
  // =============================================================================
@@ -216,6 +239,7 @@ export function ChatMessageBubble({
216
239
  name,
217
240
  metadata,
218
241
  group,
242
+ width,
219
243
  xstyle,
220
244
  className,
221
245
  style: styleProp,
@@ -292,6 +316,7 @@ export function ChatMessageBubble({
292
316
  paddingStyle,
293
317
  variant === 'ghost' && styles.paddingBlockNone,
294
318
  groupStyle,
319
+ width != null && dynamicStyles.sizing(width),
295
320
  xstyle,
296
321
  ),
297
322
  className,
@@ -81,6 +81,26 @@ const styles = stylex.create({
81
81
  opacity: 0,
82
82
  cursor: 'pointer',
83
83
  zIndex: 1,
84
+ minInlineSize: {
85
+ default: null,
86
+ '@media (pointer: coarse)': '24px',
87
+ },
88
+ minBlockSize: {
89
+ default: null,
90
+ '@media (pointer: coarse)': '24px',
91
+ },
92
+ insetBlockStart: {
93
+ default: null,
94
+ '@media (pointer: coarse)': '50%',
95
+ },
96
+ insetInlineStart: {
97
+ default: null,
98
+ '@media (pointer: coarse)': '50%',
99
+ },
100
+ transform: {
101
+ default: null,
102
+ '@media (pointer: coarse)': 'translate(-50%, -50%)',
103
+ },
84
104
  },
85
105
  inputDisabled: {
86
106
  cursor: 'not-allowed',
@@ -146,6 +146,9 @@ export const docs = {
146
146
  {className: 'astryx-codeblock-title', visualProps: ['size', 'language']},
147
147
  {className: 'astryx-codeblock-copy-button'},
148
148
  ],
149
+ vars: [
150
+ {name: '--_codeblock-gutter-width', description: 'Width of the line-number gutter, computed from the digit count of the last line so the code column starts at a stable offset.', default: '2ch', private: true},
151
+ ],
149
152
  },
150
153
  usage: {
151
154
  description: 'CodeBlock renders syntax-highlighted code with line numbers, a copy button, and optional collapsible sections. Use CodeBlock for multi-line snippets like source files, terminal commands, and configuration examples. Use Code for inline references to function names, variables, or CLI flags within body text.',
@@ -24,6 +24,7 @@ import {
24
24
  } from '../theme/tokens.stylex';
25
25
  import {Kbd} from '../Kbd';
26
26
  import {themeProps} from '../utils/themeProps';
27
+ import {useTranslator} from '../i18n';
27
28
 
28
29
  const styles = stylex.create({
29
30
  footer: {
@@ -85,6 +86,8 @@ export function CommandPaletteFooter({
85
86
  style,
86
87
  ...props
87
88
  }: CommandPaletteFooterProps) {
89
+ const t = useTranslator();
90
+
88
91
  return (
89
92
  <div
90
93
  ref={ref}
@@ -100,15 +103,15 @@ export function CommandPaletteFooter({
100
103
  <span {...stylex.props(styles.hint)}>
101
104
  <Kbd keys="up" />
102
105
  <Kbd keys="down" />
103
- Navigate
106
+ {t('@astryx.commandPalette.footer.navigate')}
104
107
  </span>
105
108
  <span {...stylex.props(styles.hint)}>
106
109
  <Kbd keys="enter" />
107
- Select
110
+ {t('@astryx.commandPalette.footer.select')}
108
111
  </span>
109
112
  <span {...stylex.props(styles.hint)}>
110
113
  <Kbd keys="escape" />
111
- Close
114
+ {t('@astryx.commandPalette.footer.close')}
112
115
  </span>
113
116
  </>
114
117
  )}
@@ -594,10 +594,12 @@ export function DateTimeInput({
594
594
 
595
595
  const resolvedTimePlaceholder = useMemo(() => {
596
596
  if (isTimeFocused && !timeDisplayValue) {
597
- return hourFormat === '12h' ? 'e.g., 2:30 PM' : 'e.g., 14:30';
597
+ return hourFormat === '12h'
598
+ ? t('@astryx.dateTimeInput.timeHint12h')
599
+ : t('@astryx.dateTimeInput.timeHint24h');
598
600
  }
599
601
  return timePlaceholder;
600
- }, [isTimeFocused, timeDisplayValue, hourFormat, timePlaceholder]);
602
+ }, [isTimeFocused, timeDisplayValue, hourFormat, timePlaceholder, t]);
601
603
 
602
604
  // --- Unified change handler ---
603
605
  const fireChange = useCallback(
@@ -8,7 +8,7 @@
8
8
 
9
9
  import {describe, it, expect, vi, beforeEach} from 'vitest';
10
10
  import {useState} from 'react';
11
- import {render, screen, waitFor, fireEvent} from '@testing-library/react';
11
+ import {render, screen, waitFor, fireEvent, act} from '@testing-library/react';
12
12
  import userEvent from '@testing-library/user-event';
13
13
  import {DropdownMenu} from './DropdownMenu';
14
14
  import {DropdownMenuItem} from './DropdownMenuItem';
@@ -708,3 +708,74 @@ describe('DropdownMenuSubMenu theming slots', () => {
708
708
  ).toBeInTheDocument();
709
709
  });
710
710
  });
711
+
712
+ describe('DropdownMenuSubMenu hover/click guard', () => {
713
+ it('keeps the flyout open when a hover-open is immediately clicked', async () => {
714
+ vi.useFakeTimers({shouldAdvanceTime: true});
715
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
716
+ render(<MoveMenu />);
717
+
718
+ await user.click(screen.getByRole('button', {name: /Actions/}));
719
+ const trigger = screen.getByRole('menuitem', {
720
+ name: /Move to/,
721
+ hidden: true,
722
+ });
723
+
724
+ await user.hover(trigger);
725
+ act(() => {
726
+ vi.advanceTimersByTime(300);
727
+ });
728
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
729
+
730
+ await user.click(trigger);
731
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
732
+ expect(
733
+ screen.getByRole('menuitem', {name: 'Folder A', hidden: true}),
734
+ ).toHaveFocus();
735
+
736
+ vi.useRealTimers();
737
+ });
738
+
739
+ it('closes on a click that lands well after the hover-open', async () => {
740
+ vi.useFakeTimers({shouldAdvanceTime: true});
741
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
742
+ render(<MoveMenu />);
743
+
744
+ await user.click(screen.getByRole('button', {name: /Actions/}));
745
+ const trigger = screen.getByRole('menuitem', {
746
+ name: /Move to/,
747
+ hidden: true,
748
+ });
749
+
750
+ await user.hover(trigger);
751
+ act(() => {
752
+ vi.advanceTimersByTime(300);
753
+ });
754
+ act(() => {
755
+ vi.advanceTimersByTime(1200);
756
+ });
757
+
758
+ await user.click(trigger);
759
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
760
+
761
+ vi.useRealTimers();
762
+ });
763
+
764
+ it('moves focus into the flyout synchronously on a click-open', async () => {
765
+ const user = userEvent.setup();
766
+ render(<MoveMenu />);
767
+
768
+ await user.click(screen.getByRole('button', {name: /Actions/}));
769
+ const trigger = screen.getByRole('menuitem', {
770
+ name: /Move to/,
771
+ hidden: true,
772
+ });
773
+
774
+ await user.click(trigger);
775
+
776
+ // No waitFor: a deferred (rAF) focus would fail here.
777
+ expect(
778
+ screen.getByRole('menuitem', {name: 'Folder A', hidden: true}),
779
+ ).toHaveFocus();
780
+ });
781
+ });
@@ -299,12 +299,16 @@ export function DropdownMenuSubMenu(
299
299
 
300
300
  // Hover-intent: entering the trigger opens after a short delay; leaving
301
301
  // either surface closes after a delay. Hover-open does not steal focus.
302
- const {triggerProps, contentProps} = useMenuHover<HTMLDivElement>({
303
- show: showLayer,
304
- hide: hideLayer,
305
- isOpen,
306
- isEnabled: canOpen,
307
- });
302
+ // Hover intent and the shared hover→click guard only: this level owns its own
303
+ // click handling, roving focus and typeahead. popover="manual", so the
304
+ // invoker wiring other consumers need does not apply.
305
+ const {triggerProps, contentProps, confirmHoverOpen} =
306
+ useMenuHover<HTMLDivElement>({
307
+ show: showLayer,
308
+ hide: hideLayer,
309
+ isOpen,
310
+ isEnabled: canOpen,
311
+ });
308
312
 
309
313
  const open = useCallback(
310
314
  (options?: {focusFirst?: boolean}) => {
@@ -313,18 +317,12 @@ export function DropdownMenuSubMenu(
313
317
  }
314
318
  layer.show();
315
319
  if (options?.focusFirst) {
316
- requestAnimationFrame(() => {
317
- // Move focus into the flyout. When it has no focusable items yet
318
- // (e.g. an async submenu showing only a disabled "Loading…" row via
319
- // hasSpinner), focusFirst() finds nothing — fall back to focusing the
320
- // flyout container itself so keyboard ownership still transfers off
321
- // the parent list. Otherwise the parent would keep focus, letting
322
- // arrow keys rove the parent while the empty flyout stays open.
323
- const focusedItem = focusFirst();
324
- if (!focusedItem) {
325
- menuRef.current?.focus();
326
- }
327
- });
320
+ // Synchronous by design — see the focus note in useMenuHover. A
321
+ // still-loading flyout has no focusable item, so fall back to the
322
+ // container: keyboard ownership must leave the parent list either way.
323
+ if (!focusFirst()) {
324
+ menuRef.current?.focus();
325
+ }
328
326
  }
329
327
  },
330
328
  [canOpen, layer, focusFirst, menuRef],
@@ -354,13 +352,19 @@ export function DropdownMenuSubMenu(
354
352
  if (isDisabled) {
355
353
  return;
356
354
  }
357
- // Click toggles the flyout, moving focus into it on open.
355
+ // Toggles, except for the click that follows a hover-open (#3121).
358
356
  if (isOpen) {
357
+ if (confirmHoverOpen()) {
358
+ if (!focusFirst()) {
359
+ menuRef.current?.focus();
360
+ }
361
+ return;
362
+ }
359
363
  close({focusTrigger: true});
360
364
  } else {
361
365
  open({focusFirst: true});
362
366
  }
363
- }, [isDisabled, isOpen, open, close]);
367
+ }, [isDisabled, isOpen, open, close, confirmHoverOpen, focusFirst, menuRef]);
364
368
 
365
369
  const handleTriggerKeyDown = useCallback(
366
370
  (e: React.KeyboardEvent) => {