@astryxdesign/core 0.4.1 → 0.4.2-canary.464a445

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 (182) 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 +13 -7
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/index.d.ts +1 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +1 -1
  77. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  78. package/dist/hooks/useFocusTrap.js +16 -2
  79. package/dist/hooks/useMenuHover.d.ts +50 -5
  80. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  81. package/dist/hooks/useMenuHover.js +171 -51
  82. package/dist/theme/defineTheme.d.ts +8 -2
  83. package/dist/theme/defineTheme.d.ts.map +1 -1
  84. package/dist/theme/defineTheme.js +8 -1
  85. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  86. package/dist/theme/derivedVarRegistry.js +7 -0
  87. package/dist/theme/expandColorScale.d.ts.map +1 -1
  88. package/dist/theme/expandColorScale.js +1 -0
  89. package/dist/theme/expandMotionScale.d.ts +1 -0
  90. package/dist/theme/expandMotionScale.d.ts.map +1 -1
  91. package/dist/theme/expandMotionScale.js +1 -0
  92. package/dist/theme/expandRadiusScale.d.ts +1 -0
  93. package/dist/theme/expandRadiusScale.d.ts.map +1 -1
  94. package/dist/theme/expandRadiusScale.js +1 -0
  95. package/dist/theme/expandTypeScale.d.ts +1 -0
  96. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  97. package/dist/theme/expandTypeScale.js +1 -0
  98. package/dist/theme/tokens.stylex.d.ts +3 -1
  99. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  100. package/dist/theme/tokens.stylex.js +3 -1
  101. package/locales/en.json +84 -0
  102. package/locales/pseudo.json +63 -0
  103. package/package.json +3 -3
  104. package/src/Avatar/Avatar.doc.mjs +5 -2
  105. package/src/Avatar/Avatar.test.tsx +51 -0
  106. package/src/Avatar/Avatar.tsx +30 -20
  107. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  108. package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
  109. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
  110. package/src/Button/Button.tsx +5 -3
  111. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  112. package/src/Card/Card.doc.mjs +2 -0
  113. package/src/Chat/Chat.doc.mjs +4 -1
  114. package/src/Chat/ChatMessage.doc.mjs +3 -3
  115. package/src/Chat/ChatMessage.tsx +7 -0
  116. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  117. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  118. package/src/Chat/ChatMessageBubble.tsx +25 -0
  119. package/src/CheckboxInput/CheckboxInput.tsx +20 -0
  120. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  121. package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
  122. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  123. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
  124. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
  125. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  126. package/src/HoverCard/HoverCard.test.tsx +283 -55
  127. package/src/HoverCard/HoverCard.tsx +5 -13
  128. package/src/HoverCard/useHoverCard.tsx +8 -11
  129. package/src/InputGroup/groupStyles.ts +7 -8
  130. package/src/Item/Item.doc.mjs +4 -0
  131. package/src/Layer/layerHost.test.ts +99 -0
  132. package/src/Layer/layerHost.ts +141 -0
  133. package/src/Layer/useLayer.doc.mjs +14 -4
  134. package/src/Layer/useLayer.test.tsx +332 -7
  135. package/src/Layer/useLayer.tsx +235 -36
  136. package/src/MobileNav/MobileNav.doc.mjs +7 -0
  137. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  138. package/src/RadioList/RadioListItem.tsx +20 -0
  139. package/src/SideNav/SideNav.doc.mjs +13 -4
  140. package/src/SideNav/SideNav.test.tsx +858 -2
  141. package/src/SideNav/SideNav.tsx +37 -16
  142. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  143. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  144. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  145. package/src/SideNav/SideNavHeading.tsx +53 -25
  146. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  147. package/src/SideNav/SideNavItem.tsx +97 -84
  148. package/src/SideNav/SideNavSection.tsx +6 -20
  149. package/src/SideNav/index.ts +2 -0
  150. package/src/Slider/Slider.test.tsx +69 -33
  151. package/src/Slider/Slider.tsx +65 -13
  152. package/src/Switch/Switch.tsx +20 -0
  153. package/src/TabList/TabList.doc.mjs +3 -0
  154. package/src/Text/Text.doc.mjs +1 -1
  155. package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
  156. package/src/Thumbnail/Thumbnail.tsx +10 -0
  157. package/src/Timestamp/Timestamp.test.tsx +40 -22
  158. package/src/Toolbar/Toolbar.test.tsx +14 -14
  159. package/src/TopNav/TopNav.test.tsx +86 -1
  160. package/src/TopNav/TopNavHeading.tsx +21 -14
  161. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  162. package/src/TopNav/TopNavMegaMenu.tsx +29 -105
  163. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  164. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  165. package/src/TopNav/TopNavMenu.tsx +14 -4
  166. package/src/TreeList/TreeList.doc.mjs +1 -0
  167. package/src/hooks/index.ts +5 -1
  168. package/src/hooks/useFocusTrap.test.tsx +16 -0
  169. package/src/hooks/useFocusTrap.ts +20 -2
  170. package/src/hooks/useMenuHover.test.tsx +364 -0
  171. package/src/hooks/useMenuHover.ts +243 -47
  172. package/src/theme/defineTheme.ts +9 -0
  173. package/src/theme/derivedVarRegistry.test.ts +78 -12
  174. package/src/theme/derivedVarRegistry.ts +4 -0
  175. package/src/theme/expandColorScale.ts +1 -0
  176. package/src/theme/expandMotionScale.ts +1 -0
  177. package/src/theme/expandRadiusScale.ts +1 -0
  178. package/src/theme/expandTypeScale.ts +1 -0
  179. package/src/theme/themingTargets.test.ts +152 -26
  180. package/src/theme/tokens.stylex.ts +3 -1
  181. package/src/theme/tokens.test.ts +12 -0
  182. package/src/theme/useTheme.test.tsx +18 -0
@@ -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) => {
@@ -114,7 +114,7 @@ export const docs = {
114
114
  { guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
115
115
  { guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
116
116
  { guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
117
- { guidance: false, description: 'Nest a HoverCard whose content has block elements directly inside phrasing-only contexts such as a <p>, <label>, or heading. The card renders inline, so block content there is invalid HTML the browser reparents. Wrap the surrounding text in a block element (e.g. a <div>) instead.' },
117
+ { guidance: true, description: 'Prefer placing HoverCard in a block context rather than directly in a <p>, heading, or link. Those placements are supported when necessary through a corrective portal, but the DOM and tab order may differ.' },
118
118
  ],
119
119
  anatomy: [
120
120
  {name: 'Trigger', required: true, description: 'The element that opens the hover card on hover or focus: a button, link, or inline text.'},
@@ -229,7 +229,7 @@ export const docsZh = {
229
229
  { guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
230
230
  { guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
231
231
  { guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
232
- { guidance: false, description: 'Nest a HoverCard whose content has block elements directly inside phrasing-only contexts such as a <p>, <label>, or heading. The card renders inline, so block content there is invalid HTML the browser reparents. Wrap the surrounding text in a block element (e.g. a <div>) instead.' },
232
+ { guidance: true, description: 'Prefer placing HoverCard in a block context rather than directly in a <p>, heading, or link. Those placements are supported when necessary through a corrective portal, but the DOM and tab order may differ.' },
233
233
  ],
234
234
  },
235
235
  };
@@ -247,7 +247,7 @@ export const docsDense = {
247
247
  { guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
248
248
  { guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
249
249
  { guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
250
- { guidance: false, description: 'Nest a block-content HoverCard directly inside phrasing-only contexts (<p>, <label>, heading); it renders inline so block content is invalid HTML there. Wrap surrounding text in a block element instead.' },
250
+ { guidance: true, description: 'Prefer a block context over a direct <p>, heading, or link placement. Those placements work when necessary through a corrective portal, but DOM/tab order may differ.' },
251
251
  ],
252
252
  },
253
253
  components: [