Skip to content

Design Decisions

neodigm edited this page Oct 4, 2026 · 1 revision

Design Decisions

Behaviours that look like omissions or bugs, and are neither. Each has a reason, and most have a failure behind them. If you are tempted to "fix" one of these, read the reason first.

The interface

A nudge suggestion fills the composer; it does not send

The user stays the author of their own message. A chip that sends on click commits someone to a phrasing they did not choose and cannot edit — and the one time it misfires, they have said something to your product they did not mean.

The idle nudge fires once per conversation

A surface that keeps asking whether you are still there is a surface people close. Once is a courtesy; twice is nagging.

While generating, the send button becomes Stop — it never disables

A disabled control leaves the user watching output they cannot interrupt, which is the single thing a streaming interface must never do. Mid-generation, the most likely intent is "stop", so that is what the button is.

Autoscroll follows the bottom only when the reader is already near it

Within 48px. Yanking someone back to the bottom while they are reading earlier context is the most common way a chat surface becomes unusable, and it happens precisely when the model is being most useful.

The transcript updates one node per record

It never re-renders the list. Re-rendering steals focus, collapses a text selection mid-copy, and makes a screen reader re-announce turns the user already heard. A test asserts node identity survives a streaming append.

Enter sends, Shift+Enter inserts a newline

The convention every chat surface now shares. Violating it costs a user one mis-sent message before they learn otherwise, and that message is already gone. IME composition is respected, so Enter mid-composition does not send.

Slash commands never reach the model

/clear is not a prompt. Sending it as one wastes a turn, costs a token budget, and produces a confident wrong answer about what was cleared.

A throwing command reports instead of taking the surface down

The CLI prints /name failed: … in its output line. A chat surface that dies because one command had a bug loses the conversation too.

Read-aloud speaks completed turns only

Speaking stream chunks as they arrive produces stuttered nonsense — the synthesiser has no sentence to work with and no prosody to apply.

Dictation is push-to-talk and fills rather than sends

A microphone that transmits the moment it recognises a phrase will eventually send half a sentence, or a conversation happening in the room.

Voice says what the browser cannot do

Neither Speech API is universal, so "it works on my machine" is the default failure here. The component disables what is missing and shows the reason, rather than rendering a button that silently does nothing.

The architecture

Services are found by DOM position, not a global

Two independent chat surfaces on one page must not share a conversation, and a module-level singleton makes that impossible to express. Document position already says "these belong together", so we use it and add nothing.

The container creates its services in its light DOM

Not its shadow root. Slotted children walk up the light tree, so a services element inside the shadow root would be invisible to exactly the components that need it.

PubSub is not an EventTarget

EventTarget binds the bus to whichever realm supplied the global, and dispatching an event constructed in another realm throws. In the sibling package this made a successful tool call log as an error, because a jsdom CustomEvent met Node's EventTarget. The hand-rolled listener set is realm-free and works in a worker or in plain Node.

A throwing subscriber cannot silence its siblings

emit catches per handler. A chat surface has many independent listeners and one bad render must not take the rest down with it.

conversation.records returns a copy

Handing out the live array lets any component corrupt the transcript, and the transcript is the record of what was actually said — there is nothing to recover it from.

Turns are trimmed oldest-first past max-turns

Recent turns are the ones a reader needs, and an unbounded list is an unbounded render.

A failed turn is a visible error record

Not a silent drop. Silently dropping it leaves the user staring at a prompt that appears to have done nothing, which reads as "the site is broken" rather than "that request failed".

stop() keeps the partial text

The user already read it. Deleting what they just read is disorienting in a way that leaving it is not.

Concurrent send is refused

It returns null rather than queueing. Interleaving two streams into one transcript produces output no reader can attribute to anything.

Reserved meta keys are namespaced mcs:

meta is deliberately open, because integrators put CRM ids, ticket numbers and session keys there. An unnamespaced reserved key would collide with a real field and silently overwrite it. Keys outside the namespace are carried verbatim and never stripped; userMeta(record) returns just yours.

IndexedDB, not localStorage

A transcript grows without bound and localStorage is a synchronous ~5 MB cliff that fails exactly when the conversation got interesting.

Persistence is opt-in

Storing someone's conversation is a decision a page makes deliberately. See Data Agency.

Deletion removes the memory copy too

A delete that only drops the stored row leaves the data readable, which makes the control a lie.

Security

Record text is rendered with textContent, everywhere

Model output is untrusted input. It may contain text the model copied from a document, a web page or a tool result, and innerHTML would make every reply a script-injection vector. The inspector is not exempt — a dev tool that executes what it inspects is a worse version of the same hole. Two tests cover this.

If you add Markdown or HTML rendering, that decision is yours and it reopens the hole: sanitize before inserting.

Nothing here calls a model, holds a credential, or makes a request

Stack agnosticism is the point: the moment this package has an opinion about a provider it has an opinion about your billing and your data residency. A test fails if any module gains fetch, XMLHttpRequest, WebSocket or an external URL — because a claim of absence with no test goes false one feature later, with nothing failing to say so.

The constraint that is not negotiable

These modules are browser-only

class X extends HTMLElement is evaluated at module load and each module self-registers at the bottom, so importing one where no DOM exists throws ReferenceError: HTMLElement is not defined.

This is inherent to custom elements, not an oversight — and declaring the classes lazily to work around it would break self-registration on import, which is the feature. In an SSR framework, import from a client-only path. See Getting Started.

Next: Testing a Chat Surface.

Clone this wiki locally