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

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
canon registry lib
docs/CANON.md docs/next-features.md §7 (v2 gaps) 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; 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:

  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.)
  • 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, 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.