Replies can be wrong. Check what matters.
Replies can be wrong. Check what matters.
Replies can be wrong. Check what matters.
Replies can be wrong. Check what matters.
What can I help with?
What are we building today?Ask anything, or start from one of these.
` 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 \