10 KiB
| title | type | audience | authority | status | related | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| Chat block — design record | design-record | human + agent | 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 | current |
|
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; the deferred v2
surface in next-features.md §7.
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:
- No a11y. Nobody implements
role="feed"+article+ keyboard navigation (only Twilio documents a bare live region). → We compose the existingFeed(full APG feed pattern) and add a separate polite live region for incoming messages — the combination no open-source kit has. - No virtualization (except Stream, coupled to its SaaS). → We extend
the existing
VirtualListwith ananchor: 'end'chat mode rather than invent a scroller. - Backend coupling in the complete kits. → The block is pure UI; the transport is the app's, via props + callbacks.
- 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={[]}). - Threads and reactions as gaps. →
Feed.Threadalready 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 thechat-logprovider andFeed.ProviderandVirtualList.Provider(sharedid/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/Sentinelare re-exports, so their nativedata-*attrs stay the authoritative styling API.chat-messageroot =Feed.Article(double registration): role,posinset/setsize, PageUp/PageDown all come from Feed.chat-composerinput = the somaTextarea(double registration, shared id/ref) with the block's Enter/Escape/ArrowUp keyboard layered on top;attach/attachmentscomposeFileUpload(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
totalSizegrows and the view drifts. - The pin lands against the real DOM (
scrollHeight/clientHeightinsidedom.measure, state fallback for jsdom) — the state mirror derives px from the real box (ROviewportSizevsclientHeight, 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
isAtEndon 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 asurfacecard with a hairline shadow, floating over the log'ssurface-mutedcanvas. - 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, notmargin— virtualized rows are measured by border-box, so a margin falls outside the measurement and drifts offsets. - Archetype
itemstate-layer is neutralized — the composedFeed.Article/VirtualList item stampsdata-archetype='item', whose global hover/highlight would paint the whole row; a chat row is not a selectable option. - Per-message bidi:
unicode-bidi: plaintexton 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|fallbacktot(); 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.)- VirtualList
commit-set-resizecarrieschannels: []. 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-interactionvibrate/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 composesAvatarGroup),aria-hidden.ChatMessage.ReadBy— a "seen by N" avatar row (the app composesAvatarGroup),aria-hidden(theStatusindicator already conveys "read" to AT). Fed by the sender'sonMessageSeen.- Jump-to-message highlight —
ChatMessageemphasizedprop →data-emphasizedrow flash (acolor-mixwash that fades via transition, no@keyframes). Visual-only, no sema event — a jump-to-quote flash is silent in the reference apps; asignalthat beeped by default would be wrong. Wired in the showcase via a reply'sonJump(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, 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.