docs(chat): design record del bloque + README de $libs/chat
Documentación del bloque chat-* recién shippeado, siguiendo la convención
del corpus (por-componente = READMEs dobles; por-lib = README; doctrina de
bloque = design record en decisions).
- docs/decisions/design-chat-block.md: la doctrina cross-cutting que abarca
los 4 componentes — la tesis comparativa (5 fallos del sector = nuestras
oportunidades), la topología de composición (triple registro Feed +
VirtualList anclado, Textarea, FileUpload, Icon lucide), el anclaje
(sticky monótono, pin contra DOM real), la doctrina visual del rediseño
de referencia y los 2 fixes de framework que destapó. Indexado en
decisions.md (sección "Component families — design records").
- src/libs/chat/README.md: los helpers puros (groupIntoRuns ventana 8min,
aggregateTypers, límites de día) — convención de README por lib.
docs:check 0 errores (503 docs). Los READMEs por componente y next-features
§7 ya iban en el commit del bloque (4c4878f13).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
---
|
|
|
|
|
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.
|