diff --git a/docs/specs/components/Chat/ChatMessageList.spec.md b/docs/specs/components/Chat/ChatMessageList.spec.md index 05401a8180ced..366d05e613cb7 100644 --- a/docs/specs/components/Chat/ChatMessageList.spec.md +++ b/docs/specs/components/Chat/ChatMessageList.spec.md @@ -1,19 +1,23 @@ --- schema_version: 3 -template_version: 6 +template_version: 7 kind: component id: component:ChatMessageList -authority: draft +authority: current archive_reason: null superseded_by: null -approved_by: null -approved_at: null +approved_by: cixzhang +approved_at: 2026-11-01 owners: [cixzhang] review_triggers: [public-api, behavior, layout, scrolling, accessibility, theming, testing] verified_by: [ packages/core/src/Chat/ChatMessageList.test.tsx, + packages/core/src/Chat/ChatMessage.test.tsx, + packages/core/src/Chat/ChatMessageBubble.test.tsx, + packages/core/src/Chat/ChatLayout.test.tsx, + packages/core/src/Chat/useChatNewMessages.test.tsx, packages/core/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts, apps/storybook/stories/ChatMessageList.stories.tsx, packages/core/src/theme/themingTargets.test.ts, @@ -36,25 +40,19 @@ system_specs: [spec:AST-029] # ChatMessageList component contract -This draft is an observational account of shipped `core/ChatMessageList`. Its -candidate invariants name their source and evidence; they do not decide any -new API, default, loading protocol, or visual direction. The current shared -records linked above continue to govern independently. - ## Contract at a glance -| Area | Contract | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | `children`, `emptyState`, `scrollToTopAction`, `density`, `gap`, `align`, `isStreaming`, the root's `BaseProps`, and `ref`. | -| 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. | -| End-user impact | A reader with a numeric empty-state value sees `0`; other transcript arrangements remain unchanged. | -| Builder impact | None. No new caller choice or migration is introduced. | -| Compatibility/readiness | The published `./Chat` subpath and its defaults remain unchanged. This is a draft pending verification and exact-head owner review. | -| 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. | -| Governing rules | `architecture:public-component-api/INV1, INV5–INV8`; `architecture:react-component-runtime/INV3, INV5–INV6`; `spec:AST-029/FR2–FR5`. | +| Area | Contract | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | `children`, `emptyState`, `scrollToTopAction`, `density`, `gap`, `align`, `isStreaming`, the root's `BaseProps`, and `ref`. | +| Behavior | A polite log holds caller messages. Density selects row spacing and passes through context. A top sentinel can request older messages. A short list rests at the block end by default. | +| End-user impact | Readers get one focusable, polite log of the conversation that reports when a response is streaming; older messages load when they scroll to the top. | +| Builder impact | None. | +| Compatibility/readiness | Records shipped behavior; no public change. One implementation gap, listed under Current-state impact; two owner questions stay open. | +| Review checks | Reject a claim that the list owns ChatLayout auto-scroll, that this record settles the loading-overlap policy, or that DOM `aria-busy` proves spoken output. | +| Governing rules | `architecture:public-component-api/INV1, INV5–INV8`; `architecture:react-component-runtime/INV3, INV5–INV6`; `spec:AST-029/FR2–FR5`. | -This table is a review projection; the body below describes the observed -surface rather than creating new authority. +This table is a review projection; the body below is authoritative. ## Intent @@ -68,14 +66,23 @@ Consumer prop syntax and examples remain in `ChatMessageList.doc.mjs`. ## Compatibility and migration -- Released default preserved: yes. The published Core package exposes `./Chat` - with `ChatMessageList` and `ChatMessageListProps`. -- Compatibility class: no new API, default, or migration. Visible numeric - empty-state content follows the existing `ReactNode` type. +- Released default preserved: `yes` +- Compatibility class: records shipped behavior; no public change - Controlled/uncontrolled behavior: not applicable; there is no controlled value on this component. - Migration decision: none. +## Current-state impact + +- **Bottom alignment uses an extra element.** `align="bottom"` renders a flex + spacer element before the messages to push a short list down. When the list + overflows, the collapsed spacer still leaves one row gap above the first + message. + With no messages, the spacer takes the upper half of the free space, so the + empty state sits about three-quarters of the way down instead of centered. + `architecture:component-style-authoring` INV7 expresses layout through styles + on the elements a component already owns, not added elements. + ## Ownership boundary **Owns** @@ -83,7 +90,7 @@ Consumer prop syntax and examples remain in `ChatMessageList.doc.mjs`. - The outer log element, its public DOM/ref/style passthrough, and the `chat-message-list` target with its `density` selector. - The inner flex-column message box and density-based padding and gap. -- The short-list block spacer when `align` is `bottom`. +- Where a short list rests: the block end for `bottom`, the block start for `top`. - The optional `emptyState` substitution and older-message sentinel/loading indicator. - Passing the inner content element to `ChatLayoutContext.contentRef` while a @@ -96,20 +103,20 @@ Consumer prop syntax and examples remain in `ChatMessageList.doc.mjs`. - Sender identity, message alignment, bubbles, metadata, and text semantics, which belong to their child components. - The caller's region landmark or an accessible name for arbitrary transcript - context; no new naming policy is made by this draft. + context; this record makes no naming policy. ## Public concepts -| Concept | Closed values or states | Meaning observed | Availability | Default | Owner | Stability | Invalid-value behavior | +| Concept | Closed values or states | Meaning | Availability | Default | Owner | Stability | Invalid-value behavior | | ------------------- | ----------------------------------------------- | --------------------------------------------------------------- | ------------- | ---------- | --------- | --------- | -------------------------------------------------- | -| `children` | `ReactNode` | Top-level transcript content | every render | required | caller | released | null, false, or `[]` counts empty at the top level | -| `emptyState` | `ReactNode`; omitted | Substitutes for top-level empty children | empty state | omitted | caller | released | absent renders no substitute | -| `scrollToTopAction` | async function; omitted | Invoked when the top sentinel intersects | when supplied | omitted | caller | released | absent creates no sentinel or observer | -| `density` | `compact`; `balanced`; `spacious` | Chooses message spacing and child density context | every render | `balanced` | component | released | closed type union | -| `gap` | published `SpacingStep`; omitted | Overrides the list's top-level row gap independently of density | every render | omitted | caller | released | closed type union | -| `align` | `top`; `bottom` | Positions short content at the start or block end | every render | `bottom` | component | released | closed type union | -| `isStreaming` | `true`; `false` | Exposes `aria-busy` on the polite log while streaming | every render | `false` | caller | released | boolean | -| 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 | +| `children` | `ReactNode` | Top-level transcript content | every render | required | caller | stable | null, false, or `[]` counts empty at the top level | +| `emptyState` | `ReactNode`; omitted | Substitutes for top-level empty children | empty state | omitted | caller | stable | absent renders no substitute | +| `scrollToTopAction` | async function; omitted | Invoked when the top sentinel intersects | when supplied | omitted | caller | stable | absent creates no sentinel or observer | +| `density` | `compact`; `balanced`; `spacious` | Chooses message spacing and child density context | every render | `balanced` | component | stable | closed type union | +| `gap` | published `SpacingStep`; omitted | Overrides the list's top-level row gap independently of density | every render | omitted | caller | stable | closed type union | +| `align` | `top`; `bottom` | Positions short content at the start or block end | every render | `bottom` | component | stable | closed type union | +| `isStreaming` | `true`; `false` | Exposes `aria-busy` on the polite log while streaming | every render | `false` | caller | stable | boolean | +| root passthrough | `ref`, accepted DOM/data/ARIA and styling props | Extends the outer log element | every render | omitted | caller | stable | owned role/live/busy attributes retain precedence | The table records the implementation's top-level emptiness test; it does not promise recursive child inspection or resolve the meaning of arbitrary @@ -117,19 +124,16 @@ non-rendering React nodes. ## Behavioral and layout contract -These candidate invariants record observable shipped behavior. A `verify` -state means the audit has not upgraded implementation into policy. - -| ID | Candidate invariant | Basis | Draft review state | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | ------------------ | -| 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 | -| 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 | -| FR3 | The optional `gap` replaces only the top-level row gap; density still selects padding and context. | Shipped source and consumer docs | verify | -| 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 | -| 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 | -| 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 | -| 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 | -| 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 | +| ID | Invariant | Basis | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------- | +| FR1 | The outer root renders `role="log"`, `aria-live="polite"`, and `tabIndex=0`; `isStreaming=true` adds `aria-busy="true"`, which is absent otherwise. | Current source, tests, and docs | +| FR2 | The chosen density is reflected as `data-density` on the root and flows to child messages through `ChatListContext`; it selects three spacing groups. | Current source, tests, and docs | +| FR3 | The optional `gap` replaces only the top-level row gap; density still selects padding and context. | Current source, tests, and docs | +| FR4 | `emptyState` renders only when `children` is nullish, `false`, or an empty top-level array. Otherwise the passed children render unchanged. | Current source and tests | +| FR5 | With `align="bottom"` a short list rests at the block end; with `align="top"` it rests at the block start. Overflowing content takes the same rendering path. Implementation gap: the bottom placement uses an added spacer element, which also leaves one row gap above the first message when the list overflows (Current-state impact). | Current source and tests | +| FR6 | Supplying `scrollToTopAction` creates an observed top sentinel. An intersection starts its async transition, and a top-positioned spinner renders while it is pending. | Current source, tests, and docs | +| 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. | Current source | +| 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. | Current source; `architecture:public-component-api` | For the `emptyState` slot, numeric `0` is visible content. Nullish values, booleans, and an empty string do not create an empty wrapper. This describes @@ -146,40 +150,40 @@ scalar output only; it does not decide how to inspect nested children. ### Representative states -| State | Observed result | Allowed variation | -| ----------------------------- | ----------------------------------------------------- | ------------------------------------------ | -| Two messages and a system row | Polite log with messages in source order | Caller controls row content | -| Empty top-level children | Optional centered empty state | An omitted empty state leaves an empty log | -| Compact/balanced/spacious | Different token-derived row spacing and child density | `gap` may override only row spacing | -| Short top/bottom list | Bottom includes a spacer, top omits it | Container height is caller-owned | -| Streaming | Log has `aria-busy=true` | Message content remains caller-owned | -| Top action pending | Spinner above content while transition is pending | Async action result is caller-owned | +| State | Required invariant | Allowed variation | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| Two messages and a system row | Polite log with messages in source order | Caller controls row content | +| Empty top-level children | The optional empty state is centered in the whole list for `top`; for `bottom`, it is centered below the spacer (Current-state impact) | An omitted empty state leaves an empty log | +| Compact/balanced/spacious | Different token-derived row spacing and child density | `gap` may override only row spacing | +| Short top/bottom list | Bottom rests at the block end, top at the block start | Container height is caller-owned | +| Streaming | Log has `aria-busy=true` | Message content remains caller-owned | +| Top action pending | Spinner above content while transition is pending | Async action result is caller-owned | ### Performance and resources -- An `IntersectionObserver` is installed only when `scrollToTopAction` exists, - and disconnected on cleanup. Its root is ChatLayout's scroll container when +- **PR1 — Sentinel observer.** An `IntersectionObserver` is installed only when + `scrollToTopAction` exists, and disconnected on cleanup. Its root is ChatLayout's scroll container when available, otherwise the document viewport. -- An Effect registers the inner content element with ChatLayout, then clears - that registration on cleanup. This draft does not prescribe an internal hook - or listener implementation. +- **PR2 — Layout registration.** The inner content element is registered with + ChatLayout, and the registration is cleared on cleanup. ## Accessibility contract -- The shipped root is a focusable polite log with `aria-busy` reflecting - `isStreaming`; this describes browser-exposed semantics, not an observed +- **AR1 — Log semantics.** The root is a focusable polite log with `aria-busy` + reflecting `isStreaming` (FR1); this states browser-exposed semantics, not a screen-reader announcement transcript. -- The loading spinner is a separate child of the log. The list adds no - interactive button or directional keyboard behavior of its own. -- Caller-supplied content remains responsible for names and semantics inside - messages. This record does not claim one browser/AT pairing proves another. +- **AR2 — No keyboard model.** The loading spinner is a separate child of the + log. The list adds no interactive button or directional keyboard behavior of + its own. +- **AR3 — Caller content.** Caller-supplied content remains responsible for + names and semantics inside messages. ## Design relationships -No current component-local design decision is added by this observational -backfill. Spacing uses portable semantic token steps. The family consumer doc +No component-local design decision is recorded. Spacing uses portable semantic token steps. The family consumer doc owns the published `chat-message-list` theme target and its `density` axis. -This record does not choose a new visual representation or a new theming target. +No anatomy-theming map is recorded yet; the block is optional during +migration, so the map is deferred. ## Family and system relationships @@ -187,23 +191,36 @@ This record does not choose a new visual representation or a new theming target. consumer family document, not a product-authority family contract. - `ChatLayout` owns the scroll container and provides `contentRef`; the list registers its content element without taking over ChatLayout's scrolling. -- `spec:AST-029` governs observational audit backfills and keeps their - evidence inventory separate from component policy. +- `spec:AST-029` owns the audit evidence inventory this record's verification + map follows. ## Verification map -| Contract | Evidence | Representative states | Mutation or failure expectation | Audit section | -| -------- | ------------------------------------------------------------------- | ---------------------------------- | -------------------------------------------------------- | -------------------------- | -| FR1 | `ChatMessageList.test.tsx` ARIA assertions; Chromium audit receipts | rest, streaming | Dropping busy or the log role changes the exposed state | `audit:ChatMessageList/§1` | -| 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` | -| 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` | -| FR6 | `ChatMessageList.test.tsx` async action/status test | sentinel absent, pending, settled | Action loss or stuck spinner fails the focused test | `audit:ChatMessageList/§6` | -| FR7 | ChatLayout integration and source review | standalone, inside layout, unmount | Content observation can lose registration | `audit:ChatMessageList/§7` | -| 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` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------ | -------------------------- | +| FR1, AR1 | `ChatMessageList.test.tsx` role and `aria-busy` tests; `ChatMessageList.a11y.chromium.spec.ts` `aria-live` assertion | rest, streaming | A dropped log role or busy state fails. | `audit:ChatMessageList/§1` | +| FR2–FR3 | `ChatMessageList.test.tsx` density and gap tests | densities; gap override | Lost density reflection or a gap that changes density fails. | `audit:ChatMessageList/§2` | +| FR2 | `ChatMessage.test.tsx` "inherits list density and lets an explicit prop override it"; `ChatMessageBubble.test.tsx` list-density case | child message in a list | Density that stops reaching child messages fails. | `audit:ChatMessageList/§2` | +| FR7 | `ChatLayout.test.tsx` "first-fill scroll positioning" cases; `useChatNewMessages.test.tsx` "works end-to-end with ChatLayout conditional ChatMessageList" | list inside ChatLayout | A list that stops registering its content element fails. | `audit:ChatMessageList/§7` | +| FR4 | `ChatMessageList.test.tsx` empty-state tests | empty; numeric empty state | A missing empty state, or a dropped numeric one, fails. | `audit:ChatMessageList/§4` | +| FR6 | `ChatMessageList.test.tsx` "loads older messages through the top sentinel and exposes pending status" | pending, settled | A lost action or stuck spinner fails. | `audit:ChatMessageList/§6` | + +Read from source, with no focused test yet: + +- FR1: `tabIndex=0` on the log. +- FR2: the spacing each density selects. +- FR3: `gap` replacing the top-level row gap. +- FR4: `null` and `false` children selecting the empty state. +- FR5: where a short list rests; the spacer tests count the added element + behind the Current-state impact gap. +- FR7: removing the registration on cleanup. +- FR8: `ref`, DOM, data, and ARIA props, `className`, `style`, and `xstyle` on + the root, and owned log semantics winning over forwarded props. +- PR1–PR2: the observer's root, and registration cleanup. ## Decision log -None. This draft settles no component-local product decision. +No component-local decision is recorded. ## Open questions @@ -218,5 +235,5 @@ None. This draft settles no component-local product decision. ## Content boundary -This file contains no audit score, screenshot matrix, run transcript, consumer -prop example, new public API, or cross-component visual policy. +This file does not duplicate consumer prop tables or examples, ChatLayout's +scroll behavior, or cross-component visual policy. It links to their owners.