Themes

AI Chat ships eight themes users can switch between, and the theming system is open - a host or an individual user can add their own by dropping a JSON file into App_Data, with no rebuild and no fork.

Bundled themes

Theme colorScheme Background image
light light -
light_sky light
light_slate light
soft_pink light
dark dark -
nord dark
matrix dark
blue_smoke dark

The theme picker splits them into Light Themes and Dark Themes columns based on each theme's colorScheme, with light and dark pinned first in their respective columns and the rest sorted by display name. Each entry renders a miniature chrome preview from the theme's own preview values.

Anatomy of a theme

A theme is a JSON document with three sections:

{
  "preview": {
    "chromeBorder": "border-frost-700/50",
    "bgBody": "bg-nord-900",
    "bgSidebar": "bg-nord-800",
    "icon": "text-nord-300",
    "heading": "text-nord-200"
  },
  "vars": {
    "colorScheme": "dark",
    "--background-image": "url(/themes/nord/ui/bg.webp)",
    "--background": "#111827",
    "--user-bg": "#3b4252",
    "--user-text": "#eceff4",
    "--assistant-bg": "#0000001A",
    "--assistant-text": "#eceff4"
  },
  "styles": {
    "bgSidebar": "bg-nord-900 lg:bg-nord-900/50",
    "heading": "text-nord-200",
    "primaryButton": "border border-transparent shadow-sm text-white bg-frost-700 hover:bg-frost-700/80 rounded-md"
  }
}
Section Applied as
preview The five values the theme picker's mini preview renders with
vars colorScheme, plus CSS custom properties set on document.documentElement
styles Named slots mapped to Tailwind class strings, bound throughout the UI

vars

colorScheme is special: "dark" or "light" decides which built-in base the theme layers on, which column it appears in, and whether the document is put into dark mode. Everything else beginning with -- is set directly as a CSS custom property.

The variables worth knowing:

Variable Controls
--background, --background-image Page background colour and image
--border, --input, --ring Borders, input borders, focus rings
--primary-bg, --secondary-bg, --secondary-border Surface colours
--scrollbar-track-bg, --scrollbar-thumb-bg Scrollbars
--user-bg, --user-text, --user-border User message bubbles
--assistant-bg, --assistant-text, --assistant-border Assistant message bubbles
--tw-prose-* Rendered Markdown - body, headings, links, code, tables

--user-* and --assistant-* do double duty: they're also what generated avatars are drawn with.

styles

styles is a flat map of named UI slots to Tailwind class strings. There are around 60, bound directly to elements throughout the Chat UI - so a theme restyles the App by naming classes, not by shipping CSS.

Group Keys
Chrome chromeBorder, appInner, bgBody, bgSidebar, bgChat, bgPage, panel, dialog
Text heading, muted, mutedHover, mutedActive, highlighted, link, linkHover, textLink
Inputs bgInput, textInput, borderInput, labelInput, helpInput, checkbox, draggingInput
Buttons primaryButton, secondaryButton, chatButton, dropdownButton, tabButton
Tags tagButtonGroup, tagButton, tagButtonActive, tagButtonLarge, tagButtonSmall, tagLabel, tagLabelHover
Cards card, cardTitle, cardActive, cardActiveTitleBar, infoCard, textBlock
Popovers bgPopover, popoverButton, popoverButtonActive
Icons icon, bgIcon, iconHover, iconActive, iconPartial, iconFull, mutedIcon, mutedIconHover
Code codeTag, codeTagStrong
Threads threadItem, threadItemActive, threadItemActiveBorder
Messages messageUser, messageAssistant
Voice voiceButtonDefault, voiceButtonRecording, voiceButtonProcessing
Status bgSuccess, bgWarning

A theme only needs to define the slots it wants to change - everything else falls through to the base theme.

How a theme is resolved

Themes are layered, so a custom theme can be as small as a handful of overrides:

preview is taken from the theme when it defines one; otherwise it's derived by looking up each of the base theme's preview keys in the resolved styles.

colorScheme itself is resolved from the theme's vars.colorScheme, falling back to the browser's stored colour scheme and then prefers-color-scheme.

Where themes live

Themes are discovered from three roots and merged, with later roots overriding earlier ones:

Within each root, a theme can take either shape:

Directories are merged first, then flat files. A directory's theme.json overrides only the keys it defines; a flat {name}.json is treated as the complete definition, with any vars already merged from a directory stub layered back over the top.

That's exactly how the bundled light and dark themes work - dark.json carries the full definition while dark/theme.json contributes nothing but its colorScheme.

INFO

shared.json is not a theme. It holds the vars and styles every theme inherits and is deliberately excluded from the theme listing.

Adding your own theme

The simplest custom theme starts from a built-in base and overrides a few values. Create App_Data/chat/user/default/themes/company.json to give it to every user:

{
  "vars": {
    "colorScheme": "light",
    "--user-bg": "#e0f2fe",
    "--user-border": "#bae6fd",
    "--assistant-bg": "#f8fafc",
    "--ring": "#0284c7"
  },
  "styles": {
    "heading": "text-sky-900",
    "primaryButton": "border border-transparent shadow-sm text-white bg-sky-600 hover:bg-sky-500 rounded-md",
    "threadItemActive": "bg-sky-50 border-sky-200"
  }
}

It appears in the theme picker's Light column immediately - no rebuild, no plugin change.

With a background image

Use the directory shape so the theme can carry its own assets:

App_Data/chat/user/default/themes/company/
    theme.json
    ui/
        bg.webp
{
  "vars": {
    "colorScheme": "dark",
    "--background-image": "url(/themes/company/ui/bg.webp)"
  },
  "styles": {
    "appInner": "bg-black/40"
  }
}

Assets are served at {RoutePrefix}/themes/{theme}/ui/{file}. Reference them with a site-root URL - url(/themes/...) - and AI Chat rebases it onto RoutePrefix as the theme is served, so the same theme file works whether the UI is mounted at /chat or at the site root.

TIP

The asset route is deliberately anonymous, so the sign-in screen renders themed. The /themes listing itself stays authenticated, and the UI falls back to its bundled light/dark defaults until the user signs in.

Custom Tailwind palettes

Themes like nord reference custom colour utilities (bg-nord-900, text-frost-600). Those resolve because the corresponding --color-nord-* and --color-frost-* variables are registered in the Chat UI's stylesheet.

A custom theme is safest sticking to Tailwind's built-in palette. To introduce your own colour scale, define the --color-* variables from an extension:

public override void Install(ExtensionContext ctx)
{
    ctx.AddIndexHeader("""<link rel="stylesheet" href="/chat/custom/theme.css">""");
}
/* chat/custom/theme.css */
:root {
    --color-brand-500: #0284c7;
    --color-brand-900: #0c4a6e;
}

How a theme is applied

Selecting a theme sets every -- variable on document.documentElement, sets document.body.className from the resolved bgBody, and toggles the document's dark mode from colorScheme. The resolved styles map is exposed to every component, so the UI restyles reactively without a reload.

The catalog is fetched once from GET {RoutePrefix}/themes and cached in localStorage under llms.themes, so the chosen theme is applied on the next load before the network call completes.

Selection and persistence

The active theme is resolved in this order:

Choosing a theme writes to both the user's server-side preferences and localStorage, so it follows the user across browsers and applies instantly on this one.

An unknown theme name falls back to the built-in theme matching the current colour scheme, so removing a custom theme can't leave a user stranded.

Agent Profiles can pin a theme

An Agent Profile can specify the theme it runs under, so switching to a Coder or Analyst profile visually signals the change of context:

{
  "theme": "nord",
  "model": "GLM-5.2",
  "onlyTools": ["api_tools"]
}

Themed avatars

Generated user and agent avatars are drawn from the active theme, so they never clash with it:

The theme's colorScheme selects light or dark text, --user-bg / --user-text colour user avatars, and --assistant-bg / --assistant-text colour agent avatars. Without a theme parameter, sensible light/dark defaults are used.

Uploaded avatars replace generated ones:

API

Both routes belong to the app extension. Theme names containing .. are rejected, and a directory only serves assets when it also contains a theme.json.