--- title: Chat block — design record type: design-record audience: human + agent authority: the cross-cutting doctrine of the `chat-*` family — the block-level decisions that span several components; per-component detail lives in the component READMEs, not here status: current related: canon: docs/CANON.md registry: docs/next-features.md §7 (v2 gaps) lib: src/libs/chat/README.md --- # Chat block — design record The `chat-*` family (`chat-log`, `chat-message`, `chat-composer`, `chat-typing`) is a **messaging block**: the room-core surfaces of a conversation UI. This document records the decisions that span the whole block — the market thesis, the composition topology, the transport stance, and the doctrines every member obeys. Per-component contracts (parts, events, tokens) live in the double READMEs under `src/uix/{soma,eidos}/components/chat-*`; the pure data helpers in [`src/libs/chat/README.md`](../../src/libs/chat/README.md); the deferred v2 surface in [`next-features.md §7`](../next-features.md). ## The thesis — the five sector failures are our opportunities The block was designed from a comparative study of the sector (Stream, Sendbird, CometChat, chatscope, MinChat, react-chat-elements, shadcn chat, Twilio Paste, the Svelte kits). Five failures repeat across the field, and each is a capability the framework already had: 1. **No a11y.** Nobody implements `role="feed"` + `article` + keyboard navigation (only Twilio documents a bare live region). → We compose the existing `Feed` (full APG feed pattern) and add a **separate** polite live region for incoming messages — the combination no open-source kit has. 2. **No virtualization** (except Stream, coupled to its SaaS). → We extend the existing `VirtualList` with an `anchor: 'end'` chat mode rather than invent a scroller. 3. **Backend coupling** in the complete kits. → The block is pure UI; the transport is the app's, via props + callbacks. 4. **The composable-vs-data-driven dichotomy.** The composable kits ship no grouping/state/anchoring; the complete kits can't be composed. → Our soma providers are **headless logic without an imposed data model**: a compositional API (parts as children, never `messages={[]}`). 5. **Threads and reactions as gaps.** → `Feed.Thread` already carries the thread semantics; reactions ship in v1. The success criterion follows directly: the block sits *above* the sector on all five, and *below* the cost of the complete kits — zero dependency, zero backend. ## Composition topology — compose, never reinvent The user's founding constraint: *use the components already in the ecosystem, don't reinvent the wheel*. The block is composition all the way down. - **`chat-log` = triple registration.** One shell element registers as the `chat-log` provider **and** `Feed.Provider` **and** `VirtualList.Provider` (shared `id`/`ref`, created inside the log provider — the Knob/NumberField precedent). Feed gives the APG semantics + keyboard; VirtualList gives the virtualization + `anchor:'end'`; the log owns the pin/pill/announce bookkeeping. `Viewport`/`Item`/`Article`/`Sentinel` are re-exports, so their native `data-*` attrs stay the authoritative styling API. - **`chat-message` root = `Feed.Article`** (double registration): role, `posinset`/`setsize`, PageUp/PageDown all come from Feed. - **`chat-composer` input = the soma `Textarea`** (double registration, shared id/ref) with the block's Enter/Escape/ArrowUp keyboard layered on top; `attach`/`attachments` compose `FileUpload` (its upload events stay owned by FileUpload — event delegation, D.1). - **Icons are the ecosystem `Icon` (lucide)**, never hand-rolled SVG paths — `SmilePlus`, `Send`, `Paperclip`, `X`, `ArrowDown`, `Reply`. The one **new** primitive is `$libs/chat` — pure, data-agnostic transforms (`groupIntoRuns`, `aggregateTypers`), not a component. Everything visible composes an existing component. ### No shared root provider in v1 Reply/edit coordination flows through the consumer's props/callbacks, not a `Chat.Provider`. If ≥2 consumers later need the same shared state, a root provider is proposed then (no-premature-abstraction). ## Transport-agnostic — the app owns the wire The message data model belongs to the app; components take primitive props + snippets. The optimistic cycle (client_msg_id, dedupe, queue) is app domain; the UI models the **states** (`data-delivery`) and the retry affordance. Incoming messages arrive through an imperative `chat-log` `notifyIncoming({message})` (toaster-style: announces the message at the end, the "N new" summary while scrolled up). The showcase at `/uix/demos/chat` drives all of this with a simulated echo-bot transport (`uix.timers`); a real app swaps that section for `$connection`. ## Anchoring doctrine (VirtualList `anchor:'end'`) `column-reverse` is rejected (documented Firefox/iOS/smooth bugs). The correct pattern (TanStack Virtual / react-virtuoso): anchor to end, follow-on-append **only when the user is at the end**, **stable keys** (never indices), prepend compensation by anchoring a stable key + offset. Hard-won specifics: - **Re-pin on growth while sticky** — dynamic rows measure larger than the estimate *after* the pin, so `totalSize` grows and the view drifts. - **The pin lands against the real DOM** (`scrollHeight/clientHeight` inside `dom.measure`, state fallback for jsdom) — the state mirror derives px from the real box (RO `viewportSize` vs `clientHeight`, fractional sums). - **Sticky is MONOTONIC** — reaching the end arms it; only an offset decrease (moving away) disarms it; increments that don't reach the end (chasing a moving end on mobile) preserve intent. Deriving sticky from `isAtEnd` on every scroll event disarmed the chase on the async jump event. ## Perceptual doctrine (sema) High-frequency sobriety (D.5): send = soft form-commit + light tap; incoming = `signal.notify`; reactions = light tap; typing/delivery = silent. `chat-typing` is `sustain.streaming` — the sustain family carries **no** sound/haptic (D.8); its whole expression is the visual pulse loop, so it ships no pack (`family-default`). Selectors are always the typed `semaSelector`. ## Visual doctrine (eidos) — the reference redesign Calibrated side-by-side against WhatsApp/Telegram after a first pass was rejected. The durable rules (per-component tokens in the READMEs): - **Bubbles**: own messages a SOFT role tint (`--color-primary-element`) with normal text — never the saturated solid; peer messages a `surface` card with a hairline shadow, floating over the log's `surface-muted` canvas. - **Metadata inside the bubble** (time/ticks `float: inline-end`, the last line wraps around them); a run's last bubble drops its margin-side corner (the tail cue); the header is the author name only, in the accent color. - **Reactions** are pills that overlap the bubble's bottom edge; the quick-react picker is a **horizontal tapback bar** of round cells with big emoji and scale-on-hover — not a menu list. - **Size contract is canonical**: `size="md"` = `--size-md-font-size` (16px), like the rest of the catalog; density comes from line-height + padding, never from down-shifting the scale. The composer input is 16px (also the iOS-Safari no-zoom floor). - **Row gaps are `padding`, not `margin`** — virtualized rows are measured by border-box, so a margin falls outside the measurement and drifts offsets. - **Archetype `item` state-layer is neutralized** — the composed `Feed.Article`/VirtualList item stamps `data-archetype='item'`, whose global hover/highlight would paint the whole row; a chat row is not a selectable option. - **Per-message bidi**: `unicode-bidi: plaintext` on the bubble. ## Framework fixes this block surfaced Two defects, fixed at the framework level (not worked around): - **`EngineLangs.t()` now strips the `#?` prefix.** The imperative idlangref constants (A3) pass a full `#?a.b|fallback` to `t()`; without stripping, the path lookup always missed and every caller (pagination, file-upload, chronos, chat) silently fell back to the English template in non-EN locales. Fix: `parseLangRef(path) ?? parsePathFallback(path)` + a regression test. ([`src/arts/langs/README.md`](../../src/arts/langs/README.md).) - **VirtualList `commit-set-resize` carries `channels: []`.** It is structural bookkeeping fired by ResizeObserver/layout, never a user gesture — the commit-family base would beep + buzz on every relayout (browsers flag the pre-interaction `vibrate`/AudioContext). The layer-4 override silences it. ## v1 → v2 status (2026-07-19) **v1 (room core) — complete.** `chat-log`, `chat-message`, `chat-composer`, `chat-typing`, `$libs/chat`, the VirtualList `anchor:'end'` extension, and the read-receipt hook `ChatLog.onMessageSeen` (IntersectionObserver over the virtualized rows, dedup internal). Showcase at `/uix/demos/chat`. **v2 — shipped so far** (the "remates sobre v1" batch, all decorative or prop-driven additions on existing components): - **`ChatTyping.Avatars`** — an avatar slot before the dots (the app composes `AvatarGroup`), `aria-hidden`. - **`ChatMessage.ReadBy`** — a "seen by N" avatar row (the app composes `AvatarGroup`), `aria-hidden` (the `Status` indicator already conveys "read" to AT). Fed by the sender's `onMessageSeen`. - **Jump-to-message highlight** — `ChatMessage` `emphasized` prop → `data-emphasized` row flash (a `color-mix` wash that fades via transition, no `@keyframes`). **Visual-only, no sema event** — a jump-to-quote flash is silent in the reference apps; a `signal` that beeped by default would be wrong. Wired in the showcase via a reply's `onJump` (scroll → emphasize ~1.4s). A morfo presence attr's prop source returns the raw **boolean** (`() => this.opts.emphasized.current`); returning `''` silently omits it. **v2 — remaining** (in [`next-features.md §7`](../next-features.md), by independence/value): `chat-list` (conversation pane) and `emoji-picker` (full picker) are the high-value independent components; then `threads UI`; `@` mentions depend on the words/palabras integration decision; VirtualList bidirectional sparse ranges is an infra refinement. `delegate ↔ Aura` stays reserved until agent conversations land.