The appearance of DIAL Chat is controlled by a theme: a set of colors, logos, and icons served by an external themes service and applied at runtime as CSS custom properties. Themes are deployment configuration, not a rebuild — the same application image can be rebranded by pointing it at a different themes service.
This document covers configuring a theme for a deployment, and migrating a
theme from the legacy DIAL Chat. Styling of the reusable components in libs/*
is a separate concern — see Styling libraries.
Themes are served by a standalone static host, as in the legacy chat — see
DIAL Chat Themes for deploying
one. Configure chat-api with its base URL:
THEMES_CONFIG_URL=https://your-themes-host.example.com
THEMES_SERVICE_TIMEOUT_MS=5000THEMES_SERVICE_TIMEOUT_MS is optional and defaults to 5000.
The frontend never talks to the themes host directly. chat-api fetches
<THEMES_CONFIG_URL>/config.json and exposes it at /api/themes; image assets
are proxied through /api/themes/icon?iconName=<name>, which validates the name
against path traversal. Both responses are cached for 5 minutes, so a change on
the themes host takes up to 5 minutes (plus that host's own cache) to appear.
When THEMES_CONFIG_URL is unset, the configuration request fails, the
application logs the failure and continues with its built-in palette: the
light-theme hex fallbacks compiled into tailwind.config.js. The user menu then
offers no theme entries at all.
{
"themes": [
{
"id": "light",
"displayName": "Light",
"colors": {
"bg-layer-base": "#F5F7FA",
"text-primary": "#161B2D"
},
"app-logo": "logo-light.svg"
},
{
"id": "dark",
"displayName": "Dark",
"colors": {
"bg-layer-base": "#0C101D",
"text-primary": "#F3F4F6"
},
"app-logo": "logo-dark.svg"
}
],
"images": {
"chat-logo-light": "logo-light.svg",
"chat-logo-dark": "logo-dark.svg",
"chat-favicon": "favicon.png"
}
}Each entry in colors is applied verbatim as a CSS custom property on the
<html> element: key bg-layer-base becomes --bg-layer-base. Do not write
the -- prefix in the file.
Keys are not validated. An unrecognized key is set as a CSS variable that nothing reads — the theme still loads, and the misspelled color silently has no effect. A recognized key that a theme omits falls back to the built-in light value. This is the single most common way a theme "half applies".
The picker in the user menu is driven by the ids present in the configuration:
| Configured ids | Picker shows |
|---|---|
light and dark |
Light, Dark, and System |
| only one of them | just that one entry |
| neither | no theme entries |
System is offered only when both light and dark exist; it follows
prefers-color-scheme and re-resolves when the OS setting changes. The
selection is stored in the browser's local storage, so it is per user and per
device.
Ids other than light, dark, and system can be present in the file, but the
picker has no entry for them. They are reachable only through the overlay's
theme option (see below).
The application reads exactly three image fields:
| Field | Used for |
|---|---|
images.chat-logo-light |
Header logo while the resolved theme is not dark |
images.chat-logo-dark |
Header logo while the resolved theme is dark |
images.chat-favicon |
Browser tab favicon (PNG, 32×32 recommended) |
The remaining fields carried by the configuration contract — themes[].app-logo,
images.favicon, images.default-addon, images.default-model — are accepted
and ignored. Keep them in the file if the legacy chat is still served from the
same themes host; do not expect them to change anything here.
Values are file names relative to the themes host; they are fetched through the
/api/themes/icon proxy, so only characters allowed by its validation
(alphanumerics, dash, underscore, dot) are usable.
An embedded overlay selects the theme by id, both at construction and at runtime:
const overlay = new ChatOverlay('#chat-root', {
domain: 'https://chat.example.com',
theme: 'dark',
});
await overlay.setOverlayOptions({ theme: 'light' });The id must exist in the deployment's configuration file. See the Chat Overlay Migration Guide.
--theme-font exists, but apps/chat/src/styles.scss declares it on html,
body, and #root. A value supplied through a theme's colors map lands on
<html> only and is shadowed for everything inside #root, so it has no
practical effect. Changing the application font currently requires editing that
stylesheet.
These are the CSS custom properties the application reads. Anything else in
colors is inert.
Surfaces
bg-layer-sunken bg-layer-base bg-layer-raised
bg-backdrop bg-error bg-warning
bg-info bg-success
Controls
bg-control-accent bg-control-accent-hover
bg-control-accent-alpha bg-control-accent-alpha-hover
bg-control-accent-alpha-active
bg-control-neutral bg-control-neutral-default
bg-control-neutral-hover-muted bg-control-neutral-hover-strong
bg-control-neutral-active bg-control-inverted
bg-control-error bg-control-error-hover
bg-control-error-active bg-control-error-alpha-hover
bg-control-error-alpha-active
bg-control-disable-primary bg-control-disable-secondary
bg-gradient-1 bg-gradient-1-hover
bg-gradient-1-active bg-gradient-2
bg-gradient-2-hover bg-gradient-2-active
The bg-gradient-* stops paint the accent gradient behind primary buttons and
the selected tab's underline.
Decorative fills
bg-visual-blue bg-visual-green-1 bg-visual-green-2
bg-visual-brown bg-visual-red bg-visual-violet-1
bg-visual-violet-2
Text
text-primary text-secondary text-tertiary
text-accent
text-error text-warning text-warning-icon
text-info text-success
text-control-permanent text-control-inverted
text-control-accent-hover text-control-accent-active
text-control-disable-primary text-control-disable-secondary
text-visual-blue text-visual-brown-1 text-visual-brown-2
text-visual-green-1 text-visual-green-2 text-visual-green-3
text-visual-red text-visual-violet-1 text-visual-violet-2
Strokes
stroke-primary stroke-secondary stroke-tertiary
stroke-error stroke-error-alpha stroke-warning
stroke-info stroke-success stroke-accent
stroke-accent-alpha stroke-default stroke-focus-black
stroke-accent-focus stroke-gradient-1 stroke-gradient-2
stroke-control-disable-primary
These name the stroke colour. Stroke width is not themable: the design
scale is four fixed widths, written as plain Tailwind utilities, because
borderColor already owns names like warning and error and a
border-warning utility would then set a width and a colour at once.
| Width | Written as | Used for |
|---|---|---|
| 0.5px | 0.5px solid in a stylesheet |
Dividers inside a table |
| 1px | border |
Controls, standalone dividers, table frames |
| 1.5px | DIAL_KIT_ICON_STROKE |
Tabler icon stroke (its own default is 2) |
| 2px | border-2 |
Active/selected highlighting |
Tabler renders every outline icon at 2px unless told otherwise, so the icon
weight has to be passed explicitly on each icon:
<IconPlus stroke={DIAL_KIT_ICON_STROKE} />. Two departures are deliberate:
empty-state illustrations stay lighter (stroke={1}), since 1.5px reads as a
fence at 48px, and filled glyphs carry no icon stroke at all.
Shadows
shadow-xs-1 shadow-xs-2 shadow-sm shadow-md shadow-lg
The variables are named after the elevation that consumes them, not after their
hue. shadow-xs paints two layers — a wide blue one from shadow-xs-1 and a
tight grey one from shadow-xs-2. shadow-sm, shadow-md, and shadow-lg
are a single blue layer each, so they take one variable apiece. Themes that set
the pre-spec shadow-xs-sm-1 / shadow-xs-sm-2 need to move to the
shadow-xs-* names; shadow-sm was retuned for the side panels and now has
its own variable.
Besides the four scale steps there is shadow-chat-button, the resting shadow
the chat button kept when shadow-sm was retuned. It is not a foundations
elevation and has no variable of its own — it paints the same two variables as
shadow-xs at a wider offset and blur.
There is no inset variant: a recessed seam between a panel and the content
beside it is painted by the panel's own shadow-sm, not by an inset shadow on
the content.
The control tokens were renamed for the role they fill instead of their opacity
or their literal hue. A theme that sets the old variable names keeps its
colors in the Tailwind utilities — each one is still the next link in the new
token's fallback chain in tailwind.config.js. The chains in libs/* are a
separate mechanism and stop at the current name (see
Styling libraries), so a theme still on the old names
renders the hex fallback for a handful of library values. Rename them.
| Pre-0.14 variable | Preferred now |
|---|---|
bg-control-disable |
bg-control-disable-primary |
bg-control-neutral-hover |
bg-control-neutral-hover-muted |
text-control-disable-alpha |
text-control-disable-primary |
text-control-disable-beta |
text-control-disable-secondary |
text-control-blue-hover / -active |
text-control-accent-hover / -active |
bg-control-accent-gradient-from / -to |
bg-gradient-1 / bg-gradient-2 |
bg-control-accent-gradient-hover-from |
bg-gradient-1-hover |
bg-control-accent-gradient-active-to |
bg-gradient-2-active |
stroke-control-accent-gradient-from / -to |
stroke-gradient-1 / stroke-gradient-2 |
stroke-control-accent-gradient-hover-from / -to |
bg-gradient-1-hover / bg-gradient-2-hover |
Two exceptions where the old name is not honored, so a theme setting it loses the color:
stroke-hover-alphawas removed — it held the same value asstroke-accent-alpha. Move the value there.- The
stroke-control-accent-gradient-*stops behind the selected tab's underline have no fallback chain. Rename them as in the table above.
stroke-focus-black is unchanged as a variable; only its Tailwind class name
moved (outline-focus-black → outline-focus).
The accent focus ring variable is named for the role it fills instead of its
literal hue, matching the border-accent-focus / outline-accent-focus
classes that consume it. The old name is still the next link in the fallback
chain — in tailwind.config.js and in the libs/* chains that reference it —
so a theme setting stroke-focus-blue keeps its focus-ring color. Rename it.
One caveat: @epam/ai-dial-ui-kit's own stylesheet reads
--stroke-focus-blue directly, so the kit's focus rings do not pick up
--stroke-accent-focus. Until the kit is updated, a theme that wants a custom
accent focus ring everywhere has to set both variables.
A legacy config.json loads without an error but produces mostly the built-in
palette: the environment variable, the token names, and the default color scheme
all changed.
| Legacy | New |
|---|---|
THEMES_CONFIG_HOST |
THEMES_CONFIG_URL |
| — | THEMES_SERVICE_TIMEOUT_MS (optional, 5000) |
/api/themes/listing, /api/themes/image/[name] |
/api/themes, /api/themes/icon?iconName= |
| Default theme: dark | Default theme: light |
| 24-hour cache on the themes host | 5-minute cache in chat-api |
The flipped default matters even for a faithful port: any token the theme omits now resolves to a light fallback, so a partially migrated dark theme renders as light patches rather than as approximately-dark.
The palette was redesigned, not renamed, so treat this as a starting point
rather than a mechanical substitution. Colors carried over unchanged (same name,
same role) are omitted: bg-error, bg-warning, bg-info, bg-success,
stroke-primary, stroke-secondary, stroke-tertiary, stroke-error,
stroke-warning, stroke-info, stroke-success, text-primary,
text-secondary, text-error, text-warning, text-warning-icon,
text-info, text-success.
| Legacy token | Closest new token |
|---|---|
bg-layer-0 … bg-layer-4 |
bg-layer-sunken, bg-layer-base, bg-layer-raised — five elevation steps collapsed into three |
bg-blackout |
bg-backdrop |
controls-bg-accent, bg-accent-primary |
bg-control-accent |
controls-bg-accent-hover |
No solid hover token; the new controls use bg-control-accent-alpha-hover / -active |
controls-bg-disable, controls-bg-disable-accent |
bg-control-disable-primary |
controls-text-permanent |
text-control-permanent |
controls-text-disable, controls-text-primary-disable, controls-text-accent-disable |
text-control-disable-primary, text-control-disable-secondary — three states collapsed into two |
text-accent-primary |
text-accent |
stroke-hover |
stroke-accent-alpha |
stroke-accent-primary |
stroke-accent, plus stroke-accent-focus for accent focus rings |
bg-accent-secondary, bg-accent-tertiary, stroke-accent-secondary, stroke-accent-tertiary, text-accent-secondary, text-accent-tertiary |
No equivalent — the three-accent scheme became a single accent |
bg-auth-layer-0, bg-auth-layer-1 |
No equivalent — the login page uses the shared surface tokens |
bg-model-icon |
No equivalent |
border-radius |
No equivalent — radii are Tailwind classes, not a themeable variable |
codeblock-font, theme-font |
No equivalent in practice — see Fonts are not themeable |
New token groups with no legacy counterpart — bg-visual-*, text-visual-*,
bg-control-neutral*, bg-control-error*, text-control-accent-*,
stroke-focus-black, stroke-error-alpha, and shadow-* — start at their
built-in light values until the theme sets them. A dark theme that leaves them
alone will show light chips, focus rings, and shadows.
Replace themes[].app-logo with images.chat-logo-light and
images.chat-logo-dark, and images.favicon with images.chat-favicon. The
legacy fields are ignored rather than rejected, which is why a migrated theme
can render with no logo at all and no error.
additional_css/ADDITIONAL_CSS_DIR. The legacy chat injected arbitrary stylesheets from a mounted directory. 1.0 replaces that with two supported channels: deployment-wide colors through the theme tokens above, and anything a token does not cover through the libs' own styling props —styles={{ colors, typography }}at the call site, described in Styling libraries. Both survive an upgrade; a stylesheet targeting generated class names does not.- The
custom-logoUI feature flag. The logo now always comes from the theme configuration; the flag no longer exists.
Components in libs/* are consumed both by this application and by external
projects, so they do not depend on this theme. Each themeable value resolves
through a three-tier chain — per-instance prop override, then the application
theme token described above, then a hard-coded hex fallback:
background: var(--ci-bg, var(--bg-layer-sunken, #eef1f7));Inside this application the middle tier resolves, so libs pick up the deployment
theme automatically. In a project without these tokens the hex fallback applies,
and a host can still override individual values through each component's
styles={{ colors, typography }} prop.
The chain is exactly three tiers. The middle tier names the current token only —
it does not carry the pre-0.14 aliases that tailwind.config.js keeps for its
utilities, because a lib is not supposed to know this application's rename
history.
The full convention — variable naming, what belongs in .module.scss versus
Tailwind, the buildCssVars helper, and the checks that catch styles which
silently do nothing — is in
openspec/lib-styling-guide.md.