@astryxdesign/core 0.6.3-canary.db4e378 → 0.6.3-canary.ddb63c3

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.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @file ChatMessageList.tsx
3
- * @input Uses React, StyleX, ChatListContext, theme tokens, spacing step utilities
3
+ * @input Uses React, StyleX, ChatListContext, theme tokens, spacing step utilities,
4
+ * and isRenderable to preserve accepted empty-state content
4
5
  * @output Exports ChatMessageList component and ChatMessageListProps
5
6
  * @position Presentational message container — holds ChatMessage children
6
7
  *
@@ -1 +1 @@
1
- {"version":3,"file":"ChatMessageList.d.ts","sourceRoot":"","sources":["../../src/Chat/ChatMessageList.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAC,KAAK,SAAS,EAA4C,MAAM,OAAO,CAAC;AAGhF,OAAO,EAEL,KAAK,WAAW,EAEjB,MAAM,eAAe,CAAC;AAGvB,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAGhD,MAAM,WAAW,oBAAqB,SAAQ,SAAS,CAAC,cAAc,CAAC;IACrE,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAEhC;;;OAGG;IACH,QAAQ,EAAE,SAAS,CAAC;IAEpB;;OAEG;IACH,UAAU,CAAC,EAAE,SAAS,CAAC;IAEvB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAExC;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,WAAW,CAAC;IAElB;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC;IAEzB;;;;;;;;;;;OAWG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AA4FD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,CAAC,EAC9B,QAAQ,EACR,UAAU,EACV,iBAAiB,EACjB,OAAoB,EACpB,GAAG,EACH,KAAgB,EAChB,WAAmB,EACnB,MAAM,EACN,SAAS,EACT,KAAK,EACL,aAAa,EAAE,MAAM,EACrB,GAAG,EACH,GAAG,IAAI,EACR,EAAE,oBAAoB,+BAgGtB;yBA9Ge,eAAe"}
1
+ {"version":3,"file":"ChatMessageList.d.ts","sourceRoot":"","sources":["../../src/Chat/ChatMessageList.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAC,KAAK,SAAS,EAA4C,MAAM,OAAO,CAAC;AAGhF,OAAO,EAEL,KAAK,WAAW,EAEjB,MAAM,eAAe,CAAC;AAGvB,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAGhD,MAAM,WAAW,oBAAqB,SAAQ,SAAS,CAAC,cAAc,CAAC;IACrE,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAEhC;;;OAGG;IACH,QAAQ,EAAE,SAAS,CAAC;IAEpB;;OAEG;IACH,UAAU,CAAC,EAAE,SAAS,CAAC;IAEvB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAExC;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,WAAW,CAAC;IAElB;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC;IAEzB;;;;;;;;;;;OAWG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AA4FD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,CAAC,EAC9B,QAAQ,EACR,UAAU,EACV,iBAAiB,EACjB,OAAoB,EACpB,GAAG,EACH,KAAgB,EAChB,WAAmB,EACnB,MAAM,EACN,SAAS,EACT,KAAK,EACL,aAAa,EAAE,MAAM,EACrB,GAAG,EACH,GAAG,IAAI,EACR,EAAE,oBAAoB,+BAgGtB;yBA9Ge,eAAe"}
@@ -4,7 +4,8 @@
4
4
 
5
5
  /**
6
6
  * @file ChatMessageList.tsx
7
- * @input Uses React, StyleX, ChatListContext, theme tokens, spacing step utilities
7
+ * @input Uses React, StyleX, ChatListContext, theme tokens, spacing step utilities,
8
+ * and isRenderable to preserve accepted empty-state content
8
9
  * @output Exports ChatMessageList component and ChatMessageListProps
9
10
  * @position Presentational message container — holds ChatMessage children
10
11
  *
@@ -27,7 +28,7 @@ import * as stylex from '@stylexjs/stylex';
27
28
  import "../theme/tokens.stylex.js";
28
29
  import { spacingVars } from "../theme/tokens.stylex.js";
29
30
  import { ChatListContext, useChatLayoutContext } from "./ChatContext.js";
30
- import { mergeProps } from "../utils/index.js";
31
+ import { isRenderable, mergeProps } from "../utils/index.js";
31
32
  import { Spinner } from "../Spinner/index.js";
32
33
  import { themeProps } from "../utils/themeProps.js";
33
34
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -225,7 +226,7 @@ export function ChatMessageList({
225
226
  className: "x98rzlu x2lwn1j"
226
227
  },
227
228
  "aria-hidden": true
228
- }), hasChildren ? children : emptyState ? /*#__PURE__*/_jsx("div", {
229
+ }), hasChildren ? children : isRenderable(emptyState) ? /*#__PURE__*/_jsx("div", {
229
230
  ...{
230
231
  className: "x78zum5 x6s0dn4 xl56j7k x98rzlu x2lwn1j"
231
232
  },
@@ -11,6 +11,7 @@ export type { MarkdownFenceContext, MarkdownFenceNode, MarkdownFenceTransformOpt
11
11
  export { createMarkdownSourceDecoration, getMarkdownSourceDecorations, } from './sourceDecoration';
12
12
  export type { MarkdownSourceDecoration, MarkdownSourceDecorationOptions, MarkdownSourceDecorationRange, } from './sourceDecoration';
13
13
  export { createMarkdownFrontmatter } from './frontmatter';
14
+ export { markdownSoftBreaksPlugin } from './softBreaks';
14
15
  export type { MarkdownFrontmatter, MarkdownFrontmatterOptions, MarkdownFrontmatterParseResult, } from './frontmatter';
15
16
  export type { MarkdownPluginData, MarkdownExtensionNode, MarkdownTokenizerInput, MarkdownTokenizeResult, MarkdownSyntaxContribution, MarkdownSyntaxCapability, MarkdownTransformContext, MarkdownTransform, MarkdownExtensionRenderer, MarkdownExtensionRenderers, MarkdownSyntaxPluginDefinition, MarkdownTransformPluginDefinition, MarkdownPluginDefinition, MarkdownPluginEntry, MarkdownNodeOf, MarkdownExtensionsOf, } from './protocol';
16
17
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/Markdown/plugins/index.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,OAAO,EAAC,oBAAoB,EAAE,uBAAuB,EAAC,MAAM,YAAY,CAAC;AACzE,OAAO,EAAC,2BAA2B,EAAC,MAAM,iBAAiB,CAAC;AAC5D,YAAY,EACV,4BAA4B,EAC5B,4BAA4B,GAC7B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAC,4BAA4B,EAAC,MAAM,iBAAiB,CAAC;AAC7D,YAAY,EACV,oBAAoB,EACpB,iBAAiB,EACjB,6BAA6B,GAC9B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,8BAA8B,EAC9B,4BAA4B,GAC7B,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,wBAAwB,EACxB,+BAA+B,EAC/B,6BAA6B,GAC9B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAC,yBAAyB,EAAC,MAAM,eAAe,CAAC;AACxD,YAAY,EACV,mBAAmB,EACnB,0BAA0B,EAC1B,8BAA8B,GAC/B,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,sBAAsB,EACtB,sBAAsB,EACtB,0BAA0B,EAC1B,wBAAwB,EACxB,wBAAwB,EACxB,iBAAiB,EACjB,yBAAyB,EACzB,0BAA0B,EAC1B,8BAA8B,EAC9B,iCAAiC,EACjC,wBAAwB,EACxB,mBAAmB,EACnB,cAAc,EACd,oBAAoB,GACrB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/Markdown/plugins/index.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,OAAO,EAAC,oBAAoB,EAAE,uBAAuB,EAAC,MAAM,YAAY,CAAC;AACzE,OAAO,EAAC,2BAA2B,EAAC,MAAM,iBAAiB,CAAC;AAC5D,YAAY,EACV,4BAA4B,EAC5B,4BAA4B,GAC7B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAC,4BAA4B,EAAC,MAAM,iBAAiB,CAAC;AAC7D,YAAY,EACV,oBAAoB,EACpB,iBAAiB,EACjB,6BAA6B,GAC9B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,8BAA8B,EAC9B,4BAA4B,GAC7B,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,wBAAwB,EACxB,+BAA+B,EAC/B,6BAA6B,GAC9B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAC,yBAAyB,EAAC,MAAM,eAAe,CAAC;AACxD,OAAO,EAAC,wBAAwB,EAAC,MAAM,cAAc,CAAC;AACtD,YAAY,EACV,mBAAmB,EACnB,0BAA0B,EAC1B,8BAA8B,GAC/B,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,sBAAsB,EACtB,sBAAsB,EACtB,0BAA0B,EAC1B,wBAAwB,EACxB,wBAAwB,EACxB,iBAAiB,EACjB,yBAAyB,EACzB,0BAA0B,EAC1B,8BAA8B,EAC9B,iCAAiC,EACjC,wBAAwB,EACxB,mBAAmB,EACnB,cAAc,EACd,oBAAoB,GACrB,MAAM,YAAY,CAAC"}
@@ -10,4 +10,5 @@ export { createMarkdownPlugin, isMarkdownExtensionNode } from "./protocol.js";
10
10
  export { createMarkdownTextTransform } from "./textTransform.js";
11
11
  export { createMarkdownFenceTransform } from "./semanticFence.js";
12
12
  export { createMarkdownSourceDecoration, getMarkdownSourceDecorations } from "./sourceDecoration.js";
13
- export { createMarkdownFrontmatter } from "./frontmatter.js";
13
+ export { createMarkdownFrontmatter } from "./frontmatter.js";
14
+ export { markdownSoftBreaksPlugin } from "./softBreaks.js";
@@ -0,0 +1,3 @@
1
+ /** Converts soft line endings in Markdown phrasing content to hard breaks. */
2
+ export declare const markdownSoftBreaksPlugin: import("./protocol").MarkdownPluginEntry<never>;
3
+ //# sourceMappingURL=softBreaks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"softBreaks.d.ts","sourceRoot":"","sources":["../../../src/Markdown/plugins/softBreaks.ts"],"names":[],"mappings":"AAmKA,8EAA8E;AAC9E,eAAO,MAAM,wBAAwB,iDAInC,CAAC"}
@@ -0,0 +1,172 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file softBreaks.ts
5
+ * @input Soft line endings in Markdown phrasing content
6
+ * @output A first-party plugin that renders each soft line ending as a hard break
7
+ * @position Optional Markdown transform built only on the public plugin protocol
8
+ */
9
+
10
+ import { createMarkdownPlugin } from "./protocol.js";
11
+ function replaceLineEndings(value) {
12
+ const pattern = /\r?\n|\r/g;
13
+ let match = pattern.exec(value);
14
+ if (match == null) {
15
+ return null;
16
+ }
17
+ const output = [];
18
+ let cursor = 0;
19
+ do {
20
+ if (match.index > cursor) {
21
+ output.push({
22
+ type: 'text',
23
+ value: value.slice(cursor, match.index)
24
+ });
25
+ }
26
+ output.push({
27
+ type: 'break'
28
+ });
29
+ cursor = match.index + match[0].length;
30
+ match = pattern.exec(value);
31
+ } while (match != null);
32
+ if (cursor < value.length) {
33
+ output.push({
34
+ type: 'text',
35
+ value: value.slice(cursor)
36
+ });
37
+ }
38
+ return output;
39
+ }
40
+ function transformPhrasing(children) {
41
+ let next;
42
+ for (let index = 0; index < children.length; index++) {
43
+ const node = children[index];
44
+ if (node.type === 'text') {
45
+ const replacement = replaceLineEndings(node.value);
46
+ if (replacement == null) {
47
+ next?.push(node);
48
+ } else {
49
+ next ??= children.slice(0, index);
50
+ next.push(...replacement);
51
+ }
52
+ continue;
53
+ }
54
+ if (node.type === 'strong' || node.type === 'emphasis' || node.type === 'delete' || node.type === 'link') {
55
+ const nested = transformPhrasing(node.children);
56
+ if (nested !== node.children) {
57
+ next ??= children.slice(0, index);
58
+ next.push({
59
+ ...node,
60
+ children: nested
61
+ });
62
+ } else {
63
+ next?.push(node);
64
+ }
65
+ continue;
66
+ }
67
+ next?.push(node);
68
+ }
69
+ return next ?? children;
70
+ }
71
+ function transformTableCell(cell) {
72
+ const children = transformPhrasing(cell.children);
73
+ return children === cell.children ? cell : {
74
+ ...cell,
75
+ children
76
+ };
77
+ }
78
+ function transformTableRow(row) {
79
+ const children = row.children.map(transformTableCell);
80
+ return children.every((child, index) => child === row.children[index]) ? row : {
81
+ ...row,
82
+ children
83
+ };
84
+ }
85
+ function transformListItem(item) {
86
+ const children = transformBlocks(item.children);
87
+ return children === item.children ? item : {
88
+ ...item,
89
+ children
90
+ };
91
+ }
92
+ function transformBlocks(children) {
93
+ let next;
94
+ for (let index = 0; index < children.length; index++) {
95
+ const node = children[index];
96
+ let transformed = node;
97
+ switch (node.type) {
98
+ case 'heading':
99
+ case 'paragraph':
100
+ {
101
+ const nested = transformPhrasing(node.children);
102
+ if (nested !== node.children) {
103
+ transformed = {
104
+ ...node,
105
+ children: nested
106
+ };
107
+ }
108
+ break;
109
+ }
110
+ case 'blockquote':
111
+ {
112
+ const nested = transformBlocks(node.children);
113
+ if (nested !== node.children) {
114
+ transformed = {
115
+ ...node,
116
+ children: nested
117
+ };
118
+ }
119
+ break;
120
+ }
121
+ case 'list':
122
+ {
123
+ const nested = node.children.map(transformListItem);
124
+ if (nested.some((child, childIndex) => child !== node.children[childIndex])) {
125
+ transformed = {
126
+ ...node,
127
+ children: nested
128
+ };
129
+ }
130
+ break;
131
+ }
132
+ case 'table':
133
+ {
134
+ const nested = node.children.map(transformTableRow);
135
+ if (nested.some((child, childIndex) => child !== node.children[childIndex])) {
136
+ transformed = {
137
+ ...node,
138
+ children: nested
139
+ };
140
+ }
141
+ break;
142
+ }
143
+ case 'code':
144
+ case 'math':
145
+ case 'thematicBreak':
146
+ case 'image':
147
+ case 'extension':
148
+ break;
149
+ default:
150
+ node;
151
+ }
152
+ if (transformed !== node) {
153
+ next ??= children.slice(0, index);
154
+ }
155
+ next?.push(transformed);
156
+ }
157
+ return next ?? children;
158
+ }
159
+ const transformSoftBreaks = document => {
160
+ const children = transformBlocks(document.children);
161
+ return children === document.children ? document : {
162
+ ...document,
163
+ children
164
+ };
165
+ };
166
+
167
+ /** Converts soft line endings in Markdown phrasing content to hard breaks. */
168
+ export const markdownSoftBreaksPlugin = createMarkdownPlugin({
169
+ name: 'soft-breaks',
170
+ apiVersion: 1,
171
+ transform: transformSoftBreaks
172
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.6.3-canary.db4e378",
3
+ "version": "0.6.3-canary.ddb63c3",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -692,8 +692,8 @@
692
692
  "react-dom": ">=19.0.0"
693
693
  },
694
694
  "devDependencies": {
695
- "@astryxdesign/a11y-spec": "0.6.3-canary.db4e378",
696
- "@astryxdesign/cli": "0.6.3-canary.db4e378",
695
+ "@astryxdesign/a11y-spec": "0.6.3-canary.ddb63c3",
696
+ "@astryxdesign/cli": "0.6.3-canary.ddb63c3",
697
697
  "@babel/cli": "^7.29.7",
698
698
  "@babel/core": "^7.29.7",
699
699
  "@babel/preset-react": "^7.29.7",
@@ -0,0 +1,222 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 6
4
+ kind: component
5
+ id: component:ChatMessageList
6
+ authority: draft
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: null
10
+ approved_at: null
11
+ owners: [cixzhang]
12
+ review_triggers:
13
+ [public-api, behavior, layout, scrolling, accessibility, theming, testing]
14
+ verified_by:
15
+ [
16
+ packages/core/src/Chat/ChatMessageList.test.tsx,
17
+ packages/core/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts,
18
+ apps/storybook/stories/ChatMessageList.stories.tsx,
19
+ packages/core/src/theme/themingTargets.test.ts,
20
+ ]
21
+ modules: []
22
+ families: []
23
+ design_specs: []
24
+ architecture:
25
+ [
26
+ architecture:public-component-api,
27
+ architecture:component-test-sufficiency,
28
+ architecture:component-style-authoring,
29
+ architecture:component-theming-surface,
30
+ architecture:react-component-runtime,
31
+ architecture:knowledge-contracts,
32
+ ]
33
+ contributing: []
34
+ system_specs: [spec:AST-029]
35
+ ---
36
+
37
+ # ChatMessageList component contract
38
+
39
+ This draft is an observational account of shipped `core/ChatMessageList`. Its
40
+ candidate invariants name their source and evidence; they do not decide any
41
+ new API, default, loading protocol, or visual direction. The current shared
42
+ records linked above continue to govern independently.
43
+
44
+ ## Contract at a glance
45
+
46
+ | Area | Contract |
47
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
48
+ | Public contract | `children`, `emptyState`, `scrollToTopAction`, `density`, `gap`, `align`, `isStreaming`, the root's `BaseProps<HTMLDivElement>`, and `ref`. |
49
+ | Behavior | A polite log holds caller messages. Density selects row spacing and passes through context. A top sentinel can request older messages. A spacer moves short lists down by default. |
50
+ | End-user impact | A reader with a numeric empty-state value sees `0`; other transcript arrangements remain unchanged. |
51
+ | Builder impact | None. No new caller choice or migration is introduced. |
52
+ | Compatibility/readiness | The published `./Chat` subpath and its defaults remain unchanged. This is a draft pending verification and exact-head owner review. |
53
+ | Review checks | Reject a claim that the list owns ChatLayout auto-scroll, that a draft settles an unobserved loading policy, or that DOM `aria-busy` proves spoken output. |
54
+ | Governing rules | `architecture:public-component-api/INV1, INV5–INV8`; `architecture:react-component-runtime/INV3, INV5–INV6`; `spec:AST-029/FR2–FR5`. |
55
+
56
+ This table is a review projection; the body below describes the observed
57
+ surface rather than creating new authority.
58
+
59
+ ## Intent
60
+
61
+ ChatMessageList is the presentational message container within the Chat
62
+ composition. It owns a flex-column transcript, spacing/density context, an
63
+ optional empty state and older-message sentinel, and the log's rendered ARIA
64
+ attributes. `ChatLayout` owns auto-follow scrolling and the dock; message and
65
+ bubble components own sender presentation and content.
66
+
67
+ Consumer prop syntax and examples remain in `ChatMessageList.doc.mjs`.
68
+
69
+ ## Compatibility and migration
70
+
71
+ - Released default preserved: yes. The published Core package exposes `./Chat`
72
+ with `ChatMessageList` and `ChatMessageListProps`.
73
+ - Compatibility class: no new API, default, or migration. Visible numeric
74
+ empty-state content follows the existing `ReactNode` type.
75
+ - Controlled/uncontrolled behavior: not applicable; there is no controlled
76
+ value on this component.
77
+ - Migration decision: none.
78
+
79
+ ## Ownership boundary
80
+
81
+ **Owns**
82
+
83
+ - The outer log element, its public DOM/ref/style passthrough, and the
84
+ `chat-message-list` target with its `density` selector.
85
+ - The inner flex-column message box and density-based padding and gap.
86
+ - The short-list block spacer when `align` is `bottom`.
87
+ - The optional `emptyState` substitution and older-message sentinel/loading
88
+ indicator.
89
+ - Passing the inner content element to `ChatLayoutContext.contentRef` while a
90
+ layout is present.
91
+
92
+ **Does not own / non-goals**
93
+
94
+ - Auto-scroll, scroll-to-bottom, dock, and scroll-owner selection, which belong
95
+ to `component:ChatLayout` and its scroll hooks.
96
+ - Sender identity, message alignment, bubbles, metadata, and text semantics,
97
+ which belong to their child components.
98
+ - The caller's region landmark or an accessible name for arbitrary transcript
99
+ context; no new naming policy is made by this draft.
100
+
101
+ ## Public concepts
102
+
103
+ | Concept | Closed values or states | Meaning observed | Availability | Default | Owner | Stability | Invalid-value behavior |
104
+ | ------------------- | ----------------------------------------------- | --------------------------------------------------------------- | ------------- | ---------- | --------- | --------- | -------------------------------------------------- |
105
+ | `children` | `ReactNode` | Top-level transcript content | every render | required | caller | released | null, false, or `[]` counts empty at the top level |
106
+ | `emptyState` | `ReactNode`; omitted | Substitutes for top-level empty children | empty state | omitted | caller | released | absent renders no substitute |
107
+ | `scrollToTopAction` | async function; omitted | Invoked when the top sentinel intersects | when supplied | omitted | caller | released | absent creates no sentinel or observer |
108
+ | `density` | `compact`; `balanced`; `spacious` | Chooses message spacing and child density context | every render | `balanced` | component | released | closed type union |
109
+ | `gap` | published `SpacingStep`; omitted | Overrides the list's top-level row gap independently of density | every render | omitted | caller | released | closed type union |
110
+ | `align` | `top`; `bottom` | Positions short content at the start or block end | every render | `bottom` | component | released | closed type union |
111
+ | `isStreaming` | `true`; `false` | Exposes `aria-busy` on the polite log while streaming | every render | `false` | caller | released | boolean |
112
+ | root passthrough | `ref`, accepted DOM/data/ARIA and styling props | Extends the outer log element | every render | omitted | caller | released | owned role/live/busy attributes retain precedence |
113
+
114
+ The table records the implementation's top-level emptiness test; it does not
115
+ promise recursive child inspection or resolve the meaning of arbitrary
116
+ non-rendering React nodes.
117
+
118
+ ## Behavioral and layout contract
119
+
120
+ These candidate invariants record observable shipped behavior. A `verify`
121
+ state means the audit has not upgraded implementation into policy.
122
+
123
+ | ID | Candidate invariant | Basis | Draft review state |
124
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | ------------------ |
125
+ | FR1 | The outer root renders `role="log"`, `aria-live="polite"`, and `tabIndex=0`; `isStreaming=true` adds `aria-busy="true"`, which is absent otherwise. | Shipped source, colocated DOM tests and consumer docs | verify |
126
+ | FR2 | The chosen density is reflected as `data-density` on the root and flows to child messages through `ChatListContext`; it selects three spacing groups. | Shipped source, colocated density test, family docs | verify |
127
+ | FR3 | The optional `gap` replaces only the top-level row gap; density still selects padding and context. | Shipped source and consumer docs | verify |
128
+ | FR4 | `emptyState` renders only when `children` is nullish, `false`, or an empty top-level array. Otherwise the passed children render unchanged. | Shipped source, empty-array and numeric-zero tests | verify |
129
+ | FR5 | `align="bottom"` includes a flex spacer before messages; `align="top"` omits it. Overflowing content is not given a different rendering path. | Shipped source, colocated tests, existing family story | verify |
130
+ | FR6 | Supplying `scrollToTopAction` creates an observed top sentinel. An intersection starts its async transition, and a top-positioned spinner renders while it is pending. | Shipped source, consumer docs, focused async test | verify |
131
+ | FR7 | Inside ChatLayout, the inner content element is passed to the layout's `contentRef` and removed from that registration on cleanup; standalone lists have no layout ref. | Shipped source and context definition | verify |
132
+ | FR8 | The root receives `ref`, accepted DOM/data/ARIA props, and composed `className`, `style`, and `xstyle`; component-owned log semantics are applied after forwarded props. | Shipped source and shared public API authority | verify |
133
+
134
+ For the `emptyState` slot, numeric `0` is visible content. Nullish values,
135
+ booleans, and an empty string do not create an empty wrapper. This describes
136
+ scalar output only; it does not decide how to inspect nested children.
137
+
138
+ ### Allowed variation
139
+
140
+ - **AV1 - Content.** Any caller-owned ReactNode may be placed in the list; the
141
+ list does not infer sender metadata from children.
142
+ - **AV2 - Spacing.** Density selects token-based defaults; the optional `gap`
143
+ selects a published spacing step without changing the child density.
144
+ - **AV3 - Scroll ownership.** The list may be used alone or inside ChatLayout;
145
+ auto-follow behavior remains outside this component.
146
+
147
+ ### Representative states
148
+
149
+ | State | Observed result | Allowed variation |
150
+ | ----------------------------- | ----------------------------------------------------- | ------------------------------------------ |
151
+ | Two messages and a system row | Polite log with messages in source order | Caller controls row content |
152
+ | Empty top-level children | Optional centered empty state | An omitted empty state leaves an empty log |
153
+ | Compact/balanced/spacious | Different token-derived row spacing and child density | `gap` may override only row spacing |
154
+ | Short top/bottom list | Bottom includes a spacer, top omits it | Container height is caller-owned |
155
+ | Streaming | Log has `aria-busy=true` | Message content remains caller-owned |
156
+ | Top action pending | Spinner above content while transition is pending | Async action result is caller-owned |
157
+
158
+ ### Performance and resources
159
+
160
+ - An `IntersectionObserver` is installed only when `scrollToTopAction` exists,
161
+ and disconnected on cleanup. Its root is ChatLayout's scroll container when
162
+ available, otherwise the document viewport.
163
+ - An Effect registers the inner content element with ChatLayout, then clears
164
+ that registration on cleanup. This draft does not prescribe an internal hook
165
+ or listener implementation.
166
+
167
+ ## Accessibility contract
168
+
169
+ - The shipped root is a focusable polite log with `aria-busy` reflecting
170
+ `isStreaming`; this describes browser-exposed semantics, not an observed
171
+ screen-reader announcement transcript.
172
+ - The loading spinner is a separate child of the log. The list adds no
173
+ interactive button or directional keyboard behavior of its own.
174
+ - Caller-supplied content remains responsible for names and semantics inside
175
+ messages. This record does not claim one browser/AT pairing proves another.
176
+
177
+ ## Design relationships
178
+
179
+ No current component-local design decision is added by this observational
180
+ backfill. Spacing uses portable semantic token steps. The family consumer doc
181
+ owns the published `chat-message-list` theme target and its `density` axis.
182
+ This record does not choose a new visual representation or a new theming target.
183
+
184
+ ## Family and system relationships
185
+
186
+ - No `family:chat` authority record currently exists. `Chat.doc.mjs` is the
187
+ consumer family document, not a product-authority family contract.
188
+ - `ChatLayout` owns the scroll container and provides `contentRef`; the list
189
+ registers its content element without taking over ChatLayout's scrolling.
190
+ - `spec:AST-029` governs observational audit backfills and keeps their
191
+ evidence inventory separate from component policy.
192
+
193
+ ## Verification map
194
+
195
+ | Contract | Evidence | Representative states | Mutation or failure expectation | Audit section |
196
+ | -------- | ------------------------------------------------------------------- | ---------------------------------- | -------------------------------------------------------- | -------------------------- |
197
+ | FR1 | `ChatMessageList.test.tsx` ARIA assertions; Chromium audit receipts | rest, streaming | Dropping busy or the log role changes the exposed state | `audit:ChatMessageList/§1` |
198
+ | FR2–FR3 | density test, theming-target guard, rendered comparison | three densities and gap override | Losing density reflection or row spacing becomes visible | `audit:ChatMessageList/§2` |
199
+ | FR4–FR5 | empty/align tests, component-owned Storybook and Chromium receipts | empty, top and bottom | Empty substitute or short-list placement changes | `audit:ChatMessageList/§4` |
200
+ | FR6 | `ChatMessageList.test.tsx` async action/status test | sentinel absent, pending, settled | Action loss or stuck spinner fails the focused test | `audit:ChatMessageList/§6` |
201
+ | FR7 | ChatLayout integration and source review | standalone, inside layout, unmount | Content observation can lose registration | `audit:ChatMessageList/§7` |
202
+ | FR8 | `themingTargets.test.ts`, focused root passthrough inspection | ref, DOM/ARIA, styling | Consumer inputs fail to reach or compose on the root | `audit:ChatMessageList/§3` |
203
+
204
+ ## Decision log
205
+
206
+ None. This draft settles no component-local product decision.
207
+
208
+ ## Open questions
209
+
210
+ - **OQ1 - What is the intended overlap policy for repeated top-sentinel
211
+ intersections while an async load remains pending?** (`human-api`) The current
212
+ source invokes the action on intersection; this record does not add a
213
+ single-flight, initial-load, or exhaustion guarantee.
214
+ - **OQ2 - Should emptiness inspect nested non-rendering ReactNode values?**
215
+ (`human-api`) The shipped test is limited to the top-level `children` value.
216
+ Do not reinterpret that test as a policy about generators, fragments,
217
+ promised children, or arrays of non-rendering values.
218
+
219
+ ## Content boundary
220
+
221
+ This file contains no audit score, screenshot matrix, run transcript, consumer
222
+ prop example, new public API, or cross-component visual policy.
@@ -1,7 +1,14 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- import {describe, it, expect} from 'vitest';
4
- import {render, screen} from '@testing-library/react';
3
+ /**
4
+ * @file ChatMessageList.test.tsx
5
+ * @input React Testing Library, Vitest, and the public ChatMessageList surface
6
+ * @output Observable log, empty-state, alignment, and async-loading regressions
7
+ * @position Colocated component behavior tests
8
+ */
9
+
10
+ import {describe, it, expect, vi} from 'vitest';
11
+ import {act, render, screen} from '@testing-library/react';
5
12
  import {ChatMessageList} from './ChatMessageList';
6
13
  import {ChatMessage} from './ChatMessage';
7
14
  import {ChatMessageBubble} from './ChatMessageBubble';
@@ -55,6 +62,11 @@ describe('ChatMessageList', () => {
55
62
  expect(screen.getByText('No messages yet')).toBeTruthy();
56
63
  });
57
64
 
65
+ it('renders an accepted numeric empty state', () => {
66
+ render(<ChatMessageList emptyState={0}>{[]}</ChatMessageList>);
67
+ expect(screen.getByRole('log')).toHaveTextContent('0');
68
+ });
69
+
58
70
  it('applies density class', () => {
59
71
  render(
60
72
  <ChatMessageList density="compact" data-testid="list">
@@ -129,4 +141,54 @@ describe('ChatMessageList', () => {
129
141
  );
130
142
  expect(screen.getByText('Hello')).toBeTruthy();
131
143
  });
144
+
145
+ it('loads older messages through the top sentinel and exposes pending status', async () => {
146
+ let notify!: IntersectionObserverCallback;
147
+ let complete!: () => void;
148
+ const observeSentinel = vi.fn();
149
+ const disconnect = vi.fn();
150
+ const action = vi.fn(
151
+ async () =>
152
+ new Promise<void>(resolve => {
153
+ complete = resolve;
154
+ }),
155
+ );
156
+ class Observer {
157
+ constructor(callback: IntersectionObserverCallback) {
158
+ notify = callback;
159
+ }
160
+ observe = observeSentinel;
161
+ disconnect = disconnect;
162
+ }
163
+ vi.stubGlobal('IntersectionObserver', Observer);
164
+ try {
165
+ const {unmount} = render(
166
+ <ChatMessageList scrollToTopAction={action}>
167
+ <div>Earlier message</div>
168
+ </ChatMessageList>,
169
+ );
170
+ expect(observeSentinel).toHaveBeenCalledTimes(1);
171
+ act(() => {
172
+ notify(
173
+ [{isIntersecting: false} as IntersectionObserverEntry],
174
+ {} as IntersectionObserver,
175
+ );
176
+ });
177
+ expect(action).not.toHaveBeenCalled();
178
+ act(() => {
179
+ notify(
180
+ [{isIntersecting: true} as IntersectionObserverEntry],
181
+ {} as IntersectionObserver,
182
+ );
183
+ });
184
+ expect(action).toHaveBeenCalledTimes(1);
185
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeTruthy();
186
+ await act(async () => complete());
187
+ expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
188
+ unmount();
189
+ expect(disconnect).toHaveBeenCalledTimes(1);
190
+ } finally {
191
+ vi.unstubAllGlobals();
192
+ }
193
+ });
132
194
  });
@@ -4,7 +4,8 @@
4
4
 
5
5
  /**
6
6
  * @file ChatMessageList.tsx
7
- * @input Uses React, StyleX, ChatListContext, theme tokens, spacing step utilities
7
+ * @input Uses React, StyleX, ChatListContext, theme tokens, spacing step utilities,
8
+ * and isRenderable to preserve accepted empty-state content
8
9
  * @output Exports ChatMessageList component and ChatMessageListProps
9
10
  * @position Presentational message container — holds ChatMessage children
10
11
  *
@@ -31,7 +32,7 @@ import {
31
32
  type ChatDensity,
32
33
  useChatLayoutContext,
33
34
  } from './ChatContext';
34
- import {mergeProps} from '../utils';
35
+ import {isRenderable, mergeProps} from '../utils';
35
36
  import {Spinner} from '../Spinner';
36
37
  import type {BaseProps} from '../BaseProps';
37
38
  import type {SpacingStep} from '../utils/types';
@@ -321,7 +322,7 @@ export function ChatMessageList({
321
322
  {/* Messages or empty state */}
322
323
  {hasChildren ? (
323
324
  children
324
- ) : emptyState ? (
325
+ ) : isRenderable(emptyState) ? (
325
326
  <div {...stylex.props(styles.emptyState)}>{emptyState}</div>
326
327
  ) : null}
327
328
  </div>