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
parent
4c4878f139
commit
82c91093d5
@ -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…
Reference in new issue