This is the full developer documentation for aparté # Compare aparté with Loquix, assistant-ui, AI Elements & more (2026) > A fair, dated comparison of AI chat UI libraries — Loquix, assistant-ui, Vercel AI Elements, deep-chat, kitn, OpenAI ChatKit — on framework lock-in, runtime, local/in-browser models, human-in-the-loop and theming. If you are choosing a chat UI library for an LLM product, the libraries below are the ones you will shortlist. This page says what each is good at and where aparté differs — including the cases where you should pick the other one. Claims about other projects were checked against their public docs in **August 2026**; they move fast, so verify before deciding. The short version: **aparté is the one that is a complete runtime *and* framework-agnostic *and* free of third-party dependencies.** The React kits have more components. The other web-component kits are presentational — you bring the loop. If you need neither framework freedom nor a runtime, you have better options than aparté, and they are named below. ## The matrix [Section titled “The matrix”](#the-matrix) | | aparté | assistant-ui | AI Elements | deep-chat | Loquix | kitn | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------- | ----------------------------------- | ------------------------------- | ----------------------------------- | ----------------------------- | | Frameworks | Web Components + React, Vue, Svelte, Angular wrappers | React | React | Web Component (+ React wrapper) | Web Component (Lit) | Web Component | | Runtime included (streaming, agent loop, tool calls) | **Yes** — [core](/why/) + [headless engine](/guides/engine/) | Runtime adapters (AI SDK, LangGraph, …) | Via the Vercel AI SDK | Built in, config-driven | No — presentational | No — presentational | | Framework you must adopt | **None** — and no third-party dependency in `@aparte/core` | React | React + the AI SDK | None | None (Lit is bundled) | None | | Human-in-the-loop: tool approval, typed questions | **Approval at the composer, [typed elicitation panel](/guides/elicitation/)** | Generic tool UI | Confirmation component | — | Rendered, not run | Rendered, not run | | Edit / retry / branch a conversation | **[Yes, with a version picker](/guides/conversations-branching/)** | Yes | Partial | — | Partial | — | | Persistence | [A storage interface you implement](/guides/conversation-persistence/) | A hosted cloud service (paid) | Your own | — | — | — | | Theming | [CSS variables, light DOM](/guides/theming/), no fork | Tailwind / shadcn — fork the markup | shadcn — the code lives in your app | Config object | CSS variables | CSS variables + online editor | | Run a model in the browser | Yes — the [transformers provider](/providers/ai/transformers/) | — | — | Yes (Web LLM) | — | — | | Scripted demo/test provider | **Yes — [the scenario provider](/providers/ai/scenario/)** (deterministic turns: thinking, tools, artifacts) | — | — | Demo mode (canned) | — | — | | Markdown, code, reasoning | Yes ([opt-in plugins](/plugins/)) | Yes | Yes | Yes | Yes | Yes | | Mermaid, KaTeX | — | Yes | Yes | — | — | — | | Voice | — | Yes (realtime) | Yes | Yes | — | — | | Citations / sources | — | Partial | Yes | — | Yes | — | | Floating / modal chat | — | Yes | — | Yes (embed) | Partial | — | | Best fit for | Multi-framework teams that want the runtime included | React teams, all-in on React | Apps already on the Vercel AI SDK | A config-driven embed, fast | A polished presentational kit (Lit) | A minimal presentational kit | | Licence | MIT | MIT | Apache-2.0 | MIT | MIT | MIT | The rows read left to right as *what aparté is for*: the top half is the runtime, the bottom half is the catalogue. aparté wins the top half and loses the bottom half, and both halves are true. ## assistant-ui [Section titled “assistant-ui”](#assistant-ui) **The React reference.** A composable primitives kit (thread, composer, message, action bar, branch picker, chain of thought, attachments) with the widest set of runtime integrations in the category — the Vercel AI SDK, LangGraph, LangChain, Mastra, AG-UI, A2A and more — plus realtime voice, Mermaid and KaTeX rendering, and a hosted cloud for thread persistence. **Pick assistant-ui when** you are on React for good, want its idioms end to end, and want to plug into one of those runtimes rather than run a loop of your own. Its ecosystem is larger than aparté’s and will stay so. **Pick aparté when** the same chat has to ship in more than one framework (or none), when you do not want a UI kit to dictate your styling system, or when you want a runtime in the library rather than an adapter to someone else’s. Theming is the other line: assistant-ui is Tailwind/shadcn, so a visual change is a fork of markup; aparté is CSS variables on light DOM, so it is a stylesheet. ## Vercel AI Elements [Section titled “Vercel AI Elements”](#vercel-ai-elements) **The largest catalogue.** Fifty-odd shadcn components — chatbot, code (artifact, file tree, terminal, sandbox, web preview), voice and workflow-graph families — installed by CLI, so the source lands in your app. Requires React and the Vercel AI SDK. **Pick AI Elements when** you build on Next.js and the AI SDK and want a finished part for every screen an agent product has. Nobody else has that breadth. **Pick aparté when** you are not on React, or when you want the parts to update as a dependency rather than as copied source. One thing to know: AI Elements’ file tree, terminal and test results are **app-fed views**, not message types — the same conclusion aparté reached when it removed those segment kinds. A tool result rendered richly — generative UI, in the term the field settled on — is the seam both libraries converge on. ## deep-chat [Section titled “deep-chat”](#deep-chat) **The fastest time-to-chat.** One `` element, configured by attributes and objects, connects directly to twenty-odd vendor APIs, does files, camera, microphone, speech in and out, and can run a model in the browser. **Pick deep-chat when** you want one tag and a config object and you are done — it is the best of the category at that, and the only other one that runs a model client-side. **Pick aparté when** you need composable parts rather than one element (aparté is a viewport, a composer, bubbles and a status line you place yourself), token-level theming, an agent loop with [tool approval](/guides/tools/), or the same chat in four frameworks with [typed wrappers](/frameworks/). deep-chat’s customisation is a large config surface; aparté’s is CSS and render hooks. ## Loquix [Section titled “Loquix”](#loquix) **A presentational web-component kit.** A few dozen Lit components across reasoning, trust, search and template categories, with accessibility as its headline. By design it ships no provider, no loop and no transport: it is a type-only interface you feed. **Pick Loquix when** you already have a chat runtime you like and want a broad, accessible set of views to render its output. **Pick aparté when** you want the runtime too. aparté’s core has no third-party dependency (Loquix carries Lit), and its [engine](/guides/engine/) runs the agent loop, the tool calls, the approval gate and the conversation compactor — the parts a presentational kit leaves to you. Loquix’s catalogue is wider than aparté’s; its roadmap points at provider integrations, which is the ground aparté already covers. ## kitn [Section titled “kitn”](#kitn) **The presentational twin.** A single custom element that renders tool calls, reasoning traces and typed cards (confirmations, choices, forms) — “you bring the model” is the stated contract. Its theming is its strength: a small token set, dozens of presets and an online theme editor that exports CSS. **Pick kitn when** you want a drop-in view over a loop you run elsewhere, and a theme in five minutes. **Pick aparté when** the typed cards have to be *honoured*, not just drawn: aparté’s [elicitation](/guides/elicitation/) pauses the run, collects the answer and resumes it, and the approval step gates a real tool call. kitn draws the same vocabulary without the runtime under it. ## OpenAI ChatKit [Section titled “OpenAI ChatKit”](#openai-chatkit) **The platform drop-in.** A web component (with a React wrapper) served by the OpenAI platform through a client token — streaming, tools, attachments and chain-of-thought visualisation without writing the front end. Self-hosting goes through their Python SDK. **Pick ChatKit when** you build on the OpenAI platform and want their hosted chat experience with the least code. **Pick aparté when** the model is not OpenAI’s, when the key must stay yours (browser-direct BYOK or [your own endpoint](/guides/backend-transport/)), when a model runs locally, or when you need theming beyond what a hosted surface exposes. The lock-in there is the vendor; aparté’s [transport](/why/) is the seam that keeps it out. ## Also in the field [Section titled “Also in the field”](#also-in-the-field) * **CopilotKit** — a category above: app-state sync with agents, generative UI, LangGraph co-agents. Not a chat component; if that is what you need, aparté is not it. * **NLUX** — React and vanilla, with a modified MPL licence to read before adopting. * **TanStack AI** — type-safe, framework-agnostic hooks with no UI (alpha). Closer to aparté’s engine than to its components. * **Chainlit** — Python full-stack; a different stack entirely. ## How to read this page [Section titled “How to read this page”](#how-to-read-this-page) aparté is alpha (`0.x`, released in lockstep, renames land as renames — see [Why aparté](/why/#where-it-is-in-its-life)). The comparison is written against that version; the catalogue rows will change as components land, and the runtime rows are the ones that define the project. If a claim above is wrong or out of date, [open an issue](https://github.com/apartejs/aparte/issues) — a comparison that flatters the author is worth nothing to the reader. # Chatbot UI Components > Every element aparté ships — what each one is for, in one line. Every element is a plain custom element: no framework, no runtime dependency, and the same object whatever you mount it with. A component made of parts is documented on one page, with its parts as sections. [Segments](/segments/text/) are the other family and a different kind of thing — data somebody renders, with no tag at all. There is a third family: the UI kit Buttons, fields, switches, tags, alerts, menus, tabs and avatars ship as **plain classes on plain elements** — no tag, themed by the same variables as the chat — for the controls your own page puts around it. They are listed with their HTML in [the UI kit](/kit/button/), one page per family with its live preview; a consumer who started here rewrote his own before finding them. ## The conversation [Section titled “The conversation”](#the-conversation) | Component | What it is | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | [Chat](/components/conversation/aparte-chat/) | AparteChat - The Shell The container element for a chat. | | [Chat viewport](/components/conversation/aparte-chat-viewport/) | The transcript surface: a light-DOM container with sticky scrolling, token streaming and segment-aware rendering. | | [Chat bubble](/components/conversation/aparte-chat-bubble/) | One message: plain content or a list of rich segments, in light DOM. | | [Chat status](/components/conversation/aparte-chat-status/) | A standalone status line — a light-DOM indicator the APP owns. | | [Context](/components/conversation/aparte-context/) | A gauge of the model’s context window: how much of it the conversation uses. | | [Conversation list](/components/conversation/aparte-conversation-list/) | Conversation-history sidebar — a framework-agnostic web component. | | [Scroll rail](/components/conversation/aparte-scroll-rail/) | A rail of ticks beside the transcript — one per turn — that shows where you are in a long conversation and jumps back to any message on a click. | ## Input [Section titled “Input”](#input) | Component | What it is | | ----------------------------------------------------------- | ------------------------------------------------------------------------------- | | [Composer](/components/input/aparte-composer/) *(+7 parts)* | The root context for every `aparte-composer-*` primitive. | | [Elicitation](/components/input/aparte-elicitation/) | The default presenter for a request to the human. | | [Suggestions](/components/input/aparte-suggestions/) | Prompt starters — a row of suggested prompts a reader clicks instead of typing. | ## The shell [Section titled “The shell”](#the-shell) | Component | What it is | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Sidebar](/components/shell/aparte-sidebar/) | The column beside the chat — conversations, a new-chat button, a search field, an account row — as an element, because it has behaviour: it collapses, it becomes a drawer on a narrow window, and its s | | [Split](/components/shell/aparte-split/) | Two panes and a draggable seam — a chat beside a preview, an editor, an artifact card. | ## Utility [Section titled “Utility”](#utility) | Component | What it is | | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | [Select](/components/utility/aparte-select/) *(+2 parts)* | Dropdown select for aparté — a vanilla web component with optional grouping, a search filter and a keyboard-driven listbox. | | [Icon](/components/utility/aparte-icon/) | AparteIcon The library’s icon set, reachable from MARKUP. | | [Progress spinner](/components/utility/aparte-progress-spinner/) | AparteProgressSpinner Circular progress spinner web component. | # Chat > AparteChat - The Shell The container element for a chat. `` AparteChat - The Shell The container element for a chat. It lays out its Light DOM children as a flex column: an `` takes the space left over (`flex: 1 1 auto`) and scrolls, an `` keeps its own height below it. Light DOM on purpose, so the page’s own global CSS reaches inside. The presence of an `` child at connect is the exact test for “the author composed this”. Find one and the children are used as given — this element moves none of them, so anything else you drop in (a header, a banner above the composer) is simply another row of that column, in DOM order. Find none and `innerHTML` is OVERWRITTEN with a default composition — a viewport, an `` presenter, and a composer shell holding an input and a send button, plus the two attachment primitives when `attachments` is set — so children written without a viewport anywhere inside them are destroyed, that header included. The test is a DESCENDANT query, so a viewport nested in a wrapper of your own still counts — compose it yourself with the viewport somewhere in the tree, or leave the tag empty. Angular’s wrapper sets `framework-managed` instead of relying on that test, because its children do not exist yet when this element upgrades; React, Vue and Svelte never create this element at all, so the question does not arise for them. Being a component (not a bare `
`), it also owns behaviour a wrapper div can’t: with `center-empty`, it watches its own viewport and keeps the composer centered as a welcome state until the first `` lands, then slides to the normal layout — no external JavaScript (`data-empty`, in the attribute list below, is that watcher’s output). The watcher needs a viewport somewhere inside, and hand-written markup always has one because composing the default injects it — so the only path where no watcher starts and `data-empty` is never set is `framework-managed`, where the framework owns the subtree anyway. The stylesheet centers through `aparte-chat[center-empty][data-empty]` and its DIRECT viewport child, so a framework-managed host that nests the viewport inside a container of its own gets nothing from the attribute — the wrappers ship their own centered layout. It is also one of the anchors where core re-declares its derived CSS layer, so overriding a master — `--aparte-primary`, a surface, a text colour — on a single `` re-derives the values computed from it for that instance rather than moving one button. That is per-instance theming. The literal palette is deliberately not re-declared here, so a chat nested in a dark wrapper stays dark. Presentational only: it does NOT wire a transport/client. Attach an `AparteClient`, or handle `aparte-send` yourself, as with the primitives. Size the element via CSS (a height, or let it fill a sized parent). Composing it yourself is the other form, and the container still lays it out and still runs `center-empty`. It is written out here rather than as a second `@example` for a mechanical reason: every element-own example is concatenated into ONE live frame on the generated reference page, so a second `` there rendered as a second whole chat — two empty composers with 600px of nothing between them. ```html
``` ## Usage [Section titled “Usage”](#usage) Width 375 768 Full [Open in a tab](/preview/aparte-chat/) [Live preview of \](/preview/aparte-chat/) The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes. ### Example [Section titled “Example”](#example) ```html ``` ## API [Section titled “API”](#api) ### Attributes [Section titled “Attributes”](#attributes) | Attribute | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `placeholder` | Placeholder for the composer input (default composition) | | `disabled` | Disables the composer | | `submit-on-enter` | Forwarded to the composer by value: `submit-on-enter="false"` makes Enter break the line and Shift+Enter send (the bare attribute, or none, keeps the default — Enter sends). The four wrappers expose the same switch as `submitOnEnter`. | | `center-empty` | Center the composer as a welcome state until the first message, then slide to the normal layout | | `attachments` | Add the file picker + chips strip to the default composition (opt-in: the host must consume the files — an `AparteClient` does, a hand-rolled loop must read `event.detail.files`) | | `data-empty` | Reflected BY the element while `center-empty` is set and no `` has landed in its viewport; the stylesheet centers the composer through `aparte-chat[center-empty][data-empty]`, and an app styles its welcome state against it. Never set without `center-empty`, and never under `framework-managed` (no viewport child to watch). Read-only. | | `overlay-composer` | The ChatGPT anatomy, opt-in: the transcript’s scroll surface spans the whole column and the composer (with the rest of the bottom stack) floats over it, so the scrollbar runs edge to edge instead of stopping at the composer’s top. The viewport measures the stack and publishes `--aparte-bottom-inset`; content, spacer and the scroll button clear it. Read when the viewport wires its observers — set it in the initial markup. Also honoured on a wrapper’s `[data-aparte-chat]` root. | | `framework-managed` | The wrapper’s explicit hands-off signal: set it and this element composes none of its own children, because the framework owns them. Read once at connect (it is not observed), so it has to be in the initial markup. Angular’s wrapper sets it on this element — its component selector IS `aparte-chat`; React/Vue/Svelte render a `[data-aparte-chat]` div and never create this element at all. | ### Properties [Section titled “Properties”](#properties) | Property | Type | Description | | ----------------------- | ---------------------------- | ---------------------------------------------------------------------- | | `viewport` *(readonly)* | `AparteChatViewport \| null` | The message viewport (yours or the default), or `null` before connect. | | `composer` *(readonly)* | `AparteComposer \| null` | The composer (yours or the default), or `null` before connect. | ## Theming [Section titled “Theming”](#theming) Override any of these on `:root`, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at `:root` moves everything that reads it. The full set is in the [CSS variables reference](/reference/css-variables/). ### `` [Section titled “\”](#aparte-chat) | Variable | Default | Description | | -------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--aparte-chat-bottom-gap` | `var(--aparte-space-8)` | Space below the composer, as `padding-block-end` on the shell (the same rule covers a wrapper’s `[data-aparte-chat]` root). The gap belongs to this element because padding applied from outside would also shrink the scroll area, stopping the transcript short of the edge instead of scrolling to it. | ## In a framework [Section titled “In a framework”](#in-a-framework) The element is the same object everywhere — **the tag does not change**. What changes is how an attribute is written and how an event reaches you. * Vanilla ```html ``` * React ```tsx ``` The `aparte-*` tags are typed JSX intrinsics as soon as you import from `@aparte/react`. A presence attribute takes `''`, never `true` — React stringifies it, and `disabled={false}` would render `disabled="false"`, which `hasAttribute` reads as on. * Vue ```vue ``` Declared through Vue’s `GlobalComponents`, so `vue-tsc` checks the tag in any template. A presence attribute takes `''` to set and `null` to remove, never `false`. * Svelte ```svelte ``` Declared through `SvelteHTMLElements`, so `svelte-check` covers the attributes and the `on:` handlers. A presence attribute takes `''`, never `false`. * Angular ```ts import { AparteChatComponent } from '@aparte/angular'; ``` ```html ``` A standalone component whose selector IS the tag, so the real element sits in the template — `@if`, `@for` and content projection all reach it — and no `CUSTOM_ELEMENTS_SCHEMA` is needed. Installation and the framework-specific traps: [React](/frameworks/react/) · [Vue](/frameworks/vue/) · [Svelte](/frameworks/svelte/) · [Angular](/frameworks/angular/). # Chat bubble > One message: plain content or a list of rich segments, in light DOM. `` One message: plain content or a list of rich segments, in light DOM. Normally created for you by ``, one per message in the store; you write the tag by hand only when you drive the DOM yourself. It is ONE message with one role — a transcript is the viewport’s job, and a bubble is not a general-purpose card. **Not a slot host.** `_render()` writes its own markup into the light DOM on connect, so children placed inside the tag are replaced rather than projected. Everything customizable is a registered hook instead of a child: the structural shell (`setBubbleShellRenderer` — it must root at `.aparte-message` and carry the region hooks, since every query here is null-guarded and a partial shell silently loses that region), the avatar (`setAvatarProvider`), the attachment chips (`setAttachmentRenderer`), the `‹1/2›` position indicator (`setSiblingNavRenderer`) and the body itself (`registerSegmentRenderer`). Two content paths, mutually exclusive: the `content` attribute (plain text run through the configured Markdown provider, then highlighted once — after streaming ends, not per token) and `setSegments()` / `addSegment()`. Segments win: `.aparte-content` stays hidden for as long as any exist. The painted `.aparte-message-content` box hides itself when there is nothing in it, so a message that is only attachments is not a coloured rectangle. The bubble owns no transport and no host behaviour. The action bar and the branch picker only dispatch the events below; nothing here retries a turn, persists an edit, opens a stats popover or switches a branch. Which buttons exist follows from that: `copy` is on by default, `edit` / `retry` / `feedback` need `setBubbleActions`, `info` needs both that flag and a prior `setUsage()` (a details button over no numbers is a dead button), and an image attachment becomes a preview button only once `setHostHandlers` declares a lightbox — undeclared it stays a picture, with no role, tab stop or pointer. The error state is derived from the segments (an `error` segment sets `data-error` on `.aparte-message`), never from a status attribute, so it behaves identically in vanilla and in every wrapper. All seven events are declared by hand rather than left to the analyser, which found six. `aparte-branch-navigate` is dispatched from the `_onBranchPickerClick` arrow class field, and the auto-detection visits `ts.isMethodDeclaration` only — so the one event belonging to the branch picker was the one missing from the manifest, and from the generated reference, for as long as both existed. ## Usage [Section titled “Usage”](#usage) Width 375 768 Full [Open in a tab](/preview/aparte-chat-bubble/) [Live preview of \](/preview/aparte-chat-bubble/) The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes. ### Example [Section titled “Example”](#example) ```html ``` ## API [Section titled “API”](#api) ### Attributes [Section titled “Attributes”](#attributes) | Attribute | Description | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data-role` | The message role, `user` or `assistant` — the one channel for it, and what the CSS keys off. (`role` is ARIA’s attribute; this element sets it to `article` itself and no longer reads a message role from it.) | | `content` | Plain text content, for a bubble with no segments. | | `timestamp` | Epoch milliseconds OR a date string: `_updateTimestamp` accepts either and only coerces when the value is numeric. | | `message-id` | How streaming and the action bar address this bubble. | | `streaming` | Hides the action bar and shows the caret while a reply is in flight. | | `name` | The display name in the header. | ### Methods [Section titled “Methods”](#methods) | Method | Description | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `appendToken(chunk: string): void` | Append a token chunk (for streaming) | | `setContent(content: string): void` | Set content directly | | `getContent(): string` | Get current content | | `setSegments(segments: AparteSegment[]): void` | Set segments for rich content | | `addSegment(segment: AparteSegment): void` | Add a segment | | `updateSegment(segmentId: string, updates: Partial<AparteSegment>): void` | Update a specific segment | | `appendToSegment(segmentId: string, content: string): void` | Append content to a segment | | `getSegments(): AparteSegment[]` | Get all segments | | `removeSegment(segmentId: string): void` | Scoped to DIRECT children on purpose. Segments are appended as direct children of the container, but a descendant query returns the first match in document order — and sanitized model markdown renders inside that same container, with `data-*` attributes deliberately preserved (they are inert). So a decoy `data-segment-id` planted in an earlier segment’s prose used to win over the real segment element. Parser ids are unguessable UUIDs, but a tool segment is `tool-${toolCallId}` and the MODEL chooses that id — so this was reachable, and pointing an update at a decoy left a rejected tool rendering as still-running: a spoof against the human-in-the-loop control. | | `setAttachments(attachments: AparteAttachment[]): void` | Set attachments (chips shown above message content, user role only) | | `setUsage(usage: AparteUsage \| null \| undefined): void` | Set token usage + timing for this message (assistant only). This is the *precondition* for the info (“i”) action, not the trigger: the button appears only if the app also declared it wants it — `aparteGlobalConfig.setBubbleActions({ info: true })` — because the stats popover it opens (`aparte-message-info`) is the app’s, and core has none. Without usage there is nothing to show, so the button never renders either way. | | `setSiblings(count: number, index: number): void` | Update the branch picker UI for tree-based navigation. The viewport calls this after a branch switch or re-render. Prev/Next clicks dispatch `aparte-branch-navigate` (bubbles: true) so the viewport can handle the actual tree switch. | | `updateMessage(updates: Partial<AparteMessage>): void` | Atomic update for the message | | `setTranscriptBusy(busy: boolean): void` | | ### Events [Section titled “Events”](#events) | Event | Type | Description | | --------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `aparte-attachment-preview` | `CustomEvent<AparteAttachmentPreviewEventDetail>` | An attached image was clicked, asking the app to open it full-size. | | `aparte-action` | `CustomEvent<AparteActionEventDetail>` | A custom action-bar button was pressed. | | `aparte-retry` | `CustomEvent<AparteRetryEventDetail>` | Retry was pressed; the host forks the turn. | | `aparte-feedback` | `CustomEvent<AparteFeedbackEventDetail>` | Thumbs up or down. | | `aparte-message-info` | `CustomEvent<AparteMessageInfoEventDetail>` | The info affordance was pressed. | | `aparte-edit` | `CustomEvent<AparteEditEventDetail>` | An edit was saved. | | `aparte-branch-navigate` | `CustomEvent<AparteBranchNavigateEventDetail>` | The `‹1/2›` picker moved between sibling versions. | | `aparte-link-click` | `CustomEvent<AparteLinkClickEventDetail>` | A link in the message body is about to be followed. Cancelable: `preventDefault()` keeps the browser from navigating, so a host can route the link itself. | ## Theming [Section titled “Theming”](#theming) Override any of these on `:root`, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at `:root` moves everything that reads it. The full set is in the [CSS variables reference](/reference/css-variables/). ### `` [Section titled “\”](#aparte-chat-bubble) | Variable | Default | Description | | ----------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--aparte-message-gap` | `var(--aparte-space-6)` | Gap between the avatar column and the body (the viewport reuses it between messages). | | `--aparte-message-padding-block` | `var(--aparte-space-8)` | Vertical padding of one message row. (The composite `--aparte-message-padding` is gone: joined on `:root`, it froze both parts, and the narrow-container override of them never reached the row.) | | `--aparte-message-padding-inline` | `var(--aparte-space-6)` | Horizontal padding of one message row. | | `--aparte-message-max-width` | `800px` | Width of the centred message row. | | `--aparte-message-content-radius` | `var(--aparte-radius-bubble)` | Radius of the painted content box. | | `--aparte-message-content-padding` | `var(--aparte-space-5) var(--aparte-space-7)` | Padding of the USER box only; the assistant’s content is plain full-width prose. | | `--aparte-message-content-bg-user` | — | Background of the user box: a wash of `--aparte-primary` over `--aparte-surface-1`, derived in `theme.css` so a rebrand moves it (declare it to override). | | `--aparte-message-content-bg-assistant` | `transparent` | Background of the assistant box — transparent on purpose (AI-chat convention, not messaging). | | `--aparte-message-content-text-user` | `var(--aparte-text)` | Text colour inside the user box. | | `--aparte-message-content-text-assistant` | `var(--aparte-text)` | Text colour inside the assistant box. | | `--aparte-avatar-size` | `32px` | Square size of the avatar slot. | | `--aparte-avatar-radius-ratio` | `0.28` | Avatar corner radius, as a fraction of its size (the corner follows the ramp; 0.28 × 32px is the 9px it used to be). | | `--aparte-avatar-initials-ratio` | `0.34` | Size of the initial, as a fraction of the avatar’s size. | | `--aparte-avatar-bg-user` | `var(--aparte-primary)` | Avatar background, user role. | | `--aparte-avatar-text-user` | `var(--aparte-text-inverse)` | Avatar text colour, user role. | | `--aparte-avatar-bg-assistant` | `var(--aparte-surface-3)` | Avatar background, assistant role. | | `--aparte-avatar-text-assistant` | `var(--aparte-text)` | Avatar text colour, assistant role. | | `--aparte-avatar-image-user` | `none` | `background-image` for the user avatar — a logo with no AvatarProvider and no JS. | | `--aparte-avatar-image-assistant` | `none` | `background-image` for the assistant avatar. | | `--aparte-avatar-image-size` | `90%` | `background-size` for both avatar images. | | `--aparte-name-font-size` | `var(--aparte-font-size-md)` | Sender name in the header. | | `--aparte-name-color` | `var(--aparte-text)` | Sender name colour. | | `--aparte-timestamp-font-size` | `var(--aparte-font-size-sm)` | Timestamp in the header. | | `--aparte-timestamp-color` | `var(--aparte-text-muted)` | Timestamp colour. | | `--aparte-content-font-size` | `var(--aparte-font-size-base)` | Body type size, applied to both the plain-content and the segments container. | | `--aparte-content-color` | `var(--aparte-text)` | Body text colour. | | `--aparte-content-line-height` | `var(--aparte-line-height-loose)` | Body line height. | | `--aparte-attachments-max-height` | `140px` | Cap on the sent-attachment strip; past it the strip scrolls instead of growing. | | `--aparte-attachment-image-size` | `72px` | Tile size in the strip. The strip re-declares the global 72px down to 40px, since these are thumbnails inside a conversation. | | `--aparte-thumb-radius` | `var(--aparte-radius-lg)` | Attachment tile radius (shared with the composer’s preview tiles). | | `--aparte-thumb-name-color` | `#ffffff` | Filename overlaid on a tile. | | `--aparte-thumb-name-scrim` | — | Gradient behind that filename, so it stays legible over any image. | | `--aparte-thumb-name-padding` | `14px 5px 4px` | Padding of the filename overlay. | | `--aparte-action-bar-gap` | `var(--aparte-space-2)` | Gap between action buttons (and between the footer’s two regions). | | `--aparte-action-bar-btn-size` | `var(--aparte-btn-size-sm)` | Square size of an action button (the recipe’s small step); also the footer’s reserved height. | | `--aparte-action-bar-btn-color` | `var(--aparte-text-muted)` | Action icon colour at rest. | | `--aparte-action-bar-btn-hover-bg` | `var(--aparte-surface-2)` | Action button hover background (the branch arrows reuse it). | | `--aparte-action-bar-btn-hover-color` | `var(--aparte-text)` | Action icon colour on hover. | | `--aparte-branch-picker-gap` | `var(--aparte-space-2)` | Gap between the arrows and the position label. | | `--aparte-branch-picker-btn-color` | `var(--aparte-text-muted)` | Arrow colour at rest. | | `--aparte-branch-picker-btn-hover-color` | `var(--aparte-text)` | Arrow colour on hover (a disabled arrow is dimmed instead). | | `--aparte-branch-picker-label-size` | `var(--aparte-font-size-sm)` | Type size of the position label. | | `--aparte-branch-picker-label-color` | `var(--aparte-text-muted)` | Colour of the position label. | | `--aparte-branch-picker-label-min-width` | `32px` | Reserved label width, so `9 / 9` growing to `10 / 12` does not shift the arrows. | | `--aparte-waiting-height` | `1.5em` | Min height of the waiting region, so the first token does not jump the layout. | | `--aparte-waiting-dot-gap` | `var(--aparte-space-2)` | Gap between the three waiting dots. | | `--aparte-status-dot-size` | `6px` | Diameter of a waiting dot (shared with the status indicator). | | `--aparte-status-color` | `var(--aparte-text-muted)` | Colour of the waiting dots (shared with the status indicator). | | `--aparte-error-solid` | `#dc2626` | Ring drawn around the avatar while `data-error` is set. The error CARD itself belongs to the error segment renderer. | ## In a framework [Section titled “In a framework”](#in-a-framework) The element is the same object everywhere — **the tag does not change**. What changes is how an attribute is written and how an event reaches you. * Vanilla ```html ``` ```js el.addEventListener('aparte-attachment-preview', (e) => use(e.detail)); ``` * React ```tsx ``` The `aparte-*` tags are typed JSX intrinsics as soon as you import from `@aparte/react`. A presence attribute takes `''`, never `true` — React stringifies it, and `streaming={false}` would render `streaming="false"`, which `hasAttribute` reads as on. Events reach you by ref, typed through the DOM. * Vue ```vue ``` Declared through Vue’s `GlobalComponents`, so `vue-tsc` checks the tag in any template. A presence attribute takes `''` to set and `null` to remove, never `false`. * Svelte ```svelte use(e.detail)} > ``` Declared through `SvelteHTMLElements`, so `svelte-check` covers the attributes and the `on:` handlers. A presence attribute takes `''`, never `false`. * Angular ```ts import { AparteChatBubbleDirective } from '@aparte/angular'; ``` ```html ``` A standalone directive whose selector IS the tag, so the real element sits in the template — `@if`, `@for` and content projection all reach it — and no `CUSTOM_ELEMENTS_SCHEMA` is needed. Installation and the framework-specific traps: [React](/frameworks/react/) · [Vue](/frameworks/vue/) · [Svelte](/frameworks/svelte/) · [Angular](/frameworks/angular/). # Chat status > A standalone status line — a light-DOM indicator the APP owns. `` A standalone status line — a light-DOM indicator the APP owns. Nothing in core turns it on: the framework host only ever flips it back OFF (on the first streamed token), and the four wrappers render one inside the viewport driven by their own `isTyping` prop. It dispatches nothing, deliberately: it reports, it does not ask. Use it for a state only the app knows about — “Searching the docs…”, “Uploading…”, a queue position. It is NOT the indicator for the gap between a send and the first token: that one is built into the bubble (`.aparte-waiting`, shown while `streaming` is set on a non-user bubble that has nothing to display yet). Turning this element on for that gap is how a page ends up showing two indicators for one wait. It does not project children: `_render()` writes the subtree — a `.aparte-status-container` row holding an empty avatar div and an `.aparte-body` that wraps `.aparte-status-content` — so markup authored between the tags does not survive. That avatar div never gets contents here, and `.aparte-avatar:empty` is `display: none`, so it is not a spacer: the line sits flush with the row padding rather than indented under an assistant bubble’s text column. The seam for custom contents is `setStatusRenderer`, scoped or global: the container keeps owning show/hide (`data-visible`), the accessible name (`aria-label`) and its `.aparte-message` row metrics whatever the renderer returns, but the pulsing dot and the text node belong to the default path only. A `text` attribute renders the visible label and feeds the accessible name; with no `text` the line is dots-only and the name falls back to the literal `Typing` — this element does not read the locale. The container is a polite live region, and a live region announces its CONTENT, not its `aria-label`: so the word also goes INTO the region, in the visible span when `text` is set and in a screen-reader-only span when it is not. Exactly one of the two is ever populated — both would be read twice. That fallback word is driven by `visible`, not written once at render: a live region is announced when its content CHANGES while it is exposed, and every wrapper mounts this element hidden and flips the attribute — so a word written at render time is a word the region already held when it appeared, the reveal-from-`display: none` path, which is the one screen readers are documented not to announce reliably. Hiding happens twice over: the host element is `display: none` without `[visible]`, and `data-visible` drives the fade/translate on the container. The config is resolved live rather than cached, so a `setStatusRenderer` call that lands after this element has already upgraded still reaches it: the element re-renders on `aparte-config-change`, filtered to its own config. The two borrowed row variables below have one scope caveat: inside a viewport narrower than 520px core REASSIGNS `--aparte-message-padding-block` and `--aparte-message-padding-inline` on `.aparte-message` itself, so a declaration on this host element loses to them there. ## Usage [Section titled “Usage”](#usage) Width 375 768 Full [Open in a tab](/preview/aparte-chat-status/) [Live preview of \](/preview/aparte-chat-status/) The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes. ### Example [Section titled “Example”](#example) ```html ``` ## API [Section titled “API”](#api) ### Attributes [Section titled “Attributes”](#attributes) | Attribute | Description | | --------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `visible` | Shows or hides the indicator. | | `text` | The line to show. Absent, the line is dots-only and the accessible name falls back to the literal `Typing` (not the locale’s string). | ### Methods [Section titled “Methods”](#methods) | Method | Description | | ---------------------- | ------------------------- | | `show(): void` | Show the typing indicator | | `hide(): void` | Hide the typing indicator | | `toggle(): void` | Toggle visibility | | `isVisible(): boolean` | Check if visible | ## Theming [Section titled “Theming”](#theming) Override any of these on `:root`, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at `:root` moves everything that reads it. The full set is in the [CSS variables reference](/reference/css-variables/). ### `` [Section titled “\”](#aparte-chat-status) | Variable | Default | Description | | --------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--aparte-status-color` | `var(--aparte-text-muted)` | Colour of the label text and of the pulsing dot in the default line. | | `--aparte-status-font-size` | `var(--aparte-font-size-md)` | Size of the visible label (italic by default) in the default line. | | `--aparte-status-dot-size` | `6px` | Diameter of the single pulsing dot in the default line. | | `--aparte-message-padding-block` | `var(--aparte-space-8)` | Vertical padding of the row, read because the container also carries `.aparte-message` — the status line borrows a bubble’s row metrics so it lines up with the transcript. | | `--aparte-message-padding-inline` | `var(--aparte-space-6)` | Horizontal padding of the row, same reason. | | `--aparte-message-max-width` | `800px` | Width cap of that same row. | ## In a framework [Section titled “In a framework”](#in-a-framework) The element is the same object everywhere — **the tag does not change**. What changes is how an attribute is written and how an event reaches you. * Vanilla ```html ``` * React ```tsx ``` The `aparte-*` tags are typed JSX intrinsics as soon as you import from `@aparte/react`. A presence attribute takes `''`, never `true` — React stringifies it, and `visible={false}` would render `visible="false"`, which `hasAttribute` reads as on. * Vue ```vue ``` Declared through Vue’s `GlobalComponents`, so `vue-tsc` checks the tag in any template. A presence attribute takes `''` to set and `null` to remove, never `false`. * Svelte ```svelte ``` Declared through `SvelteHTMLElements`, so `svelte-check` covers the attributes and the `on:` handlers. A presence attribute takes `''`, never `false`. * Angular ```ts import { AparteChatStatusDirective } from '@aparte/angular'; ``` ```html ``` A standalone directive whose selector IS the tag, so the real element sits in the template — `@if`, `@for` and content projection all reach it — and no `CUSTOM_ELEMENTS_SCHEMA` is needed. Installation and the framework-specific traps: [React](/frameworks/react/) · [Vue](/frameworks/vue/) · [Svelte](/frameworks/svelte/) · [Angular](/frameworks/angular/). # Chat viewport > The transcript surface: a light-DOM container with sticky scrolling, token streaming and segment-aware rendering. `` The transcript surface: a light-DOM container with sticky scrolling, token streaming and segment-aware rendering. Features: * Smart Scroll: Sticks to bottom when user is at bottom, stops on manual scroll up * appendToken(): For simple content streaming * appendToSegment(): For segment-aware streaming (thinking, code, etc.) Two DOM modes. By default the element builds its own scroll surface (`.aparte-viewport-container`) around a `.aparte-messages-wrapper`, and creates the `` elements itself (the last `max-rendered-bubbles` of the active path). With `framework-managed` set it builds neither wrapper: the HOST is the scroll surface, the framework owns the bubble elements, and the bottom spacer becomes additive host padding instead of an element — a relocated or removed child is what desynchronises a framework’s view tree from the live DOM, so this mode touches neither. The one child it appends in both modes is the scroll-to-bottom button, kept trailing. Children you write inside the element are just children: there is no shadow root and no slot to target. In the default mode they are MOVED into the internal `.aparte-messages-wrapper` at first render, ahead of the bottom spacer, so pre-rendered `` elements land in the transcript flow. A custom element of your own is relocated the same way, and if it carries `data-aparte-bubble` plus a matching `message-id` it also receives the live token and segment pushes, not just a restyle. Do not expect such a child to outlive the transcript, though: anything that re-renders the active path (`addBranch`, `addSiblingOf`, `navigateBranch`, `importTree`) empties the wrapper and rebuilds it from the repository, so only what the repository holds comes back — and `clearAll()` removes `` nodes only, so a `[data-aparte-bubble]` element of your own is left behind with nothing left to render. With `framework-managed` set children are not relocated: they stay direct children of the host, which is itself the scroll surface. Messages are held as a TREE (siblings, branches, an active path), which is what lets a retry fork and a bubble’s sibling picker navigate with no host object involved. What it is NOT is storage. `max-rendered-bubbles` is a DOM ceiling and never evicts from the repository — the full tree and its snapshot stay complete, `exportTree()` / `importTree()` hand that snapshot to whoever owns persistence, and real history retention is configured on the conversation manager instead. It is not a chat either: a bare viewport IS a valid `AparteClient` target, but the composer, the transport and the shell layout are other elements. ## Usage [Section titled “Usage”](#usage) Width 375 768 Full [Open in a tab](/preview/aparte-chat-viewport/) [Live preview of \](/preview/aparte-chat-viewport/) The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes. ### Example [Section titled “Example”](#example) ```html ``` ```ts // Three calls are a whole streamed turn. const viewport = document.querySelector('aparte-chat-viewport')!; viewport.appendMessage({ id: 'a1', role: 'assistant', content: '', timestamp: Date.now() }); for await (const chunk of tokens) viewport.appendToken('a1', chunk); viewport.completeMessage('a1'); // stops the streaming caret ``` ## API [Section titled “API”](#api) ### Attributes [Section titled “Attributes”](#attributes) | Attribute | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scroll-threshold` | How close to the bottom still counts as “at the bottom”. | | `max-rendered-bubbles` | Caps how many bubbles stay in the DOM; older ones are released. | | `framework-managed` | The wrapper’s explicit hands-off signal: set it and this element builds no wrapper of its own and relocates none of the nodes the FRAMEWORK renders into it, because the framework owns them. Not “none of its children”: core’s own scroll-to-bottom button is re-appended whenever it stops being last, and that path runs in this mode only. All four wrappers set it. | | `data-busy` | Reflected BY the element while a turn streams: the transcript is read-only meanwhile, and every bubble inside reads it (at connect, and when it changes). The vanilla path derives it from the repository; a framework host sets it through `setTranscriptBusy()`. Read-only from the outside. | ### Methods [Section titled “Methods”](#methods) | Method | Description | | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `configure(config: AparteViewportConfig): void` | Configure viewport with options | | `appendToken(messageId: string, chunk: string): void` | Append a token chunk to a message’s content (simple text streaming) | | `appendToSegment(messageId: string, segmentId: string, chunk: string): void` | Append content to a specific segment within a message | | `setTranscriptBusy(busy: boolean): void` | The transcript’s read-only-while-streaming flag — `data-busy` on this element and fanned out to the bubbles it holds. The vanilla path derives it from the repository (`_syncBusy`); a framework host, whose messages live outside the repository during a turn, writes it directly from its own streaming id. | | `addSegment(segment: AparteSegment): void` | Add a new segment. Two calling conventions are accepted: - `addSegment(segment)` — AparteClient’s 1-arg “operate on the current (head) message” convention (also what a wrapper host installs); - `addSegment(messageId, segment)` — explicit standalone form. The first argument’s type disambiguates (string = messageId, object = segment), so a raw viewport driven by `AparteClient` no longer drops text (the args used to bind one position short, creating a phantom message). | | `addSegment(messageId: string, segment: AparteSegment): void` | Add a new segment. Two calling conventions are accepted: - `addSegment(segment)` — AparteClient’s 1-arg “operate on the current (head) message” convention (also what a wrapper host installs); - `addSegment(messageId, segment)` — explicit standalone form. The first argument’s type disambiguates (string = messageId, object = segment), so a raw viewport driven by `AparteClient` no longer drops text (the args used to bind one position short, creating a phantom message). | | `updateSegment(segmentId: string, updates: Partial<AparteSegment>): void` | Update a segment. `updateSegment(segmentId, updates)` (1-arg client convention → current message) or `updateSegment(messageId, segmentId, updates)` (explicit). Disambiguated by arity: the 3rd arg is absent and the 2nd is the `updates` object in the 1-arg form. | | `updateSegment(messageId: string, segmentId: string, updates: Partial<AparteSegment>): void` | Update a segment. `updateSegment(segmentId, updates)` (1-arg client convention → current message) or `updateSegment(messageId, segmentId, updates)` (explicit). Disambiguated by arity: the 3rd arg is absent and the 2nd is the `updates` object in the 1-arg form. | | `removeSegment(segmentId: string): void` | Remove a segment. `removeSegment(segmentId)` (1-arg client convention → current message) or `removeSegment(messageId, segmentId)` (explicit). | | `removeSegment(messageId: string, segmentId: string): void` | Remove a segment. `removeSegment(segmentId)` (1-arg client convention → current message) or `removeSegment(messageId, segmentId)` (explicit). | | `startSegment(messageId: string, segment: AparteSegment): void` | Start a new streaming segment (e.g., thinking or code block) Creates the segment and marks it as streaming | | `completeSegment(messageId: string, segmentId: string): void` | Complete a streaming segment | | `setUsage(messageId: string, usage: AparteUsage): void` | Persist token usage on a message and propagate to the live bubble, which is what allows the info (“i”) action to render — provided the app declared it with `aparteGlobalConfig.setBubbleActions({ info: true })`; it is off by default, since the popover it opens belongs to the app. | | `completeMessage(messageId: string): void` | Mark a message as finished streaming | | `updateMessage(messageId: string, updates: Partial<AparteMessage>): void` | Atomic update for a message by ID Supports updating content, status, segments, and other metadata | | `addMessage(message: AparteMessage): void` | Add a complete message to the message registry. | | `appendMessage(message: AparteMessage, options?: { historical?: boolean }): void` | Append a new message and create its bubble in the DOM. Implements the same contract as the Angular wrapper’s appendMessage(), making aparte-chat-viewport a fully standalone target for aparte-client. When `_frameworkManagedDOM` is true, only the internal repo is updated — the framework owns the DOM and will create the bubble element itself. | | `updateLastMessage(content: string, options?: { append?: boolean }): void` | Update the last message content, optionally appending. Implements the same contract as the Angular wrapper’s updateLastMessage(),’ making aparte-chat-viewport a fully standalone streaming target for aparte-client. | | `addBranch(messageId: string): void` | Add a new sibling branch to an assistant message (retry flow). Creates a new empty assistant message as a sibling of `messageId` under the same parent, switches the active branch to it, and re-renders the active path. | | `addSiblingOf(existingId: string, newMessage: AparteMessage): string \| null` | Add a new message relative to `existingId`, switch to it, and re-render. Role-aware semantics: - existingId is an **assistant** message → create a sibling (same parent), so the active path replaces the old response with the new one. - existingId is a **user** message → create a child of that message, so the user message stays on the active path and the new response follows it. Returns the new message’s ID, or null if `existingId` is not found. | | `navigateBranch(messageId: string, direction: 'prev' \| 'next'): void` | Navigate to the previous or next sibling branch of a message. Triggers a full re-render of the active path. | | `truncateResponsesAfter(userMessageId: string): void` | Remove ALL responses to a user message (every child branch) and set head back to `userMessageId`. Cleaner than `truncateFrom` for edit flows: it discards stale sibling branches so the regenerated response starts alone. | | `truncateFrom(messageId: string): void` | Remove all messages from `messageId` onwards (inclusive) from state and DOM. Used by edit to truncate history before re-generating. | | `getMessage(messageId: string): AparteMessage \| undefined` | Get a message by ID | | `getMessages(): AparteMessage[]` | The messages on the currently ACTIVE path, root → head — not the whole tree. A message that was retried contributes only the branch currently selected; `exportTree()` is what returns every sibling. | | `exportTree(): ExportedMessageRepository` | Export the full conversation tree (all branches, not just the active path). The returned snapshot can be persisted and restored via `importTree()`. | | `importTree(tree: ExportedMessageRepository): void` | Import a previously-exported tree snapshot, restoring the full branch topology and the active head. Replaces any existing repo content. Always calls `_reRenderActivePath()`: - In native DOM mode: rebuilds bubble elements. - In framework-managed mode: skips DOM manipulation but dispatches `aparte-path-changed` with sibling metadata so the wrapper can update branch arrows on already-rendered bubbles. | | `clearAll(options?: { revokeAttachments?: boolean }): void` | Clear all messages and remove all bubble elements from the DOM. Also dispatches a aparte-reset-done event. In framework-managed mode the DOM is owned by the host framework (Angular | | `clearMessages(): void` | Clear all messages | | `setMessages(messages: AparteMessage[]): void` | Replace the entire message list in one shot. Used when switching conversations: clears existing repo + DOM, then appends each message. In framework-managed mode the framework re-renders the bubble DOM itself; we only update the internal repo (used by aparte-client to build chat history). | | `scrollToBottom(): void` | Scroll to bottom of viewport | | `resetSpacer(): void` | Reset the bottom spacer to 0 height immediately and freeze it for 350 ms so the host-app layout transition (e.g. flex: 0→1 animation) does not trigger a premature recalculation with mid-animation geometry. Call before a full messages swap. | | `setAutoScroll(enabled: boolean): void` | Enable or disable auto-scroll | | `setFrameworkManagedDOM(managed: boolean): void` | Signal that a framework (e.g. Angular) manages the bubble DOM. When true, branch navigation dispatches `aparte-path-changed` without clearing/rebuilding the messages wrapper — the framework re-renders instead. | | `requestSmoothScroll(): void` | Request that the next auto-scroll triggered by a DOM mutation uses smooth behaviour instead of instant. Call this just before adding a user message bubble so the viewport animates down rather than jumping. Resets automatically after the first auto-scroll fires. | ### Events [Section titled “Events”](#events) | Event | Type | Description | | ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------- | | `aparte-segment-update` | `CustomEvent<AparteSegmentUpdateEventDetail>` | A segment grew or settled during a stream. | | `aparte-reset-done` | `CustomEvent` | `clearAll()` finished emptying the transcript. No detail. | | `aparte-path-changed` | `CustomEvent<ApartePathChangedEventDetail>` | The active branch path changed, after a retry fork or a navigation. | ## Theming [Section titled “Theming”](#theming) Override any of these on `:root`, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at `:root` moves everything that reads it. The full set is in the [CSS variables reference](/reference/css-variables/). ### `` [Section titled “\”](#aparte-chat-viewport) | Variable | Default | Description | | ---------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--aparte-viewport-padding` | `var(--aparte-space-8)` | Padding around the transcript — on `.aparte-messages-wrapper`, or on the host itself in framework-managed mode, where the auto-scroll spacer is added on top of it. A container narrower than 520px tightens it in the default mode only: that rule reassigns the variable on `.aparte-messages-wrapper`, which framework-managed mode never builds. | | `--aparte-message-gap` | `var(--aparte-space-6)` | Gap between consecutive bubbles in the transcript column (both DOM modes). Shared: it is also the avatar-to-content gap inside a bubble. | | `--aparte-scrollbar-thumb` | `var(--aparte-neutral)` | Colour of the transcript’s scrollbar thumb. A host page with a scrollbar of its own sets this and the track so the chat’s does not read as a second, foreign scrollbar. | | `--aparte-scrollbar-track` | `transparent` | Colour of the transcript’s scrollbar track. | | `--aparte-scrollbar-width` | `6px` | Width of the WebKit scrollbar on the scroll surface. Firefox and the standard property use `scrollbar-width: thin` and ignore it. | | `--aparte-transcript-inset` | `var(--aparte-viewport-padding)` | Written BY the viewport on the chat host: how far from the host’s inline edge its rows start (padding plus the scrollbar gutter, at the current container step). The composer pads by it, so the two boxes share one edge at every width. Read-only from the outside. | | `--aparte-bottom-inset` | `0px` | How much of the transcript’s bottom is covered by content floating over it. Written by the viewport itself under `[overlay-composer]` (never set it there — it would be overwritten); a host that overlays a composer of its own, without the attribute, sets it by hand and the spacer, the container padding and the scroll button all clear it. | | `--aparte-scroll-btn-size` | `var(--aparte-btn-size-lg)` | Diameter of the scroll-to-bottom button. A coarse pointer raises it to `--aparte-touch-target-size`. | | `--aparte-scroll-btn-shadow` | `0 2px 8px rgba(0, 0, 0, 0.12)` | Its shadow; the dark theme sets a heavier one. | ## In a framework [Section titled “In a framework”](#in-a-framework) The element is the same object everywhere — **the tag does not change**. What changes is how an attribute is written and how an event reaches you. * Vanilla ```html ``` ```js el.addEventListener('aparte-segment-update', (e) => use(e.detail)); ``` * React ```tsx ``` The `aparte-*` tags are typed JSX intrinsics as soon as you import from `@aparte/react`. A presence attribute takes `''`, never `true` — React stringifies it, and `framework-managed={false}` would render `framework-managed="false"`, which `hasAttribute` reads as on. Events reach you by ref, typed through the DOM. * Vue ```vue ``` Declared through Vue’s `GlobalComponents`, so `vue-tsc` checks the tag in any template. A presence attribute takes `''` to set and `null` to remove, never `false`. * Svelte ```svelte use(e.detail)} > ``` Declared through `SvelteHTMLElements`, so `svelte-check` covers the attributes and the `on:` handlers. A presence attribute takes `''`, never `false`. * Angular ```ts import { AparteChatViewportDirective } from '@aparte/angular'; ``` ```html ``` A standalone directive whose selector IS the tag, so the real element sits in the template — `@if`, `@for` and content projection all reach it — and no `CUSTOM_ELEMENTS_SCHEMA` is needed. Installation and the framework-specific traps: [React](/frameworks/react/) · [Vue](/frameworks/vue/) · [Svelte](/frameworks/svelte/) · [Angular](/frameworks/angular/). # Context > A gauge of the model's context window: how much of it the conversation uses. `` A gauge of the model’s context window: how much of it the conversation uses. It reads two numbers and draws their ratio. The USED part is what each turn reports — `aparte-message-done` carries the provider’s usage, and the prompt tokens of the last call are the size of the context as the model saw it. The WINDOW is the current model’s `contextWindow` (a provider’s `/models` fetch fills it in), or the `window` attribute when you know better. With no window, or before the first turn, it renders nothing — an affordance with nothing to show has no chrome to leave. Two thresholds turn it `warn` then `danger` (`data-level`), and crossing one fires `aparte-context-threshold`. With `auto-compact`, reaching `danger` dispatches `aparte-compact` for its chat — once, until the level drops again — which is what a gauge that turns red and then does nothing was missing. What answers the command is `@aparte/plugin-compaction` (`setupCompaction()`): it summarises what no longer fits the window and keeps the recent turns. Core itself does not compact — the gauge asks, the plugin does, and a page without the plugin gets a gauge that only measures. The bar wears the `aparte-progress` recipe. `variant="ring"` draws the same reading as a ring with the percentage beside it — for a toolbar, where a bar wants a width and a ring wants none; the full reading is the ring’s `title`. The two share the levels, the events and the accessible name; only the drawing differs. ## Usage [Section titled “Usage”](#usage) Width 375 768 Full [Open in a tab](/preview/aparte-context/) [Live preview of \](/preview/aparte-context/) The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes. ### Example [Section titled “Example”](#example) ```html ``` ## API [Section titled “API”](#api) ### Attributes [Section titled “Attributes”](#attributes) | Attribute | Description | | -------------- | ---------------------------------------------------------------------------------------------------- | | `window` | The context window, in tokens. Overrides the current model’s `contextWindow`. | | `warn` | Fraction of the window at which the level turns `warn`. Default `0.75`. | | `danger` | Fraction at which it turns `danger`. Default `0.9`. | | `target` | The id of the `` to watch, when the element is not under it. | | `variant` | `bar` (default) or `ring`: a progress bar with the reading beside it, or a ring with the percentage. | | `auto-compact` | Dispatch `aparte-compact` on reaching `danger` (once per crossing). | | `data-level` | Reflected BY the element: `ok`, `warn` or `danger`. Read-only. | | `data-empty` | Reflected BY the element while it has nothing to show. Read-only. | ### Properties [Section titled “Properties”](#properties) | Property | Type | Description | | --------------------- | ---------------------------- | --------------------------------------------------------------- | | `used` *(readonly)* | `number \| null` | Tokens the last turn reported, or `null` before the first turn. | | `window` *(readonly)* | `number \| null` | The window in force — the attribute, else the current model’s. | | `level` *(readonly)* | `AparteContextLevel \| null` | The current level, or `null` while nothing is shown. | ### Events [Section titled “Events”](#events) | Event | Type | Description | | -------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `aparte-context-threshold` | `CustomEvent<AparteContextThresholdEventDetail>` | The level changed. Bubbles. | | `aparte-compact` | `CustomEvent<AparteCompactEventDetail>` | Dispatched on `window` when `auto-compact` is set and the gauge first reaches `danger`: compact this target’s transcript. Once per crossing, and only while the attribute is present. `@aparte/plugin-compaction` answers it; with no listener nothing happens. | ## Theming [Section titled “Theming”](#theming) Override any of these on `:root`, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at `:root` moves everything that reads it. The full set is in the [CSS variables reference](/reference/css-variables/). ### `` [Section titled “\”](#aparte-context) | Variable | Default | Description | | ---------------------------- | ------- | ----------------------------- | | `--aparte-context-ring-size` | `22px` | Diameter of the ring variant. | ## In a framework [Section titled “In a framework”](#in-a-framework) The element is the same object everywhere — **the tag does not change**. What changes is how an attribute is written and how an event reaches you. * Vanilla ```html ``` ```js el.addEventListener('aparte-context-threshold', (e) => use(e.detail)); ``` * React ```tsx ``` The `aparte-*` tags are typed JSX intrinsics as soon as you import from `@aparte/react`. A presence attribute takes `''`, never `true` — React stringifies it, and `auto-compact={false}` would render `auto-compact="false"`, which `hasAttribute` reads as on. Events reach you by ref, typed through the DOM. * Vue ```vue ``` Declared through Vue’s `GlobalComponents`, so `vue-tsc` checks the tag in any template. A presence attribute takes `''` to set and `null` to remove, never `false`. * Svelte ```svelte use(e.detail)} > ``` Declared through `SvelteHTMLElements`, so `svelte-check` covers the attributes and the `on:` handlers. A presence attribute takes `''`, never `false`. * Angular ```ts import { AparteContextDirective } from '@aparte/angular'; ``` ```html ``` A standalone directive whose selector IS the tag, so the real element sits in the template — `@if`, `@for` and content projection all reach it — and no `CUSTOM_ELEMENTS_SCHEMA` is needed. Installation and the framework-specific traps: [React](/frameworks/react/) · [Vue](/frameworks/vue/) · [Svelte](/frameworks/svelte/) · [Angular](/frameworks/angular/). # Conversation list > Conversation-history sidebar — a framework-agnostic web component. `` Conversation-history sidebar — a framework-agnostic web component. The host sets the `conversations` JS property and the `active-id` attribute; this renders the rows and fires the user’s intent, never acting on it itself. A row is two real buttons: the title, which selects, and a `⋯` that opens the row’s menu — rename, pin or unpin, archive or unarchive, delete. Every item fires an event and stops; the one exception is delete, which asks first, inline in the menu, because it is the one action the host cannot undo. Rename swaps the title for an input: Enter or leaving the field commits, Escape cancels, and an unchanged or emptied title fires nothing. The rows are grouped by date — Pinned, Today, Yesterday, Previous 7 days, Previous 30 days, then one heading per month — as soon as any item carries `updatedAt`; set `no-groups` to render them flat. Children are not a composition point: `_render()` assigns `innerHTML` from the `conversations` array, so any light-DOM child a host writes inside the element is discarded the next time the list renders — and switching this element’s locale is enough to trigger one. Compose around the element, not inside it: it renders rows and nothing else, with no header, no new-conversation button and no search field. What it is not: a store. Selecting, renaming, pinning, archiving and deleting all leave the array untouched — the events carry an id (and, for rename, the title) and stop. A row’s text comes from the array, so it changes when the host assigns `conversations` again; the exception is an empty title, which falls back to the locale’s new-chat label and therefore follows a locale switch. An archived item is still rendered (it gains `aparte-conv-item--archived`); filtering archived conversations out of the list is the host’s decision, not this element’s. The asymmetry between the two inputs is deliberate: `active-id` is an attribute because moving the selection patches the rendered rows in place, while `conversations` is a JS property because it is structured data an attribute cannot carry, and setting it re-renders the whole list. ## Usage [Section titled “Usage”](#usage) Width 375 768 Full [Open in a tab](/preview/aparte-conversation-list/) [Live preview of \](/preview/aparte-conversation-list/) The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes. ### Example [Section titled “Example”](#example) ```html ``` ```ts // The host owns the data: set the `conversations` property, listen for the intent. const list = document.querySelector('aparte-conversation-list')!; list.conversations = [ { id: 'c1', title: 'Deploy checklist', updatedAt: Date.now() }, { id: 'c2', title: 'Old thread', updatedAt: 0, archivedAt: Date.now() }, ]; list.setAttribute('active-id', 'c1'); list.addEventListener('aparte-select-conversation', (e) => load(e.detail.id)); list.addEventListener('aparte-rename-conversation', (e) => manager.updateTitle(e.detail.id, e.detail.title)); list.addEventListener('aparte-pin-conversation', (e) => manager.pin(e.detail.id)); list.addEventListener('aparte-delete-conversation', (e) => manager.delete(e.detail.id)); ``` ## API [Section titled “API”](#api) ### Attributes [Section titled “Attributes”](#attributes) | Attribute | Description | | ----------- | ----------------------------------------------------------- | | `active-id` | The id of the conversation to render as selected. | | `no-groups` | Render the rows flat, in host order, with no date headings. | ### Properties [Section titled “Properties”](#properties) | Property | Type | Description | | --------------- | ------------------------------ | --------------------------------------------------------------- | | `conversations` | `AparteConversationListItem[]` | Set the list of conversations to display. Triggers a re-render. | ### Events [Section titled “Events”](#events) | Event | Type | Description | | ------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------- | | `aparte-unpin-conversation` | `CustomEvent<AparteConversationPinDetail>` | The same item on a pinned row; same detail shape, opposite intent. | | `aparte-pin-conversation` | `CustomEvent<AparteConversationPinDetail>` | The pin item was chosen on an unpinned row. | | `aparte-unarchive-conversation` | `CustomEvent<AparteConversationArchiveDetail>` | The same item on an already-archived one; same detail shape, opposite intent. | | `aparte-archive-conversation` | `CustomEvent<AparteConversationArchiveDetail>` | The archive item was chosen on a live conversation. | | `aparte-delete-conversation` | `CustomEvent<AparteConversationDeleteDetail>` | The delete was confirmed. Nothing is removed here. | | `aparte-rename-conversation` | `CustomEvent<AparteConversationRenameDetail>` | A rename was committed with a new, non-empty title. Nothing is renamed here. | | `aparte-select-conversation` | `CustomEvent<AparteConversationSelectDetail>` | A row’s title was activated; the host loads that conversation. | ## Theming [Section titled “Theming”](#theming) Override any of these on `:root`, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at `:root` moves everything that reads it. The full set is in the [CSS variables reference](/reference/css-variables/). ### `` [Section titled “\”](#aparte-conversation-list) | Variable | Default | Description | | --------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--aparte-conv-list-gap` | `var(--aparte-space-1)` | Vertical gap between rows, and between a group’s heading and its rows. | | `--aparte-conv-item-padding` | `var(--aparte-space-4) var(--aparte-space-5)` | Padding of a row’s title button. | | `--aparte-conv-item-gap` | `var(--aparte-space-3)` | Gap between a row’s title and its `⋯` button. | | `--aparte-conv-item-radius` | `var(--aparte-radius-md)` | Corner radius of a row. | | `--aparte-conv-item-font-size` | `var(--aparte-font-size-md)` | Font size of a row’s title. | | `--aparte-conv-item-color` | `var(--aparte-text-muted)` | Title colour of an inactive row. | | `--aparte-conv-item-bg-hover` | `var(--aparte-surface-3)` | Row background on hover. | | `--aparte-conv-item-bg-active` | `var(--aparte-surface-3)` | Background of the row matching `active-id`. | | `--aparte-conv-item-color-active` | `var(--aparte-text)` | Title colour of the active row. | | `--aparte-conv-item-font-weight-active` | `var(--aparte-font-weight-medium)` | Title weight of the active row. | | `--aparte-conv-action-btn-size` | `var(--aparte-btn-size-sm)` | Square size of the `⋯` button (the recipe’s small step). Under `(pointer: coarse)` the stylesheet redeclares it as the touch target size on the button itself, so a value set on the element does not reach it there; the button also stays visible instead of appearing on hover. | ## In a framework [Section titled “In a framework”](#in-a-framework) The element is the same object everywhere — **the tag does not change**. What changes is how an attribute is written and how an event reaches you. * Vanilla ```html ``` ```js el.addEventListener('aparte-unpin-conversation', (e) => use(e.detail)); ``` * React ```tsx ``` The `aparte-*` tags are typed JSX intrinsics as soon as you import from `@aparte/react`. A presence attribute takes `''`, never `true` — React stringifies it, and `no-groups={false}` would render `no-groups="false"`, which `hasAttribute` reads as on. Events reach you by ref, typed through the DOM. * Vue ```vue ``` Declared through Vue’s `GlobalComponents`, so `vue-tsc` checks the tag in any template. A presence attribute takes `''` to set and `null` to remove, never `false`. * Svelte ```svelte use(e.detail)} > ``` Declared through `SvelteHTMLElements`, so `svelte-check` covers the attributes and the `on:` handlers. A presence attribute takes `''`, never `false`. * Angular ```ts import { AparteConversationListDirective } from '@aparte/angular'; ``` ```html ``` A standalone directive whose selector IS the tag, so the real element sits in the template — `@if`, `@for` and content projection all reach it — and no `CUSTOM_ELEMENTS_SCHEMA` is needed. Installation and the framework-specific traps: [React](/frameworks/react/) · [Vue](/frameworks/vue/) · [Svelte](/frameworks/svelte/) · [Angular](/frameworks/angular/). # Scroll rail > A rail of ticks beside the transcript — one per turn — that shows where you are in a long conversation and jumps back to any message on a click. `` A rail of ticks beside the transcript — one per turn — that shows where you are in a long conversation and jumps back to any message on a click. The thing every long chat is missing and no product ships natively: the scrollbar says how far down you are, not which question you are under. This is the minimap of a code editor reduced to its ticks, the search marks of a browser’s scrollbar, the scrollspy of a documentation site — the same ancestry, applied to turns. A tick per USER message by default, because a user’s turns are the questions and a rail with a tick per reply is unreadable on a long thread; `every="message"` marks each one. It owns nothing of the transcript: the viewport keeps its scroll, the bubbles keep their markup, and the rail only READS them — which bubbles exist (a mutation observer on the chat), which one is under the reader (an intersection observer on the scroll surface, never scroll arithmetic), and the first few words of each for the tick’s name. A click is a `scrollIntoView` on the bubble, announced first by a cancelable `aparte-scroll-rail-jump` so a host that pages history in can load it before the jump. Honoured by core alone, so it is live by default (ratified decision #8, tier a). Place it as a direct child of `` (or the wrapper’s `[data-aparte-chat]` host): the stylesheet floats it on the transcript’s end edge, centred on the transcript and clear of a classic scrollbar, and hides it under a coarse pointer, where a 2px tick is not a target. Under two ticks it renders nothing — a rail with one mark says nothing. It is a list, not a minimap: it takes the height of its ticks, up to a share of the transcript (`--aparte-scroll-rail-share`, 60%). When more turns exist than 24px targets fit in that, the pitch tightens to what fits, down to a 6px floor; past that the rail scrolls itself so the current tick is always in view, and the arrow keys walk every tick whatever the pitch. ## Usage [Section titled “Usage”](#usage) Width 375 768 Full [Open in a tab](/preview/aparte-scroll-rail/) [Live preview of \](/preview/aparte-scroll-rail/) The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes. ### Example [Section titled “Example”](#example) ```html
``` ## API [Section titled “API”](#api) ### Attributes [Section titled “Attributes”](#attributes) | Attribute | Description | | ------------ | --------------------------------------------------------------------------- | | `target` | The id of the `` to follow, when the element is not inside it. | | `every` | `user` (default): one tick per user turn. `message`: one per message. | | `data-empty` | Reflected BY the element while it has fewer than two ticks. Read-only. | ### Properties [Section titled “Properties”](#properties) | Property | Type | Description | | ------------------------------- | ---------------- | ---------------------------------------------------------- | | `currentMessageId` *(readonly)* | `string \| null` | The id of the message the rail currently marks, or `null`. | ### Methods [Section titled “Methods”](#methods) | Method | Description | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `jumpTo(messageId: string): void` | Scroll the transcript to a message. Announced first, cancelable; then a `scrollIntoView` on the bubble, smooth unless the reader prefers reduced motion. The clicked tick is the mark until the transcript has stopped moving: while a smooth scroll crosses other questions the band would name each in turn, and it ended on the wrong one two times in three before this hold. Where the scroll lands is checked once it has settled, and corrected. The bubbles carry `content-visibility: auto`, so a bubble far from the reader has an estimated height until it is rendered: the scroll aims at an estimate, the bubbles above it take their real size on the way, and the message ends up above or below the top — measured from 36px to 1,213px off, in all three engines, the further the worse. A second `scrollIntoView` from a rendered neighbourhood lands; a few are allowed. | ### Events [Section titled “Events”](#events) | Event | Type | Description | | ------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `aparte-scroll-rail-jump` | `CustomEvent<AparteScrollRailJumpDetail>` | A tick was activated. Bubbles, cancelable: `preventDefault()` leaves the transcript where it is. | ## Theming [Section titled “Theming”](#theming) Override any of these on `:root`, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at `:root` moves everything that reads it. The full set is in the [CSS variables reference](/reference/css-variables/). ### `` [Section titled “\”](#aparte-scroll-rail) | Variable | Default | Description | | ------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `--aparte-scroll-rail-width` | `max(var(--aparte-scroll-rail-hit-size), calc(var(--aparte-scroll-rail-tick-size) * 1.6))` | The rail’s column, the ticks end-aligned in it. Never under one tick’s pressable zone: the rail clips, so a narrower column would cut the target back. | | `--aparte-scroll-rail-tick-size` | `14px` | Length of a tick; the current one is 1.6× that. | | `--aparte-scroll-rail-tick-thickness` | `2px` | Thickness of a tick. | | `--aparte-scroll-rail-hit-size` | `24px` | The pressable zone around a tick (WCAG 2.5.8). The drawn line keeps its own size; this one sizes the pseudo-element and, through the gap below, the pitch — so raising it spaces the ticks out rather than overlapping them. When the ticks would not fit, the rail sets it on itself to the pitch that does, never under 6px. | | `--aparte-scroll-rail-gap` | `calc(var(--aparte-scroll-rail-hit-size) - var(--aparte-scroll-rail-tick-thickness))` | Space between ticks: the zone minus the line, so gap + thickness is exactly the pitch. Set it smaller and the zones overlap. | | `--aparte-scroll-rail-share` | `.6` | The share of the transcript’s height the rail may take. It is the height of its list up to this, centred on the transcript; past it the pitch tightens, then the rail scrolls. | | `--aparte-scroll-rail-bar` | `0px` | Published BY the element: the width of the transcript’s scrollbar when it is a classic one (0 for an overlay bar), which the stylesheet adds to the rail’s end inset so the ticks never sit on the bar. | | `--aparte-scroll-rail-block-start` | `0px` | Published BY the element: the distance from the host’s top edge to the transcript’s, so the rail centres on the transcript rather than on the composer too. | | `--aparte-scroll-rail-block-end` | `0px` | Published BY the element: the distance from the transcript’s bottom edge to the host’s. | ## In a framework [Section titled “In a framework”](#in-a-framework) The element is the same object everywhere — **the tag does not change**. What changes is how an attribute is written and how an event reaches you. * Vanilla ```html ``` ```js el.addEventListener('aparte-scroll-rail-jump', (e) => use(e.detail)); ``` * React ```tsx ``` The `aparte-*` tags are typed JSX intrinsics as soon as you import from `@aparte/react`. A presence attribute takes `''`, never `true` — React stringifies it, and `data-empty={false}` would render `data-empty="false"`, which `hasAttribute` reads as on. Events reach you by ref, typed through the DOM. * Vue ```vue ``` Declared through Vue’s `GlobalComponents`, so `vue-tsc` checks the tag in any template. A presence attribute takes `''` to set and `null` to remove, never `false`. * Svelte ```svelte use(e.detail)} > ``` Declared through `SvelteHTMLElements`, so `svelte-check` covers the attributes and the `on:` handlers. A presence attribute takes `''`, never `false`. * Angular ```ts import { AparteScrollRailDirective } from '@aparte/angular'; ``` ```html ``` A standalone directive whose selector IS the tag, so the real element sits in the template — `@if`, `@for` and content projection all reach it — and no `CUSTOM_ELEMENTS_SCHEMA` is needed. Installation and the framework-specific traps: [React](/frameworks/react/) · [Vue](/frameworks/vue/) · [Svelte](/frameworks/svelte/) · [Angular](/frameworks/angular/). # Composer > The root context for every `aparte-composer-*` primitive. `` — with 7 parts: ``, ``, ``, ``, ``, ``, `` The root context for every `aparte-composer-*` primitive. It imposes no visual layout — the consumer owns the structure — and holds the shared state the parts read: the value, the streaming flag, pending attachments, whether a panel is up. It renders nothing of its own — no shadow root, no markup, no default children — so an `` with nothing inside is an empty block. The parts that need that state locate it with `closest('aparte-composer')`, which is why they may sit at any depth and why the opt-in `.aparte-composer-shell` / `.aparte-composer-row` wrappers can exist without this element knowing about them. Not every part looks it up, though: `` is purely structural — it lays its children out and never resolves this element at all. WHAT GOES INSIDE — ordinary light-DOM children. Core has no shadow root and no ``, so there is no slot name to write: drop in ``, ``, ``, ``, ``, ``, ``, plus whatever markup you wrap them in. Order and nesting are yours. Two behaviours read the tree rather than a flag, so they depend on what you put in: `focus()` forwards to the first `` descendant, and `showPanel()` inserts the panel right after it (appending to the host when there is none). A PANEL is neither markup you write nor a named slot: `showPanel()` takes the element, stamps it `data-aparte-panel` and inserts it — inside the descendant you marked `data-aparte-panel-host` if there is one, else right after the first input — and `hidePanel()` removes it. One at a time — a second `showPanel()` evicts the first and calls its `onEvict`. While one is up the host carries `[data-panel-active]`, which hides `` and `` and leaves the attachments strip and the toolbar in place. It is not a transport either. `submit()` trims, checks the gates (disabled, empty, no model selected), dispatches `aparte-send` and clears — nothing here talks to a model, so without `AparteClient` or a listener of your own a send is a dispatched event and no answer. With no panel up it doubles as the stop button: while `streaming` it routes to `cancel()`, which is why the `getState` example below keeps a custom send button clickable rather than disabling it mid-stream. With a panel up it means “answer the question” instead — it calls the panel’s `onSubmit` and returns, so neither the stop branch nor a send is reached. The streaming flag comes from WINDOW lifecycle events, filtered by target: `aparte-message-start` sets it, and any of `aparte-message-done` / `aparte-message-error` / `aparte-message-aborted` clears it (and evicts an open panel). `AparteClient` dispatches them on the chat host, bubbling, so they reach both this element’s `window` listener and the host-bound readers; a host that runs its own loop dispatches the same two (start, done) the same way, with `detail.targetId` set to this composer’s `target` — that is the whole contract, documented under “Make the composer follow your turn” in the bring-your-own-loop guide. On a page with two chats, give the composer a `target` — or put it under a chat host that has an `id` — otherwise it answers to every chat’s events, and one chat’s Stop resets the other’s composer and evicts its open panel. Prose first, on purpose: when `@element` opens a docblock there is no free text left for the analyser to use, and this component’s description came out empty in the manifest and blank on the generated reference page. `aparte-abort` and `aparte-message-aborted` have to be declared by hand and always will: they go out through `window.dispatchEvent` (they concern the whole page, not this subtree), and the analyser’s fallback only recognises `this.dispatchEvent`. ## Usage [Section titled “Usage”](#usage) Width 375 768 Full [Open in a tab](/preview/aparte-composer/) [Live preview of \](/preview/aparte-composer/) The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes. ### Example [Section titled “Example”](#example) ```html
``` ```ts // A custom send button. Keep it CLICKABLE while streaming — submit() // routes to cancel() when a response is in flight, so one button is // Send/Stop. Disabling it on `streaming` would make "stop" unreachable. composer.addEventListener('aparte-composer-change', (e) => { const { streaming, disabled, value, attachments } = e.detail.state; myButton.textContent = streaming ? 'Stop' : 'Send'; myButton.disabled = disabled || (!streaming && !value.trim() && attachments.length === 0); }); myButton.addEventListener('click', () => composer.submit()); // send or stop ``` ### Composer input [Section titled “Composer input”](#composer-input) `` Contenteditable text input primitive. The element owns its subtree: on connect it writes one `.aparte-ci-editor` contenteditable and binds its listeners to that node, so children you place inside are replaced. There is nothing to project here — style the generated editor through the CSS variables below, or replace the whole primitive. Enter submits and Shift+Enter inserts a newline; `submit-on-enter="false"` on the composer inverts that mapping, and Enter never submits mid-IME-composition — the key that confirms a CJK candidate must not send the message. The editor auto-expands with its content up to `max-height`, then scrolls. Paste is intercepted: text lands as plain text with its markup stripped, and a pasted image goes to the composer’s attachments. Without an `` ancestor it still works, and that is deliberate: a submitting Enter then dispatches `aparte-composer-submit` instead of calling `root.submit()`, which is how the bubble’s inline editor reuses this primitive. Everything the root owns goes with it though — the mirrored value, the placeholder fallback, the disabled sync and image paste all need the composer. The editor stays editable while a reply streams (the next message is typed while the current one arrives); only the send is gated then — Enter is swallowed, the button is Stop. Not a `