Theming and the UI kit: CSS variables, dark mode, the customization hooks, and the kit of plain classes (buttons, fields, alerts, tags, menus, tabs) with their HTML. # Customize the chat > Change more than colours and sizes — which part of the chat each seam changes, when a change shows on the chats already on screen, and how two chats get different setups. [The same message, in two shells](/preview/demo/customization-bubble-shell/) ```html
``` The same message twice: core's message, then a frame of your own, given to the second one by its own configuration. ## Pick the seam [Section titled “Pick the seam”](#pick-the-seam) | You want to | Use | Page | | -------------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------- | | change colours, sizes and shapes | CSS variables | [Theme](/guides/theming/) | | replace the error card, an attachment chip, the status line | a render hook | [Render hooks](/guides/customization/render-hooks/) | | redraw the whole message | a frame of your own, or an element of your own | [The message](/guides/customization/bubble/) | | turn on retry and edit, add a button to a message or to the composer | the actions | [Buttons](/guides/customization/actions/) | | put a mode picker or a model selector under the composer | the composer’s toolbar | [The composer toolbar](/guides/customization/composer-toolbar/) | | show a chart, a map or a form in a reply | a segment renderer | [Your own content](/guides/customization/segments/) | | show “Thought for 8s” | the timing a segment carries | [Durations](/guides/customization/segment-timing/) | | swap the icons, add avatars | the icon and avatar providers | [Icons and avatars](/guides/customization/icons-and-avatars/) | | render Markdown and colour code | the Markdown and highlight plugins | [Markdown and code](/guides/markdown/) | ## Everything goes through the configuration [Section titled “Everything goes through the configuration”](#everything-goes-through-the-configuration) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setErrorRenderer(({ message }) => { const el = document.createElement('div'); el.className = 'my-error'; el.textContent = message; return el; }); aparteGlobalConfig.setErrorRenderer(null); // the built-in one again ``` The render hooks, the providers and the button settings are methods on `aparteGlobalConfig`, the configuration every chat on the page reads; a render hook or the avatar provider takes `null` to put the built-in back. A change to the buttons, the icons, the avatars or the language reaches the messages already on screen, and a new status-line renderer reaches the line already shown. The message frame, the error card, the attachment chips, a segment renderer and the Markdown and highlight providers apply to what is drawn after them, so set those before the chat renders. ## Custom segment types [Section titled “Custom segment types”](#custom-segment-types) ```ts import { registerSegmentRenderer } from '@aparte/core'; registerSegmentRenderer({ type: 'chart', render(segment) { const el = document.createElement('div'); el.className = 'my-chart'; return el; }, }); ``` A reply is made of typed parts — text, reasoning, code, a tool call — called segments, and a renderer registered for a type of your own draws it: a chart, a map, a form, generative UI. A segment whose type no renderer claims shows its `fallback` text, or `[Unknown segment type: …]` with a warning in the console when it has none: register a renderer for the type, or give the segment a `fallback`. [Your own content](/guides/customization/segments/) shows how such a segment reaches a message, and the rest of a renderer’s life. ## Per-chat configuration [Section titled “Per-chat configuration”](#per-chat-configuration) ```ts import { AparteConfig, attachConfig } from '@aparte/core'; const support = new AparteConfig(); support.setBubbleActions({ retry: true, edit: true }); const chat = document.querySelector('#support-chat'); if (chat) attachConfig(chat, support); ``` Attach a configuration of its own to a chat, or to an element around it, and every part inside reads that one; `detachConfig` takes it off. The framework packages take it as their `config` prop. A new configuration starts from the built-in defaults: set up on it what that chat needs — its Markdown and highlighting (`setupMarkedProvider(undefined, support)`), its icons, its language. It inherits one thing from the page-wide one, the segment renderers registered there, and cannot remove one of those; `registerSegmentRenderer(renderer, support)` registers one for that chat alone, which wins over the page-wide one. ## Next [Section titled “Next”](#next) * [Render hooks Your own markup for one piece: the error card, an attachment chip, the status line.](/guides/customization/render-hooks/) * [Your own content A chart, a map or a form in a reply, and a tag the model writes turned into one.](/guides/customization/segments/) * [Buttons Retry, edit and feedback, and buttons of your own on a message or in the composer.](/guides/customization/actions/) * [Icons and avatars Your icon set, and an avatar beside each message.](/guides/customization/icons-and-avatars/) # Buttons on a message and in the composer > Turn on retry, edit, feedback and details, choose their order, answer them, and add buttons of your own to a message or to the composer. [Messages with their actions](/preview/demo/message-actions/) ```html ``` A message's buttons: copy is on by default, the rest are yours to turn on. ## Turn on the built-in buttons [Section titled “Turn on the built-in buttons”](#turn-on-the-built-in-buttons) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setBubbleActions({ retry: true, edit: true, feedback: true, info: true }); ``` | Button | Shown on | Who answers it | On by default | | ------------------------------- | -------------------------- | --------------------------------------------------- | ------------- | | `copy` | every message | core | yes | | `retry` | replies | `AparteClient`, or your listener for `aparte-retry` | no | | `edit` | your messages | `AparteClient`, or your listener for `aparte-edit` | no | | `feedback`, a thumb up and down | replies | your listener (`aparte-feedback`) | no | | `info`, the details of a reply | replies that carry `usage` | your listener (`aparte-message-info`) | no | Only `copy` is on by default; turn on the buttons your app answers. Calls merge: each one changes the flags it names. `APARTE_DEFAULT_BUBBLE_ACTIONS` holds the defaults, and [Conversations and branches](/guides/conversations-branching/) shows what retry and edit do. ## Choose them and their order [Section titled “Choose them and their order”](#choose-them-and-their-order) ```ts import { aparteGlobalConfig, type AparteBubbleActionName } from '@aparte/core'; aparteGlobalConfig.setBubbleActions({ user: ['edit', 'copy'] satisfies AparteBubbleActionName[], assistant: ['copy', 'thumbUp', 'thumbDown', 'retry', 'info'] satisfies AparteBubbleActionName[], }); ``` A list for a role draws exactly those buttons, in that order, whatever the flags say: naming a button in a list turns it on. In a list, feedback is two buttons, `thumbUp` and `thumbDown`. ## Answer feedback and details [Section titled “Answer feedback and details”](#answer-feedback-and-details) ```ts document.addEventListener('aparte-feedback', (event) => { const { messageId, value } = event.detail; // value: 'positive' or 'negative' void fetch('/api/feedback', { method: 'POST', body: JSON.stringify({ messageId, value }) }); }); document.addEventListener('aparte-message-info', (event) => { openDetails(event.detail.messageId, event.detail.usage); }); ``` The events bubble out of the chat, so one listener on `document` answers every chat on the page. ## Let image tiles open a preview [Section titled “Let image tiles open a preview”](#let-image-tiles-open-a-preview) ```ts import { aparteGlobalConfig } from '@aparte/core'; declare function openLightbox(url: string, name: string): void; aparteGlobalConfig.setHostHandlers({ attachmentPreview: true }); document.addEventListener('aparte-attachment-preview', (event) => { openLightbox(event.detail.url, event.detail.name); }); ``` Declared, an attached image becomes a button that sends `aparte-attachment-preview`, with the image’s `url` and `name`; undeclared, it is a picture and nothing more. A tile of your own, drawn by [`setAttachmentRenderer`](/guides/customization/render-hooks/), gets none of this: send the event from your element yourself. ## Add a button to a message [Section titled “Add a button to a message”](#add-a-button-to-a-message) ```ts import { aparteGlobalConfig } from '@aparte/core'; declare function shareMessage(messageId: string | undefined): void; aparteGlobalConfig.registerAction({ id: 'share', icon: 'download', label: 'Share', zones: ['bubble'], bubble: { roles: ['assistant'] }, }); document.addEventListener('aparte-action', (event) => { const { actionId, messageId } = event.detail; if (actionId === 'share') shareMessage(messageId); }); ``` A registered action follows the built-in buttons, sorted by its `order`. `icon` is one of core’s [icon names](/reference/icons/), or SVG markup when it starts with `<` — pass the markup for any glyph core does not have: a name it does not know draws the action’s `iconFallback`, or nothing. `label` is the button’s accessible name, so an icon alone always has one. A click sends `aparte-action` with `{ actionId, zone, messageId, role, targetId }`, `targetId` being the chat’s id, and calls the action’s `onClick` if you gave one; `unregisterAction(id)` removes the button. The framework packages hand you the same detail: `onAction` in React, `@action` in Vue, `on:action` in Svelte, `(action)` in Angular. ## Add a button to the composer [Section titled “Add a button to the composer”](#add-a-button-to-the-composer) ```html
``` ```ts document.addEventListener('aparte-action-click', (event) => { if (event.detail.actionId === 'pin') pinDraft(); }); ``` [``](/components/composer/) sits where you write it, anywhere inside the composer, and greys out while a reply streams. `action-id` comes back on the event, which tells two of them apart, and `icon` takes a core icon name or SVG markup. Inside an ``, write the `` too, as [the composer toolbar](/guides/customization/composer-toolbar/) shows: a chat with no message list inside replaces its children with the default chat. The framework packages draw their own composer row, so put the button in their `toolbar` slot. For sending, stopping and attaching, the composer’s own elements already do the work: ``, `` and ``. ## Next [Section titled “Next”](#next) * [Message actions The buttons, live, in every state.](/components/message-actions/) * [The composer toolbar A row of controls under the composer.](/guides/customization/composer-toolbar/) # Redraw the whole message > A frame of your own around core's message, or your own element in its place — in a framework or in plain HTML. ## A frame of your own [Section titled “A frame of your own”](#a-frame-of-your-own) ```ts import { aparteGlobalConfig, escapeAttr, escapeHtml } from '@aparte/core'; aparteGlobalConfig.setBubbleShellRenderer(({ role, name }) => `
${escapeHtml(name)}
`); ``` The message stays core’s — it streams, keeps its buttons and its versions — and draws itself into your markup, finding each region by its class. The renderer receives `{ role, name, avatarInitial }`. What the frame must carry: * **`.aparte-message`** as its root. The message writes its role and its accessible name there, unless your root already has a `role`. * **`.aparte-message-content`**, holding `.aparte-segments`, `.aparte-content` and `.aparte-waiting`. * **`.aparte-waiting`**, with `.aparte-dots` around three `.aparte-dot` and an `.aparte-sr-only` label: the message shows and hides it, the dots are your markup. Leave it out and the message shows no waiting state. * **`.aparte-branch-picker`**, if the message can have versions, written `hidden` with the four parts above: the message shows it once there are two versions, and fills the label and the status. Keep `aria-live` on the status, the one thing a screen reader hears when the version changes; the arrows’ labels and glyphs are yours. * **`.aparte-action-bar`**, with `role="toolbar"` and a label of yours: the message puts its buttons there, and they share one tab stop with the arrow keys between them. * **`.aparte-name`**, holding the `name` you are given: the message updates it when the name or the language changes. Any of `.aparte-avatar`, `.aparte-timestamp`, `.aparte-attachments` and `.aparte-footer` that you want filled. A frame for one chat only goes on that chat’s own configuration ([Per-chat configuration](/guides/customization/#per-chat-configuration)). A message draws its frame once, when it appears, so a chat’s frame reaches the messages that appear after its configuration is attached: with a framework’s `config` prop, the messages there at the first render keep the page-wide frame. ## Your own element, in a framework [Section titled “Your own element, in a framework”](#your-own-element-in-a-framework) * React ```tsx } /> ``` * Vue ```vue ``` * Svelte ```svelte ``` * Angular ```html ``` Each message is then drawn by your component, from `message.content` and `message.segments`, which update while the reply streams. The buttons and the versions belong to core’s message: an element of your own draws and wires whatever it needs. ## Your own element, in plain HTML [Section titled “Your own element, in plain HTML”](#your-own-element-in-plain-html) ```html ``` ```ts const viewport = document.querySelector('aparte-chat-viewport'); viewport?.appendToken('m-42', 'Hello'); // reaches viewport?.completeMessage('m-42'); ``` Without a framework there is no reactive list, so the message list also calls your element: an element inside it carrying `data-aparte-bubble` and the message’s `message-id` receives what it would send core’s message — `appendToken`, `appendToSegment`, `addSegment`, `updateSegment`, `removeSegment`, `setUsage` and `updateMessage`. Implement the ones you need, and stream into it by its id from a loop of your own: `appendMessage` draws core’s message for that id beside yours. Three limits: `clearAll()` removes core’s messages only, and leaves yours behind; a retry, a move to another version of a reply, or loading a history rebuilds the list with core’s messages, so yours is replaced; and a client that appends each new reply draws core’s message for it. To draw every message yourself, set `framework-managed` on the viewport and add one element per message, as the framework packages do. The framework hooks above have none of these limits. ## Next [Section titled “Next”](#next) * [Render hooks Replace one piece instead of the whole frame.](/guides/customization/render-hooks/) * [Message Core's message, its parts and its options.](/components/message/) # Put controls under the composer > The row under the composer, for a mode picker, a model selector or a counter — in plain HTML and in each framework — and the slot above it for a banner. [A composer with a toolbar](/preview/demo/composer-toolbar/) ```html
``` Two buttons of your own, and the model selector pushed to the end of the toolbar. ## The toolbar [Section titled “The toolbar”](#the-toolbar) * Vanilla ```html
``` * React ```tsx } /> ``` * Vue ```vue ``` * Svelte ```svelte ``` * Angular ```html ``` [``](/components/composer/) is the row under the input. Its controls sit in the order you write them, and `margin-inline-start: auto` pushes one, with everything after it, to the end — on the right, or on the left in a right-to-left language. An empty toolbar draws nothing. The mode picker stands for a control of your own. The default chat has no toolbar. In plain HTML, write the whole chat: the message list, the `` panel where [questions and approvals](/guides/elicitation/) appear, and the composer. An `` with no message list inside replaces its children with the default chat, toolbar and all. The framework packages draw the row from their `toolbar` slot; in Angular, each node you project carries `slot="toolbar"` itself. The slot belongs to the default composer, so a composer of your own, put in through the `composer` slot, writes its own ``. `` comes from `@aparte/plugin-model-selector`, and draws nothing until that package is imported. Its `/react`, `/vue` and `/svelte` entries type the tag, and `/angular` exports `AparteModelSelectorDirective` for a standalone component’s `imports`; `persist` makes its pick the model the next message goes to. The [model selector](/components/model-selector/) and the [approval mode](/components/approval-mode/) are two controls made for this row. ## Above the composer [Section titled “Above the composer”](#above-the-composer) * Vanilla ```html

Replies can be wrong. Check what matters.

``` * React ```tsx Replies can be wrong. Check what matters.

} /> ``` * Vue ```vue ``` * Svelte ```svelte

Replies can be wrong. Check what matters.

``` * Angular ```html

Replies can be wrong. Check what matters.

``` Between the messages and the composer goes a banner, a row of suggestions or a notice. In plain HTML it is one more child of the chat, before the composer; the framework packages take it as `aboveComposer` in React and the `above-composer` slot elsewhere. The [Wrapper surface](/reference/wrappers/) lists every slot in each framework’s syntax. ## Next [Section titled “Next”](#next) * [Buttons A button of your own inside the composer row.](/guides/customization/actions/) * [Composer Every part of the composer, live.](/components/composer/) # Swap the icons and add avatars > Your own icon set, the extra glyphs core ships, an avatar beside each message, and an element of your own kept in sync when the icons or the language change. ## Your own icons [Section titled “Your own icons”](#your-own-icons) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setIconProvider({ copy: () => '', send: () => '', }); ``` Give the icons you replace, by name; the others keep core’s. Each call replaces the whole set, so give every icon you replace in one call. A value is markup you trust: an SVG, an icon font’s element, an emoji, an ``. An SVG or an `` with `class="aparte-icon"` takes its size from the place it sits, through `--aparte-icon-size`, as core’s glyphs do; an icon font or an emoji is text, and follows the `font-size` around it. The [icon names](/reference/icons/) are listed with every glyph; the inline message editor’s save and cancel use `check` and `close`. ## The extra glyphs [Section titled “The extra glyphs”](#the-extra-glyphs) ```ts import { aparteGlobalConfig, type AparteIconProvider } from '@aparte/core'; import { historyIcon, sparklesIcon } from '@aparte/core/icons'; declare const myIcons: AparteIconProvider; // the set you already gave aparteGlobalConfig.setIconProvider({ ...myIcons, retry: () => historyIcon }); document.querySelector('#new-chat')?.insertAdjacentHTML('afterbegin', sparklesIcon); ``` `@aparte/core/icons` holds glyphs core does not draw itself, each imported by name, so a bundle carries the ones you use. Give one to a name core knows, or put it in your own markup. ## Avatars [Section titled “Avatars”](#avatars) [Messages with avatars](/preview/demo/message-avatars/) ```html ``` An avatar beside each message, drawn by the avatar provider. ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setAvatarProvider({ render(role, host) { host.setAttribute('aria-hidden', 'true'); // the message already names its author host.textContent = role === 'assistant' ? 'AI' : 'Me'; }, }); ``` A message has no avatar until you set a provider: the slot shows once `render` fills it, and `setAvatarProvider(null)` stops them on the messages drawn after. Core empties the slot and calls `render` again whenever the configuration changes — a new language, a new icon set — or the message’s role does. If `render` returns a function, core calls it before each new render and when the message goes away, so you can unmount a component you put there. `--aparte-avatar-image-user` and `--aparte-avatar-image-assistant` paint a picture in the slot, and `--aparte-avatar-bg-user` and `--aparte-avatar-bg-assistant` the background behind what your provider writes. For a picture alone, the provider still puts something in the slot, which shows only when filled: `host.innerHTML = ''`. ## Keep your own element in sync with the icons and language [Section titled “Keep your own element in sync with the icons and language”](#keep-your-own-element-in-sync-with-the-icons-and-language) ```ts import { subscribeConfigChange, resolveConfig } from '@aparte/core'; class MySendButton extends HTMLElement { private off: (() => void) | null = null; connectedCallback(): void { this.innerHTML = ''; this.paint(); this.off = subscribeConfigChange(this, () => this.paint()); } disconnectedCallback(): void { this.off?.(); this.off = null; } private paint(): void { const button = this.querySelector('button'); if (!button) return; const config = resolveConfig(this); button.innerHTML = config.getIcon('send'); button.setAttribute('aria-label', config.t('sendButton')); } } customElements.define('my-send-button', MySendButton); ``` `subscribeConfigChange` calls you back when the configuration that governs your element changes — a new icon set, another language — and returns the function that stops it. `resolveConfig(this)` reads that configuration: the chat’s own when the chat has one, the page-wide one otherwise, so place `` inside the chat it should follow. Repaint the parts that change, rather than rebuilding the element, so its focus and its listeners stay. ## Next [Section titled “Next”](#next) * [Icons Every glyph, its name, and the extra set.](/reference/icons/) * [Translate the chat The labels your element reads with t().](/guides/localization/) # Replace one piece of the chat > Your own markup for the error card, an attachment chip, the status line and the position between the version arrows — and the waiting dots, restyled. ## Return an element [Section titled “Return an element”](#return-an-element) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setErrorRenderer(({ message, details }) => { const el = document.createElement('div'); el.className = 'my-error'; el.textContent = details ? `${message} (${details})` : message; return el; }); aparteGlobalConfig.setAttachmentRenderer((attachment) => { const el = document.createElement('span'); el.className = 'my-chip'; el.textContent = attachment.name; return el; }); ``` | Hook | Replaces | Receives | | ------------------------ | ------------------------------------------------------ | ------------------------------------------------ | | `setErrorRenderer` | the error card in a reply | `{ message, details }` | | `setAttachmentRenderer` | one attachment’s chip | the attachment | | `setSiblingNavRenderer` | the position between the version arrows, such as 2/3 | `{ count, index }` | | `setStatusRenderer` | the inside of your status line, `` | its text | | `setBubbleShellRenderer` | the frame of the whole message | see [The message](/guides/customization/bubble/) | Each hook takes a function that returns an element or a string, and `null` puts the built-in back. A string holds one root element for the error and attachment hooks: plain text draws nothing there. A hook replaces markup only: the version arrows and the event they send stay core’s, and so does the status line’s showing and hiding. Set the error and attachment hooks before the chat renders — a message already drawn keeps the markup it was drawn with. The status line redraws at once, and the position between the arrows at its next change. `setStatusRenderer` receives the line’s text when the line draws, `'Typing'` when it has none. A later change of that text does not call it again, so an element of yours that shows live text reads it from your own state. ## If you return a string [Section titled “If you return a string”](#if-you-return-a-string) ```ts import { aparteGlobalConfig, escapeHtml, escapeAttr } from '@aparte/core'; aparteGlobalConfig.setAttachmentRenderer((attachment) => ` ${escapeHtml(attachment.name)} `); ``` A string is inserted as HTML, without sanitizing: a file name or a model’s message written into it unescaped can run script on your page. Escape every value — `escapeHtml` between tags, `escapeAttr` inside an attribute written in quotes, never an unquoted one — or return an element and set its `textContent`. To put a value in a CSS selector, use `cssEscape`. ## A chip of yours is yours to open [Section titled “A chip of yours is yours to open”](#a-chip-of-yours-is-yours-to-open) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setAttachmentRenderer((attachment) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'my-chip'; button.textContent = attachment.name; button.addEventListener('click', () => { button.dispatchEvent(new CustomEvent('aparte-attachment-preview', { bubbles: true, composed: true, detail: { url: attachment.url, name: attachment.name }, })); }); return button; }); ``` The built-in image tile becomes a button that asks for a preview, with `aparte-attachment-preview`, once you declare that you answer it ([Buttons](/guides/customization/actions/#let-image-tiles-open-a-preview)). A chip you draw yourself gets none of that: make it a button and send the event, or open your own preview from it. ## The waiting dots [Section titled “The waiting dots”](#the-waiting-dots) While a reply has nothing in it yet, its message shows three dots, with a label a screen reader reads, and drops them with the first word. It decides from the message itself: `status: 'streaming'` or `'pending'`, or an assistant message with no status and nothing in it — so an empty message you mean as finished needs `status: 'completed'`. `isAwaitingReply(message)` gives the same answer to your own code. ```css aparte-chat.chat { --aparte-waiting-height: 1.5rem; --aparte-waiting-dot-gap: 6px; --aparte-status-dot-size: 7px; --aparte-status-color: var(--aparte-primary); } ``` Set them on a chat given `class="chat"` — `[data-aparte-chat].chat` in React, Vue and Svelte, as the [Theme](/guides/theming/#small-recipes) recipes do. The dot’s size and colour are the status line’s dot too. The dots stop moving under reduced motion. To draw them differently, give the message a frame of your own, where the dots are your markup ([The message](/guides/customization/bubble/)). The status line, ``, is something else: a channel for your own status, such as searching the web or reading the files. Core never turns it on. The framework packages expose it as `isTyping` and `typingText`, and turn it off when the first word of the reply arrives; `setStatusRenderer` draws its inside. ## Next [Section titled “Next”](#next) * [The message A frame of your own around the message, or your own element in its place.](/guides/customization/bubble/) * [Buttons Retry, edit, feedback and buttons of your own.](/guides/customization/actions/) # Show how long a segment took > Write "Thought for 8s" on a reasoning block or a tool call from the timing every segment carries, and attach numbers of your own to a segment. ## What a segment carries [Section titled “What a segment carries”](#what-a-segment-carries) | Field | What it holds | | ----------------------- | ------------------------------------------------------------------ | | `messageId` | the message it belongs to | | `index` | its place in that message’s `segments` | | `meta.aparte.startedAt` | when it arrived, in milliseconds since the epoch | | `meta.aparte.endedAt` | when its content last arrived — it moves while the segment streams | | `meta` | yours: token counts, cost, anything else | Core measures the span and draws none of it: you write the label. Read it with `segmentDuration(segment)`, ask `isSegmentSettled(segment)` whether it is final, and take the two numbers with `segmentTiming(segment)`. A segment you built by hand has not arrived yet, so it has no start. While a segment streams, its end moves with each piece of content, so the duration grows until the segment settles. A segment restored from storage keeps what you saved with it. ## Write the duration on a reasoning block [Section titled “Write the duration on a reasoning block”](#write-the-duration-on-a-reasoning-block) ```ts import { getSegmentRenderer, registerSegmentRenderer, installDefaultRenderersOnce, isSegmentSettled, segmentDuration } from '@aparte/core'; import type { AparteThinkingSegment } from '@aparte/core'; installDefaultRenderersOnce(); const builtIn = getSegmentRenderer('thinking')!; function parseRoot(html: string): HTMLElement { const t = document.createElement('template'); t.innerHTML = html; return t.content.firstElementChild as HTMLElement; } function writeDuration(host: HTMLElement, segment: AparteThinkingSegment): void { if (!isSegmentSettled(segment)) return; const ms = segmentDuration(segment); if (ms === undefined) return; const label = host.querySelector('.aparte-thinking-label'); if (label) label.textContent = ms < 1000 ? 'Thought for <1s' : `Thought for ${(ms / 1000).toFixed(1)}s`; } registerSegmentRenderer({ type: 'thinking', render(segment) { const out = builtIn.render(segment); const host = typeof out === 'string' ? parseRoot(out) : out; writeDuration(host, segment); return host; }, setup: (el, segment) => builtIn.setup?.(el, segment), update(el, segment) { builtIn.update?.(el, segment); writeDuration(el, segment); }, relabel(el, segment) { builtIn.relabel?.(el, segment); writeDuration(el, segment); }, }); ``` The built-in renderer keeps drawing the block; yours rewrites its label. Three places, because a block appears with `render`, settles later through `update`, and gets the built-in’s label back on a language switch through `relabel` — a label written in `render` only would read “Thinking” for good. The example writes English: take the words from your own translations inside `writeDuration`. Keep the root the built-in returned, without a wrapper of yours around it. For a chat with its own configuration, pass it to `getSegmentRenderer` and `registerSegmentRenderer` as their last argument. A block settles as soon as the stream closes it — its closing tag, or the first word of the answer for reasoning sent on its own channel — so the duration shows while the answer is still streaming. On a loop of your own, a segment settles when you update it with `isStreaming: false` or complete its message. ## Write the duration on a tool call [Section titled “Write the duration on a tool call”](#write-the-duration-on-a-tool-call) ```ts import { aparteGlobalConfig, isSegmentSettled, segmentDuration } from '@aparte/core'; aparteGlobalConfig.registerToolSummary('read_file', (segment) => { const ms = segmentDuration(segment); return isSegmentSettled(segment) && ms !== undefined ? `${(ms / 1000).toFixed(1)}s` : ''; }); ``` A tool’s summary is the line the default row writes after the tool’s name, read again each time the call changes. A call settles once its status leaves `pending` and `awaiting-approval`, so its span runs from the moment the call appears to its result, the time a person took to approve it included. ## Attach numbers of your own [Section titled “Attach numbers of your own”](#attach-numbers-of-your-own) ```ts import type { AparteChatImperativeApi } from '@aparte/core'; declare const chat: AparteChatImperativeApi; const last = chat.getMessages().at(-1); const thinking = last?.segments?.find((segment) => segment.type === 'thinking'); if (thinking) chat.updateSegment(thinking.id, { meta: { outputTokens: 1_240, device: 'webgpu' } }); ``` `getMessages()` returns the segments with everything on them, and `updateSegment` merges `meta`, so a key of yours never erases the timing. On a framework chat it reaches the last message; the viewport’s `updateSegment(messageId, segmentId, updates)` reaches any. Core has no channel from a provider to a segment’s `meta`: write it from your app, after the turn. ## Next [Section titled “Next”](#next) * [Reasoning The reasoning block, live, open and closed.](/components/reasoning/) * [Your own content Renderers of your own, and the four callbacks they answer.](/guides/customization/segments/) # Show your own content in a reply > A chart, a map or a form inside a message — the renderer that draws it, the ways it reaches a message, a tag the model writes turned into one, and defaults per type. [A view of your own in a reply](/preview/demo/custom-view/) ```html ``` A segment of a type of its own, drawn by its renderer inside the reply. ## Register a renderer [Section titled “Register a renderer”](#register-a-renderer) ```ts import { registerSegmentRenderer } from '@aparte/core'; import type { AparteSegment, AparteSegmentBase } from '@aparte/core'; interface ChartSegment extends AparteSegmentBase { type: 'chart'; values: number[]; } registerSegmentRenderer({ type: 'chart', render(segment) { const el = document.createElement('div'); el.className = 'my-chart'; el.textContent = segment.values.join(' · '); return el; }, getStyles: () => '.my-chart { font-variant-numeric: tabular-nums; }', }); // A chart in the reply a1, once it is on screen: const chart: ChartSegment = { id: 'c1', type: 'chart', values: [3, 5, 2] }; document.querySelector('aparte-chat-viewport')?.addSegment('a1', chart as unknown as AparteSegment); ``` A reply is a list of typed parts, segments, and a renderer draws every segment of its type. It returns an element, or a string of markup with a single root element that you escape yourself. `getStyles()` hands the renderer’s CSS to the page once. The methods that take a segment are typed for core’s own types, hence the cast for one of yours. ## How a segment reaches a message [Section titled “How a segment reaches a message”](#how-a-segment-reaches-a-message) * **In the message itself**: a message you add or restore with `segments: [...]` draws each one. * **Added later**: `viewport.addSegment(messageId, segment)` puts one into a message on screen, during a reply or after it; on a framework chat, `chat.addSegment(segment)` adds it to the last message. * **From a tag the model writes**, such as `…`: see [Turn a tag into a segment](#turn-a-tag-into-a-segment). * **From a tool’s result**: a tool renderer draws a tool call the way a segment renderer draws a segment ([Tools](/guides/tools/#custom-tool-renderer)). ## One type, several views [Section titled “One type, several views”](#one-type-several-views) ```ts import { registerSegmentRenderer, type AparteCustomSegment } from '@aparte/core'; declare function renderWeather(data: unknown): HTMLElement; declare function renderOnboarding(data: unknown): HTMLElement; registerSegmentRenderer({ type: 'custom', render(segment) { switch (segment.subType) { case 'weather': return renderWeather(segment.data); case 'onboarding': return renderOnboarding(segment.data); default: { const el = document.createElement('p'); el.textContent = segment.fallback ?? ''; return el; } } }, }); ``` The built-in `custom` type carries a `subType` and its `data`, so one renderer can draw a family of small views. A configuration has one renderer per type, so two sets of views cannot both own `custom`: give a set of your own its own `type`. Any segment can carry a `fallback`, a sentence drawn as text when no renderer claims its type — in an app that does not know your view, or a conversation replayed elsewhere. Without one, the chat shows `[Unknown segment type: chart]` and warns in the console. ## What a renderer is called for [Section titled “What a renderer is called for”](#what-a-renderer-is-called-for) | Callback | When | Rule | | --------- | ------------------------------------------------------------------------ | -------------------------------------------- | | `render` | the segment appears, and again on every change when there is no `update` | returns the element, or markup | | `setup` | right after `render` | wire listeners | | `update` | the segment changes: new content, settling, a field you update | change what is there; add or remove no child | | `relabel` | the language or the icon set changed | the same rule as `update` | Keep the root that `render` returned: the message finds a segment by it to update it in place. Implement `relabel` when your segment shows words or icons that come from the configuration. ## Turn a tag into a segment [Section titled “Turn a tag into a segment”](#turn-a-tag-into-a-segment) ```ts import { aparteGlobalConfig, isSafeUrl, registerSegmentRenderer } from '@aparte/core'; import type { AparteSegmentBase, AparteStreamBlockMatch } from '@aparte/core'; interface CitationSegment extends AparteSegmentBase { type: 'citation'; url: string; content: string; } aparteGlobalConfig.registerStreamBlock({ tag: 'cite', toSegment: ({ attrs, id }: AparteStreamBlockMatch): CitationSegment => ({ id, type: 'citation', url: attrs['url'] ?? '', content: '' }), }); registerSegmentRenderer({ type: 'citation', render(segment) { const a = document.createElement('a'); a.href = segment.url && isSafeUrl(segment.url, 'a') ? segment.url : '#'; a.textContent = segment.content; return a; }, }); ``` When the model writes `…`, the stream parser calls `toSegment` at the opening tag, with the attributes as written and an id, and streams the tag’s body into `content` until the closing tag. The attributes come from the model: treat them as untrusted, and pass an address through `isSafeUrl` before it becomes a link. The tag matches whole and by case — `` is not `` — and a tag registered while the model is writing applies from its next turn. `unregisterStreamBlock(tag)` removes it. Tags belong to a configuration: a chat with its own reads the tags registered on that one. ## Defaults for a type [Section titled “Defaults for a type”](#defaults-for-a-type) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setSegmentDefaults('thinking', { collapsed: false }); // a block arrives open aparteGlobalConfig.setSegmentDefaults('chart', { values: [] }); ``` A default fills the fields a segment arrives without: one the producer set, even to `undefined`, keeps its value, and the identity fields — `id`, `type`, `messageId`, `index` — are never defaulted. Reasoning a provider sends on its own channel arrives folded, and folds again when the answer starts, whatever the default says. Defaults apply as segments arrive in a live reply or are added to one; a restored conversation keeps its segments as saved, and a segment already on screen does not change. A chat with its own configuration reads that configuration’s defaults. `getSegmentDefaults` reads them back and `clearSegmentDefaults` drops them. ## Drive the registry [Section titled “Drive the registry”](#drive-the-registry) Draw every type yourself, before the first message renders: ```ts import { declineDefaultRenderers } from '@aparte/core'; declineDefaultRenderers(); ``` Or install the built-ins now, which the first message would do, and list what is registered — in Node too: ```ts import { installDefaultRenderersOnce, getAllRenderers } from '@aparte/core'; installDefaultRenderersOnce(); const types = getAllRenderers().map((r) => r.type); ``` Declined, the built-ins stay out for good, so text, reasoning, code, errors and tool calls need renderers of yours. Each function takes a configuration as its last argument, to act on one chat’s registry. `new AparteClient({ autoRegister: false })` declines the built-ins the same way. ## Next [Section titled “Next”](#next) * [Custom view A segment of your own, live, with its variants.](/components/custom-view/) * [Durations How long a segment took, and numbers of your own on it.](/guides/customization/segment-timing/) # Place the chat on your page > Size the chat, fill the page like ChatGPT, float the composer, put a preview beside it, centre the welcome state, add a rail for long threads, and build the whole page around it. ## Give the chat a height [Section titled “Give the chat a height”](#give-the-chat-a-height) ```html ``` ```css /* under a header, in a flex column */ .app { display: flex; flex-direction: column; height: 100dvh; } .app aparte-chat { flex: 1; min-height: 0; height: auto; } ``` ```css /* the whole window */ html, body { height: 100%; margin: 0; } aparte-chat { height: 100dvh; } ``` The message list is what scrolls, not the page, so the chat needs a height to scroll inside: `` fills its parent, and a chat with nothing sized above it grows with its messages and never scrolls. `min-height: 0` lets a flex item shrink below its content, and `100dvh` follows a phone’s browser bar as it hides and shows. Keep the message list as the scroller: the chat follows a streaming reply, offers the jump back to the bottom and stops following when the person scrolls up, all from that list. ## Fill the page, like ChatGPT [Section titled “Fill the page, like ChatGPT”](#fill-the-page-like-chatgpt) ```css /* not this: a boxed chat puts its scrollbar inside the page */ .app { max-width: 820px; margin: 0 auto; } /* this: the chat spans the page, its messages keep a centred column */ .app { display: flex; flex-direction: column; height: 100dvh; } :root { --aparte-message-max-width: 48rem; } ``` [The reading column: an unboxed chat, the column from a token](/preview/demo/layout-reading-column/) ```html
``` The column is a width on the messages, not a box around the chat, so the scrollbar runs down the frame's edge. The messages and the composer share one centred column, `--aparte-message-max-width` wide, inside a list that spans the chat. Leave the chat unboxed and the scrollbar lands on the window’s edge while the reading column stays centred. ## Float the composer over the transcript [Section titled “Float the composer over the transcript”](#float-the-composer-over-the-transcript) ```html ``` [overlay-composer: the scroll surface spans the column, the composer floats over it](/preview/demo/layout-overlay/) ```html
``` The scrollbar runs past the composer to the bottom edge, and the last message stays clear of it. With `overlay-composer`, the message list takes the whole chat and the composer floats over its bottom, so the scrollbar runs the full height. Every other child of the chat, except the [scroll rail](#jump-back-in-a-long-thread), floats at the bottom with the composer, and the list measures that stack to keep the last message, and the jump-to-bottom button, clear of it. The framework packages take it as `overlayComposer`. Set it in the first markup: it is read when the chat mounts. A composer of your own, floated without the attribute, sets the space it covers itself: `aparte-chat-viewport { --aparte-bottom-inset: 96px; }`. ## Pick a shape [Section titled “Pick a shape”](#pick-a-shape) | Shape | Like | How | | ----------------------- | ------------------------------------- | ------------------------------------------------------------------------------------- | | Reading column | ChatGPT, Claude, Gemini | the default, unboxed: the section above | | Full-width feed | Slack, Discord | `aparte-chat { --aparte-message-max-width: none; }` | | Side panel | an editor’s or a browser’s chat strip | a panel with a height, and `.panel aparte-chat { --aparte-message-max-width: none; }` | | A chat beside a preview | Canvas, Artifacts, v0 | ``, below | | A floating widget | a support chat on a product’s site | the [Support widget](/templates/support-widget/) template builds one | A message list or a composer narrower than 520px switches to its narrow spacing by itself — a container query, not a media query — so a side panel and a phone get the same spacing. That spacing is set on the messages themselves, so a value you put on the chat does not reach them there: change it in a container query of your own. ```css @container (max-width: 520px) { .aparte-message { --aparte-message-padding-inline: 12px; } } ``` ## Put a preview beside the chat [Section titled “Put a preview beside the chat”](#put-a-preview-beside-the-chat) ```html
your preview
``` ```css .page { display: flex; flex-direction: column; height: 100dvh; } .page aparte-split { flex: 1; min-height: 0; } .pane-switch { display: none; } @media (max-width: 48rem) { .pane-switch { display: flex; gap: 0.5rem; } } ``` [The builder split: a chat beside a pane, on a seam you can drag](/preview/demo/layout-split/) ```html
``` Drag the seam, or tab to it and use the arrows. [``](/components/split/) holds two panes and the seam between them; `position` is the primary pane’s size, as a percentage: the first pane, unless `primary="end"`. It keeps nothing itself: `aparte-split-resize` fires once the position settles, never during a drag, which is when you save it. A pane holds a chat, never the other way round. Under its `breakpoint`, 48rem by default, it shows one pane at a time. A control carrying `data-aparte-split-pane="start"` or `"end"` switches the pane with no script, and the media query above hides those controls where both panes show. `single` shows one pane at every width — the chat alone, until there is something to preview. ## With an artifact in the pane [Section titled “With an artifact in the pane”](#with-an-artifact-in-the-pane) ```ts import { aparteGlobalConfig } from '@aparte/core'; import { setupArtifacts, createArtifactTool, artifactHandler } from '@aparte/plugin-artifacts'; import type { ArtifactInput } from '@aparte/plugin-artifacts'; declare function showInPane(artifact: ArtifactInput): void; // your preview const split = document.querySelector('aparte-split'); setupArtifacts(); aparteGlobalConfig.registerTool(createArtifactTool(), async (call) => { const result = await artifactHandler(call); showInPane(result.structuredContent as ArtifactInput); if (split) split.single = false; // open the pane beside the chat return result; }); ``` [The builder split with an artifact: the card in the chat, the result in the pane](/preview/demo/layout-artifact/) ```html
``` The reply is an artifact card; the pane beside it is the same document, opened by the app. When the model writes a document, [`@aparte/plugin-artifacts`](/components/artifact/) shows it as a card in the reply. To open it beside the chat too, start the split with `single` and register the tool again with a handler of yours: it returns the plugin’s result, so the card still renders, and it puts the same document in the pane. The [Research assistant](/templates/research-assistant/) template does this with its report. ## Centre the welcome state [Section titled “Centre the welcome state”](#centre-the-welcome-state) ```html

What can I help with?

``` ```css .welcome { display: none; text-align: center; } aparte-chat[data-empty] .welcome { display: block; } ``` [The welcome state: a centred composer until the first message](/preview/demo/layout-empty-state/) ```html

What are we building today?Ask anything, or start from one of these.

``` Send one message: the composer slides from the middle to the bottom. `center-empty` holds the composer in the middle of an empty chat and slides it down with the first message. While the chat is empty it carries `data-empty`, the hook for the greeting, which goes away as that message lands; [``](/components/suggestions/) shows its starters on the same condition. In the framework packages the switch is `centerWhenEmpty`, the greeting goes in `emptyState` (React) or the `empty-state` slot (Vue, Svelte, Angular), shown only while the chat is empty, and the hook for anything else is `data-aparte-empty`. ## Jump back in a long thread [Section titled “Jump back in a long thread”](#jump-back-in-a-long-thread) ```html
``` [``](/components/scroll-rail/) draws one tick per question along the message list, marks the one being read, and jumps to any of them on a click; `every="message"` ticks every message. It shows from the second tick on, and stays hidden on a touch screen, where its ticks are too thin to press. A click fires `aparte-scroll-rail-jump` (`detail.messageId`) first: cancel it to load older history, then call `rail.jumpTo(messageId)` yourself and let that second event through. Outside the chat — beside a framework component, say — give the chat an `id` and the rail a `target` naming it. ## The whole page: sidebar, header and chat [Section titled “The whole page: sidebar, header and chat”](#the-whole-page-sidebar-header-and-chat) ```html
My app
New chat
``` [The application shell: sidebar, header, chat](/preview/demo/app-shell/) ```html
aparté
Deploy checklist
gpt-4.1
``` The sidebar, the header, the chat. In a narrow frame the sidebar is a drawer behind the header's button. [`.aparte-app-shell`](/components/app-shell/) lays out the page: the sidebar beside, the [header](/components/header/) above, the chat in the rest. [``](/components/sidebar/) collapses, and becomes a drawer on a narrow screen, with a scrim, Escape to close and the focus handed back. Its search field filters the [conversation list](/components/conversation-list/) by title, and any control with `data-aparte-sidebar-toggle` opens and closes it: the header’s button shows while the sidebar is closed, and always on a narrow screen, so the one in the sidebar’s own header is what closes it on a wide one. None of it needs script. A split goes in `__main` the same way as a chat. Settings belong in the kit’s [dialog](/components/dialog/), and where the conversations come from is [Save conversations](/guides/conversation-persistence/). When the shell is static HTML, link `@aparte/core/styles.css` from the `` so it never paints bare — [Register the components](/guides/getting-started/#register-the-components) shows the line. ## Next [Section titled “Next”](#next) * [Templates Whole apps in these layouts: an assistant, an agent workshop, a support widget, a research assistant.](/templates/) * [Theme Colours, shapes and density for every layout.](/guides/theming/) * [Split Every attribute, event and key of the seam.](/components/split/) # Render Markdown and highlight code > Turn the Markdown a model writes into formatted text and coloured code, while the reply streams and once it is done. [Markdown rendered while it streams](/preview/demo/response-streaming/) ```html ``` Markdown formatted as it streams: the list grows item by item. ## Install and register [Section titled “Install and register”](#install-and-register) ```bash npm install @aparte/plugin-streaming-markdown streaming-markdown @aparte/plugin-marked marked @aparte/plugin-shiki shiki ``` ```ts import { setupStreamingMarkdownProvider } from '@aparte/plugin-streaming-markdown'; import { setupMarkedProvider } from '@aparte/plugin-marked'; import { setupShikiProvider } from '@aparte/plugin-shiki'; setupStreamingMarkdownProvider(); setupMarkedProvider(); setupShikiProvider({ theme: { light: 'github-light', dark: 'github-dark' } }); ``` Core has no Markdown of its own: with no renderer set, a reply shows its text as written, escaped. Each plugin needs its library installed beside it — `streaming-markdown`, `marked`, `shiki` — and none of it reaches a page that does not install the plugin. Register them before the chat renders: a message drawn earlier keeps the plain text it was drawn with. ## Which one formats what [Section titled “Which one formats what”](#which-one-formats-what) * **`@aparte/plugin-streaming-markdown`** formats the reply while it streams, adding what each chunk brings instead of formatting the whole reply again. Alone, it formats finished and restored messages too. * **`@aparte/plugin-marked`** formats a finished or restored message at full fidelity. Beside the streaming plugin, it formats a reply again once it finishes; alone, it formats a streaming reply too, from the start at each chunk. * **`@aparte/plugin-shiki`** colours code: the code blocks of a reply — the stream parser makes each fence its own block — the fences in a message given as plain content and in reasoning, and the JSON a tool call shows. A fence inside a `text` segment you build yourself stays plain: send it as a `code` segment. To pass marked its options, give them to the setup: `setupMarkedProvider({ gfm: true, breaks: true })`. With options it uses a marked of its own, so two configurations can format differently. Another parser plugs into the same place: `aparteGlobalConfig.setMarkdownProvider((markdown) => html)`, a synchronous function from the Markdown to its HTML. ## Light and dark code [Section titled “Light and dark code”](#light-and-dark-code) The theme pair above follows the page: the dark theme under `data-aparte-theme="dark"` on any element around the code, or under a dark system unless `` carries `data-aparte-theme="light"`; the light one otherwise. A chat set light inside a dark page therefore keeps dark code. A single theme name, such as `{ theme: 'github-dark' }`, keeps that look in both, and with no `theme` at all code uses `github-dark` in both. A language shiki does not know shows as plain text. ## Ship only the grammars you need [Section titled “Ship only the grammars you need”](#ship-only-the-grammars-you-need) ```bash npm install @shikijs/langs @shikijs/themes ``` ```ts import { createHighlighterCore } from 'shiki/core'; import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; import ts from '@shikijs/langs/typescript'; import bash from '@shikijs/langs/bash'; import githubDark from '@shikijs/themes/github-dark'; import { setupShikiProviderFromHighlighter } from '@aparte/plugin-shiki/core'; createHighlighterCore({ themes: [githubDark], langs: [ts, bash], engine: createJavaScriptRegexEngine(), }).then((highlighter) => setupShikiProviderFromHighlighter(highlighter, { theme: 'github-dark' })); ``` This replaces the `setupShikiProvider` call of the first step: an import of `@aparte/plugin-shiki` anywhere in your app brings every grammar back. That entry loads each grammar the first time a reply needs it, but your bundler cannot know which ones that will be, so it emits a file for every language shiki knows. The `/core` entry takes a highlighter you build with the grammars you choose, and your build carries those alone; a language it lacks shows as plain text. For a light and dark pair, load both themes into it: `themes: [githubLight, githubDark]`. The options are typed: `ShikiProviderOptions` for `setupShikiProvider`, `ShikiCoreProviderOptions` for the `/core` entry, `ShikiThemePair` for a light and a dark theme. A highlighter of your own passed to `/core` is a `ShikiHighlighterLike`, called with `ShikiRenderOptions`. ## Use another highlighter [Section titled “Use another highlighter”](#use-another-highlighter) ```ts import { aparteGlobalConfig } from '@aparte/core'; declare const myHighlighter: { toHtml(code: string, lang: string): Promise }; aparteGlobalConfig.setHighlightProvider((code, lang) => myHighlighter.toHtml(code, lang)); ``` The provider gets the code and its language, and returns the whole block as HTML, `
` included, as a string or a promise of one. ## Keep it safe [Section titled “Keep it safe”](#keep-it-safe) 
```ts
import DOMPurify from 'dompurify';
import { aparteGlobalConfig } from '@aparte/core';

aparteGlobalConfig.setHtmlSanitizer((html) => DOMPurify.sanitize(html));
```
 What marked and a highlighter return goes through the chat’s HTML sanitizer before it reaches the page, and so does a streamed reply once it finishes, so the Markdown a model writes cannot run script. While a reply streams, a link or an image with an unsafe address is dropped, and a link to another site opens in a new tab. The built-in sanitizer needs no dependency; [`setHtmlSanitizer`](/reference/core/config/#sethtmlsanitizer) swaps in one of yours, such as DOMPurify. ## Route the links a model writes [Section titled “Route the links a model writes”](#route-the-links-a-model-writes) 
```ts
declare const router: { push(path: string): void };   // your app's router

const chat = document.querySelector('aparte-chat');

chat?.addEventListener('aparte-link-click', (event) => {
  const url = new URL(event.detail.href, location.href);
  if (url.origin !== location.origin) return;   // another site: let the browser open it
  event.preventDefault();                       // this one is yours
  router.push(url.pathname + url.search);
});
```
 Before the browser follows a link in a reply, the message fires `aparte-link-click`. It bubbles, so one listener on the chat covers every message, and it can be cancelled: call `preventDefault()` and the navigation is yours — your router, a webview, a question first. The detail is `{ href, anchor, messageId }`. ## Two chats, two setups [Section titled “Two chats, two setups”](#two-chats-two-setups) 
```ts
import { AparteConfig, attachConfig } from '@aparte/core';
import { setupStreamingMarkdownProvider } from '@aparte/plugin-streaming-markdown';
import { setupMarkedProvider } from '@aparte/plugin-marked';
import { setupShikiProvider } from '@aparte/plugin-shiki';

const support = new AparteConfig();
setupStreamingMarkdownProvider(support);
setupMarkedProvider({ breaks: true }, support);
setupShikiProvider({ theme: 'github-dark' }, support);

const chat = document.querySelector('#support-chat');
if (chat) attachConfig(chat, support);
```
 Every setup takes a configuration as its last argument, the page-wide one by default. A configuration of your own starts from the built-in defaults and reads nothing the page-wide one registered, so set up on it every formatter that chat uses; attached to a chat, it formats that chat alone. The framework packages take it as their `config` prop. ## Next [Section titled “Next”](#next) * [Response The formatted reply, its variants and its options.](/components/response/) * [Code block Code as its own part of a reply, with its language and copy button.](/components/code-block/) * [Customization Your own way to draw a part of a message.](/guides/customization/)

# Theme the chat to your brand, in light and dark

> Restyle the whole chat with CSS variables — your brand colours, light and dark, density and shape — on the page, on one part of it, or on one chat.

[The chat in a brand of its own](/preview/demo/theme-brand/) 
```html



``` Eight variables on a wrapper, and every part of the chat follows them. ## Set your brand colours [Section titled “Set your brand colours”](#set-your-brand-colours) ```css :root, [data-aparte-theme="light"] { --aparte-primary: #b45309; /* the accent: send button, links, the user's bubble */ --aparte-primary-hover: #92400e; --aparte-bg: #fbf7f0; /* your page's background */ --aparte-surface-1: #ffffff; /* cards, code blocks, the composer */ --aparte-surface-2: #f4ece0; /* headers, inline code */ --aparte-text: #241a12; --aparte-text-muted: #7c6f60; --aparte-border: #e7dccb; } ``` The chat’s colours follow these eight: 241 of core’s variables read another one, such as `--aparte-input-bg`, which is `var(--aparte-surface-1)`, or `--aparte-avatar-bg-user`, which is `var(--aparte-primary)`. Set the bases, not each value they feed. The block wins whichever stylesheet loads first, and its second selector keeps it on an element [forced light](#light-and-dark). A few do not follow them. The status colours — `--aparte-info`, `--aparte-success`, `--aparte-warning`, `--aparte-error` — and `--aparte-secondary` are yours to set in the same block if your brand has an opinion, and so is `--aparte-neutral`, which draws the focus ring, the scrollbar and the neutral buttons. The chat paints no background of its own, so your page shows through it: `--aparte-bg` only tints the progress track and the loading skeleton, and is there for your page too (`body { background: var(--aparte-bg); }`). ### The text on a fill [Section titled “The text on a fill”](#the-text-on-a-fill) ```css :root { --aparte-primary: #1a1a2e; --aparte-on-primary: #f8fafc; } ``` The label on a filled button, badge or checkbox is worked out from the fill’s lightness, so it stays readable whatever colour you choose. Set its pair to choose it yourself: `--aparte-on-primary`, `--aparte-on-secondary`, `--aparte-on-neutral`, `--aparte-on-info`, `--aparte-on-success`, `--aparte-on-warning` and `--aparte-on-error`. Two numbers tune the worked-out ink: `--aparte-ink-flip`, the fill lightness where it turns from light to dark, and `--aparte-ink-dark`, how dark it gets. ## Light and dark [Section titled “Light and dark”](#light-and-dark) ```html … ``` With no attribute, the system’s `prefers-color-scheme` decides. `data-aparte-theme` forces either way, on the chat or on any ancestor, so a light panel inside a dark page stays light. The attribute puts core’s palette on the element that carries it, which is why your light block names `[data-aparte-theme="light"]` beside `:root`. ## Give dark its own colours [Section titled “Give dark its own colours”](#give-dark-its-own-colours) ```css /* light */ :root, [data-aparte-theme="light"] { --aparte-primary: #087ea4; /* …the rest of your light palette */ } /* dark, when your toggle sets the attribute */ [data-aparte-theme="dark"] { --aparte-primary: #58c4dc; /* …the same tokens, in dark */ } /* dark, when the system decides */ @media (prefers-color-scheme: dark) { :root:not([data-aparte-theme="light"]) { --aparte-primary: #58c4dc; /* …the same tokens, in dark */ } } ``` Without a dark block, a dark system shows core’s dark palette rather than your light colours. Give each dark block every token your light block sets. Core’s dark palette has a value for the eight, `--aparte-neutral`, the shadows, the scrim behind dialogs and the error colours (`--aparte-error-bg`, `--aparte-error-text`, `--aparte-error-border`, `--aparte-error-solid`), and any of those you leave out takes core’s dark value rather than yours. Keep the selector of the third block as it is: a bare `:root` inside the media query loses to core’s dark palette. ## Theme one chat, or part of the page [Section titled “Theme one chat, or part of the page”](#theme-one-chat-or-part-of-the-page) ```html ``` A variable set on an element reaches everything inside it: one [chat](/components/chat/), a sidebar, a section of the page. A token that reads another one, such as `--aparte-avatar-bg-user` or `--aparte-radius-bubble`, is worked out again on every `aparte-chat`, every `[data-aparte-chat]` root the React, Vue and Svelte components render, and every `[data-aparte-theme]` element, so a chat given its own `--aparte-primary` gets its own user bubble and avatar too. The same rule stops a value you give such a token on `:root` at the chat: set it on the chat, in a rule with a class as above, which outweighs core’s own declaration there whatever order the stylesheets load in. The [CSS variables](/reference/css-variables/) reference marks those tokens **anchored**. In React, Vue and Svelte, the chat’s root is a `
`, and the same tokens are worked out again there: give it a class and write `[data-aparte-chat].sales { … }`. ## Make it denser, squarer or larger [Section titled “Make it denser, squarer or larger”](#make-it-denser-squarer-or-larger) ```css :root { --aparte-space-unit: 1.5px; /* every gap and padding, tighter */ --aparte-radius-unit: 2px; /* every corner, squarer */ --aparte-font-scale: 1; /* every text size, a notch smaller */ --aparte-btn-size-md: 28px; /* the medium control, and every size read from it */ } ``` The sizes are scales, each a base times a step, so one base moves a whole family: | Scale | Tokens | Base | | -------- | --------------------------------------------------- | ---------------------- | | Spacing | `--aparte-space-1` … `--aparte-space-8` | `--aparte-space-unit` | | Corners | `--aparte-radius-xs` … `--aparte-radius-xl` | `--aparte-radius-unit` | | Text | `--aparte-font-size-2xs` … `--aparte-font-size-2xl` | `--aparte-font-scale` | | Controls | `--aparte-btn-size-sm`, `-md`, `-lg`, `-xl` | each its own | `--aparte-radius-full`, the pill, stays a pill whatever the unit. A single step can be set too, on the chat with a class: `aparte-chat.chat { --aparte-space-6: 8px; }` pulls in the paddings that use it and leaves the rest of the scale alone. The kit’s controls read the same tokens, so a page of [buttons](/components/button/) and [fields](/components/field/) moves with the chat. ## Small recipes [Section titled “Small recipes”](#small-recipes) They set tokens on ``. In React, Vue and Svelte, give the chat’s root the class with `className` and write `[data-aparte-chat].chat`. ### Give the assistant a bubble too [Section titled “Give the assistant a bubble too”](#give-the-assistant-a-bubble-too) ```css aparte-chat.chat { --aparte-message-content-bg-assistant: var(--aparte-surface-2); } .aparte-message[data-role="assistant"] .aparte-message-content { width: fit-content; padding: var(--aparte-message-content-padding); } ``` The assistant’s reply is plain text across the column by default, and only the person’s words sit in a bubble. The token colours the reply; the rule gives it the user bubble’s shape. ### Resize the composer [Section titled “Resize the composer”](#resize-the-composer) ```css aparte-chat.chat { --aparte-composer-control-size: 52px; } ``` The input’s height and every button in the composer follow it. On a touch screen they take `--aparte-touch-target-size` instead. ### Change the code font [Section titled “Change the code font”](#change-the-code-font) ```css :root { --aparte-code-font-family: 'JetBrains Mono', ui-monospace, monospace; } ``` ### Mark a row as chosen [Section titled “Mark a row as chosen”](#mark-a-row-as-chosen) ```css :root { --aparte-mark-tint: 12%; /* a quieter ground, for marked rows and the user's bubble */ --aparte-mark-bar: 3px; /* a bar on the start edge, which has none by default */ } ``` ```html
  • Chosen
  • Accepted
  • Declined
  • ``` The [mark](/components/mark/) is how the chat shows what was chosen: a select’s option, a checked choice in a question, the answer a question kept. The same class marks a row of yours. `--success`, `--danger` and `--neutral` change the intent; `--quiet` is the one that did not happen, with no tint and muted text. ### Type a theme in TypeScript [Section titled “Type a theme in TypeScript”](#type-a-theme-in-typescript) ```ts import type { AparteThemeVariables } from '@aparte/core'; export const brand: AparteThemeVariables = { '--aparte-primary': '#b45309', '--aparte-surface-1': '#ffffff', }; for (const [name, value] of Object.entries(brand)) { if (value) document.documentElement.style.setProperty(name, value); } ``` A key that does not start with `--aparte-` is a compile error. The name after the prefix is not checked, which is what the next recipe is for. ### Check that every token you use exists [Section titled “Check that every token you use exists”](#check-that-every-token-you-use-exists) A `var(--aparte-text-primary)` that names a token the library does not have fails without a word: the property falls back to its inherited or initial value, and the page looks almost right. The stylesheet you import names every token it declares or reads, so a script can check your CSS against it: ```js // aparte-tokens.mjs — node aparte-tokens.mjs src/**/*.css import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; const sheet = readFileSync(createRequire(import.meta.url).resolve('@aparte/core/styles.css'), 'utf8'); const known = new Set(sheet.match(/--aparte-[\w-]+/g)); let bad = 0; for (const file of process.argv.slice(2)) { for (const name of new Set(readFileSync(file, 'utf8').match(/--aparte-[\w-]+/g) ?? [])) { if (!known.has(name)) { bad++; console.log(`${file}: ${name} is not an @aparte/core token`); } } } process.exit(bad ? 1 : 0); ``` Run it with your tests. A name it reports that you declared yourself is fine, though a token of your own reads better under a prefix of your own. ## Inside a shadow root of your own [Section titled “Inside a shadow root of your own”](#inside-a-shadow-root-of-your-own) Load `@aparte/core/styles.css` inside the shadow root — a `` in it, or `adoptedStyleSheets` — since a document’s stylesheet does not cross into it. The stylesheet declares its tokens on `:host` as well as `:root`, so the chat themes the same way: set the variables on the host or on any element inside, and put `data-aparte-theme` on the host itself, since an attribute on an element outside the shadow root does not reach the chat. `` and `` look their partner up in the document; inside a shadow root, put them in the chat and leave `target` out. ## Next [Section titled “Next”](#next) * [Customization Past colours and sizes: your own icons, avatars, buttons and parts of a message.](/guides/customization/) * [CSS variables Every token, its default, and whether it is anchored.](/reference/css-variables/) * [Layouts A reading column, a full-width feed, a side panel, a floating composer.](/guides/layout/) * [The kit The controls around the chat, in the same theme.](/kit/) # UI Kit for an AI Chat App — plain CSS classes, 25 families > 25 families of plain CSS classes for the chrome around a chat — buttons, fields, tabs, dialogs, avatars, the sidebar — themed by the same variables as the chat itself. Every recipe here is **plain classes on plain elements**: no component to import, no framework, no shadow DOM — copy the markup, restyle it with the same [CSS variables](/reference/css-variables/) the chat reads. Each family has one page, its live preview first; every class of the kit on one page is the [classes reference](/reference/classes/). ## Controls [Section titled “Controls”](#controls) * **[Button](/components/button/)** — The button. Ready-made classes; nothing to configure. * **[Field](/components/field/)** — Form fields. Ready-made classes; nothing to configure. ## Display [Section titled “Display”](#display) * **[Avatar](/components/avatar/)** — Sizes, shape, image + initials fallback * **[Icon](/components/icon/)** — The icon. Every glyph the library draws carries this class. * **[Badge](/components/badge/)** — Small count or status pill * **[Tag](/components/tag/)** — A removable chip * **[Thumbnail](/components/thumbnail/)** — The thumbnail. A media tile: a fixed square that clips whatever is dropped * **[Spinner](/components/spinner/)** — A rotating indicator * **[Progress](/components/progress/)** — Determinate bar + indeterminate variant * **[Skeleton](/components/skeleton/)** — A loading placeholder * **[Divider](/components/divider/)** — Horizontal, vertical, optionally labelled * **[Alert](/components/alert/)** — A callout * **[Card](/components/card/)** — A surface with optional header/body/footer * **[Kbd](/components/kbd/)** — A keyboard key * **[Mark](/components/mark/)** — The mark: a row a list has settled on. ## Surfaces [Section titled “Surfaces”](#surfaces) * **[Tabs](/components/tabs/)** — One row of tabs, and the panel under it * **[Accordion](/components/accordion/)** — Stacked disclosures * **[Menu](/components/menu/)** — A floating list of commands * **[Popover](/components/popover/)** — A floating box, and nothing else * **[Dialog](/components/dialog/)** — A modal, drawn on the native \. * **[Tooltip](/components/tooltip/)** — A short label, with an arrow ## Shell [Section titled “Shell”](#shell) * **[Sidebar](/components/sidebar/)** — The column beside the chat. * **[App Header](/components/header/)** — The bar above the chat. * **[App Shell](/components/app-shell/)** — Sidebar beside, header above, the chat in the rest. * **[Split](/components/split/)** — Two panes and a draggable seam. # CSS classes > Every ready-made class aparté ships — buttons, fields, tags, icons, alerts and the rest — generated from the stylesheets that define them. Core is **light DOM**: it writes ordinary classes onto ordinary elements, and so can you. The classes below are the ones aparté styles for its own use *and leaves available* — put `aparte-btn` on a button of yours and it looks like the send button, with no component to mount and no framework involved. Your own class stays on the element for events and for targeting. It just stops carrying the look. Everything here is themed by the [CSS variables](/reference/css-variables/); the recipes read those tokens, so a rebrand reaches your buttons and aparté’s at the same time. ## Controls [Section titled “Controls”](#controls) Things a person operates. Each is one base class plus modifiers that compose — an intent says what the control means, a fill says how loudly to say it, and the two axes do not know about each other. ### Button [Section titled “Button”](#button) aparté — the button. Ready-made classes; nothing to configure. TWO AXES, and keeping them apart is the whole design. An INTENT says which colour the button means; a FILL says what to do with that colour. Seven intents times five fills is thirty-five buttons out of twelve classes, and every combination works because neither axis knows about the other. An intent on its own is a ghost — the quietest form, and the one a chat uses most. Add a fill when the button has to carry more weight. `.aparte-btn-group` fuses adjacent buttons into ONE control: the inner corners drop, the shared borders overlap, and only the group’s ends keep the radius. Give the wrapper `role="group"` and a name — visually one control, it should read as one too. A SEGMENTED control is the same group with one segment selected: mark it `aria-pressed="true"` (or `aria-selected="true"` under `role="tablist"`) and it is painted solid in the group’s intent, at rest — not only under the pointer. This layer is NEUTRAL: it is what a UI library offers, not a summary of what this repo happens to use. A variant nothing wears still ships, because the plugin that needs it is not written yet, and its absence is what makes an author invent a seventh shade of orange. Your own class stays on the element, for events and for targeting. It just stops carrying the look. [Live preview of the Button classes](/preview/class/button/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. ```html
    ``` `.aparte-btn` · `.aparte-btn--block` · `.aparte-btn--circle` · `.aparte-btn--danger` · `.aparte-btn--ghost` · `.aparte-btn--icon` · `.aparte-btn--info` · `.aparte-btn--lg` · `.aparte-btn--neutral` · `.aparte-btn--outline` · `.aparte-btn--pill` · `.aparte-btn--primary` · `.aparte-btn--secondary` · `.aparte-btn--sm` · `.aparte-btn--soft` · `.aparte-btn--solid` · `.aparte-btn--success` · `.aparte-btn--surface` · `.aparte-btn--warning` · `.aparte-btn--xl` · `.aparte-btn-group` · `.aparte-btn__icon` · `.aparte-icon` ### Field [Section titled “Field”](#field) aparté — form fields. Ready-made classes; nothing to configure. Every input control a UI library offers: text entry, textarea, select, checkbox, radio, switch, range. The library renders into the LIGHT DOM — there is no shadow root and no ::part() — so a class is the only handle a consumer has onto a native form control, and `appearance: none` is how this file takes the browser’s own chrome off a checkbox/radio/switch/range so these classes can repaint it. `.aparte-field` is ONE shared recipe — border, background, radius, padding, focus ring, placeholder — applied to \, \