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>
alpha-0.1-sec-dom
dev 3 months ago
parent 4c4878f139
commit 82c91093d5

@ -77,6 +77,7 @@ Design records for component families whose doctrine spans several components
| Document | Family | The decision it records |
| --- | --- | --- |
| [`design-text-effects.md`](./decisions/design-text-effects.md) | text effects | Why animations applied to real text are **canon** (a11y surface = contract surface) while backgrounds are the pack tier; the CountUp-service vs `Text*`-decorative split (D-T1/D-T2); the shared doctrine every member obeys (content-is-the-SR-surface, no fake interactivity, measurement discipline, reduced motion, ecosystem citizenship, theme-aware color); per-member animation home (D-T5); the `TextCircular`→`Aura` connection. |
| [`design-chat-block.md`](./decisions/design-chat-block.md) | chat (`chat-*`) | Why the messaging block **composes** the ecosystem (triple-registered `Feed` + anchored `VirtualList`, `Textarea`, `FileUpload`, lucide `Icon`) instead of reinventing; the five sector failures it beats (a11y feed + separate live region, virtualization, transport-agnostic, headless-with-logic, threads/reactions); the anchoring doctrine (monotonic sticky, real-DOM pin); the reference visual redesign (soft bubbles, tapback bar, canonical size contract); and the two framework fixes it surfaced (langs `#?`-prefix, `commit-set-resize` `channels:[]`). |
## Cross-cutting decision logs

@ -0,0 +1,160 @@
---
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.

@ -0,0 +1,95 @@
# chat
Pure, zero-dependency helpers behind the `chat-*` component block: the two
data transforms every conversation UI needs — grouping consecutive messages
into visual **runs**, and aggregating who-is-typing into an i18n-ready shape.
Data-agnostic (the caller supplies accessors over its own message type) and
side-effect free, so both the components and the app can share them.
```ts
import {
groupIntoRuns,
aggregateTypers,
isSameLocalDay,
localDayKey,
type ChatRunPosition,
type ChatRunAccessors,
type TypersAggregate
} from '$libs/chat';
```
## Why a lib, not component-internal
The run grouping and the typer aggregation are **presentation logic over the
app's own data**, not component state. Baking them into `chat-message` /
`chat-typing` would force a data model on the app and hide the math from
tests. As a pure lib they are: unit-tested in isolation (server project),
reused by `chat-log`'s demo AND by app code that needs the same grouping for,
say, a notification digest, and swappable (the window, the `manyFrom`
threshold are options, not magic numbers).
## `groupIntoRuns(items, accessors, options?)`
Computes each message's position inside its visual run in one **O(n)** pass —
a run is a stretch of same-author messages close in time that shares one
header + avatar (the WhatsApp/Discord/Slack grouping). Returns a
`ChatRunPosition[]` parallel to `items`: `'solo' | 'first' | 'middle' | 'last'`.
A message CONTINUES the current run when ALL hold:
- same `authorId` as the previous message,
- neither it nor the previous message `breaks` (system rows, replies with
their own header — always `'solo'`, and they end the previous run),
- same LOCAL calendar day as the previous message,
- within `windowMs` of the run's **first** message (the window is measured
from the run start, not message-to-message).
| Option | Default | Meaning |
| --- | --- | --- |
| `windowMs` | `480_000` (8 min — the Discord rule) | Max span from the run's first message. |
`accessors`: `authorId(item)` (stable identity), `timestamp(item)` (epoch ms
or `Date`, non-decreasing in list order), optional `breaks(item)`.
```ts
const runs = groupIntoRuns(rows, {
authorId: (r) => (r.kind === 'message' ? r.author : `event-${r.id}`),
timestamp: (r) => r.at,
breaks: (r) => r.kind === 'event'
});
// eidos: <ChatMessage run={runs[i]} …>
```
### Day helpers
`isSameLocalDay(a, b)` and `localDayKey(value)` (`"2026-07-18"`) back the day
separators — `localDayKey` is a stable `{#each}` key for the separator rows.
## `aggregateTypers(names, options?)`
Collapses the active typer names into the shape i18n renders — the thresholds
mirror the reference apps (1 → "X is typing…", 2 → "X and Y…", 3+ → "several
people…"). Returns a discriminated `TypersAggregate`:
```ts
type TypersAggregate =
| { kind: 'none' }
| { kind: 'one'; name: string }
| { kind: 'two'; first: string; second: string }
| { kind: 'many'; count: number };
```
| Option | Default | Meaning |
| --- | --- | --- |
| `manyFrom` | `3` (clamped `≥ 3`) | Collapse into `'many'` at this count. |
The `chat-typing` provider maps each `kind` to a pluralized langs template
(`components.chat-typing.{one,two,many}`); an app can map it to its own
strings just as easily.
## Consumers
`$libs/chat` is consumed by the `chat-*` block (`chat-message` runs,
`chat-typing` aggregation, `chat-log` day separators). See the block overview
in [`docs/next-features.md §7`](../../../docs/next-features.md) and the
per-component READMEs under `src/uix/{soma,eidos}/components/chat-*`.
Loading…
Cancel
Save

Powered by TurnKey Linux.