You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/decisions/design-chat-block.md

191 lines
10 KiB

---
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.

Powered by TurnKey Linux.