Skip to Content

Theming

Erato supports extensive theming capabilities to match your brand identity. You can customize colors, logos, and assistant avatars.

Theme Configuration

Themes are configured in the backend configuration file (erato.toml) and consist of:

  1. Theme Directory - A folder containing all theme assets
  2. Theme Configuration - JSON file defining colors and styles
  3. Static Assets - Logos and avatar images

Setting Up a Custom Theme

  1. Configure the theme name in erato.toml:
[frontend] theme = "my-company"
  1. Create the theme directory in the frontend bundle:
public/custom-theme/my-company/ ├── theme.json # Theme configuration ├── fonts.css # Optional custom fonts ├── theme.css # Optional shell-level polish ├── logo.svg # Application logo (light mode) ├── logo-dark.svg # Application logo (dark mode, optional) ├── assistant-avatar.svg # Assistant avatar (optional) └── locales/ # Custom translations (optional) ├── en/ │ └── messages.po └── de/ └── messages.po

Theme Structure

theme.json

The theme.json file defines the color scheme for both light and dark modes:

{ "name": "My Company Theme", "theme": { "light": { "colors": { "background": { "primary": "#ffffff", "secondary": "#f5f5f5", "tertiary": "#ffffff" }, "shell": { "page": "#f5f5f5", "chatInput": "#ffffff", "modal": "#ffffff" }, "message": { "assistant": "#f5f5f5" }, "foreground": { "primary": "#000000", "accent": "#0066cc" }, "overlay": { "modal": "rgba(15, 23, 42, 0.5)" }, "avatar": { "assistant": { "background": "#0066cc", "foreground": "#ffffff" }, "user": { "background": "#666666", "foreground": "#ffffff" } } }, "radius": { "shell": "0.75rem", "input": "1rem", "modal": "0.5rem" }, "spacing": { "message": { "paddingX": "1rem", "paddingY": "1rem", "gap": "1.5rem" } }, "layout": { "chat": { "contentMaxWidth": "48rem", "inputMaxWidth": "56rem" } } }, "dark": { "colors": { "background": { "primary": "#1a1a1a", "secondary": "#2d2d2d" }, "foreground": { "primary": "#ffffff", "accent": "#4d94ff" }, "avatar": { "assistant": { "background": "#4d94ff", "foreground": "#ffffff" }, "user": { "background": "#888888", "foreground": "#ffffff" } } } } } }

Typed Theme Tokens

The typed theme contract is grouped semantically so starter themes can express shell styling without immediately falling back to theme.css or custom components.

  • colors.shell covers app, page, sidebar, chat shell, modal, and dropdown surfaces
  • colors.message covers user and assistant message surfaces plus shared hover and controls surfaces
  • colors.overlay covers backdrops such as the modal overlay
  • radius covers base, shell, input, control, message, modal, dropdown, card, and pill radii
  • spacing covers shell, message, controls, sidebar density, chat input, and dropdown spacing
  • elevation covers shell, input, modal, and dropdown shadows
  • layout covers chat content width, chat input width, sidebar width, and under layout.dropdown the popover panel’s minWidth, wideWidth and viewportMargin

Dropdown menus nest two shapes. The panel takes radius.dropdown, every row is inset by spacing.dropdown.chromePadding on all four sides, and the row highlight radius is derived as the panel radius minus that inset. Set those two tokens and the rows stay concentric with the panel; there is no separate row radius to keep in step.

That derivation is declared once, as --dropdown-item-radius on the panel’s anchored-popover-skin class, and only read by the rows. A theme that wants a row corner unrelated to the panel re-declares that one variable on the skin. Declaring it on dropdown-item-geometry instead shadows the panel’s value on that row, and a later panel-level retune stops reaching it. The read carries no fallback, because every element with that class is a menu row inside a panel that declares the variable.

Framed cards read radius.card: entity rows in settings, assistant and search result cards, the settings and hub panels, the wells that hold a divided list, the drive cards in the file picker, and the assistant hub’s category tiles and version cards. Selectable option cards and their icon tiles read radius.control instead, because they are choices with a selected state rather than containers. Two frames read another token on purpose: the attachment group frame takes radius.input, so it matches the composer it stages files for, and the background-runs bar takes radius.message, so it matches the messages beside it.

Cards nest, and a nested card derives its corner rather than reading the token again: it takes the enclosing card’s corner minus that card’s inset, floored at zero, and its own inset shrinks by 0.25rem per level unless it names one of its own. That is what keeps an email thread card concentric inside its attachment frame at any theme radius. It also means radius.card reshapes a whole nest on its own, while a rule that sets border-radius on a single card reshapes that frame and leaves everything nested inside it deriving from the old value. Re-declare --card-radius on the hook instead and the nest follows.

Existing theme JSON can omit these new groups. When a theme does not provide them yet, the corresponding CSS variables fall back to the built-in theme defaults until they are explicitly overridden.

One older field is not a fallback case. A theme that still sets a single top-level borderRadius inside theme.light or theme.dark has that one value copied onto every radius key it leaves unset under radius: base, shell, input, control, message, modal, dropdown, card and pill alike, resolved separately per mode. Any per-key value under radius still wins over it. The consequence worth knowing is pill: under such a theme every badge, chip and status pill takes that radius instead of staying a full capsule. Set radius.pill explicitly to keep them round while the rest of the theme softens.

Optional Stylesheets

Theme packs can include these optional sibling stylesheets beside theme.json:

  • fonts.css loads after the runtime CSS variables from theme.json
  • theme.css loads after fonts.css

If VITE_THEME_CONFIG_PATH or THEME_CONFIG_PATH points to a specific theme.json, these stylesheets are resolved relative to that exact file path. Otherwise they are resolved from the active theme directory selected through VITE_THEME_PATH, THEME_PATH, VITE_CUSTOMER_NAME, or THEME_CUSTOMER_NAME.

theme.css should stay constrained to theme-level visual polish. Prefer root selectors like html[data-theme="light"], html[data-theme="dark"], and CSS custom properties. Avoid deep DOM selectors or overrides tied to Tailwind utility classes.

Stable Styling Hooks

For shell-level overrides in theme.css, prefer the app-owned data-ui and data-role hooks over DOM-depth selectors or Tailwind class names.

  • data-ui="app-shell"
  • data-ui="page-shell"
  • data-ui="sidebar"
  • data-ui="sidebar-header" and data-ui="sidebar-footer" (the bands along the sidebar’s top and bottom edge, drawn by one component on the host and in the Office add-in’s history drawer alike. Both carry the class sidebar-section-skin for fill and divider colour, and in their normal form sidebar-band-geometry, whose min-height derives from the tallest control a band hosts plus the band’s own padding, so header and footer stay equal at any token values instead of pinning a literal each. A band whose single child carries the sidebar inset and the row height itself — the add-in drawer’s Settings row — swaps that geometry class for sidebar-band-flush, which zeroes the band’s padding on source order alone: the two rules have the same specificity and the flush one comes later, so nothing in the family needs !important. A theme’s own stylesheet is appended after both, which is where the trap is: a padding rule written on sidebar-section-skin in theme.css re-pads the flush band as well and pushes the drawer’s Settings row off the edge it is meant to span. Put band padding on sidebar-band-geometry instead and the flush band stays flush. Neither band writes a style attribute, so everything they paint stays reachable from a stylesheet)
  • data-ui="sidebar-toggle" (the control that opens and closes the sidebar, everywhere it is drawn: the host header in both states, the trigger floating over the conversation while the sidebar is hidden, the add-in’s pane trigger and its history-drawer header. data-surface says which paint it takes, taking floating for a trigger that sits outside [data-ui="sidebar"], framed for the same frame in normal flow (the add-in’s chat header row) and flush inside a band that already paints; the first two also carry the class floating-control-skin, which is where their corner (radius.shell), hairline, shadow and opaque base come from. That base is the point of the class: a floating trigger sits outside the element a theme scopes its sidebar blur to, so the skin lays --theme-shell-app down first and paints --theme-shell-sidebar over it, and a translucent sidebar colour still reads solid there instead of showing the conversation through the control. A flush one carries none of that and keeps the corner every icon control has, radius.control — so the family reads two radius tokens on purpose, the shell’s for a control standing on the shell and the control’s for one sitting in a band. The shape is announced on data-surface and never on data-variant, because the control keeps the data-variant="sidebar-icon" the shared button emits — the attribute the shipped customer themes key their sidebar-control rules on. aria-expanded says which way it points and the glyph inside is what turns: a rule that rotates the button instead flips the attention badge riding its corner from top-right to bottom-left, and one that takes the control out of position moves that badge off the control altogether)
  • data-ui="chat-history-list"
  • data-ui="chat-history-item" (carries data-selected="true" on the open conversation’s row; the aria-current="page" sits on the link or click wrapper around it)
  • data-ui="chat-header"
  • data-ui="chat-body"
  • data-ui="chat-input-shell"
  • data-ui="chat-input-controls"
  • data-ui="chat-message"
  • data-ui="message-body"
  • data-ui="message-attachments"
  • data-ui="message-controls"
  • data-ui="modal-overlay"
  • data-ui="modal-shell"
  • data-ui="dropdown-panel" (the panel DropdownMenu draws — an instance name rather than a family hook: a caller renames it through the dataUi prop, as the model selector does with model-selector-menu)
  • data-ui="chat-input-add-menu", data-ui="chat-input-mention-menu", data-ui="chat-input-delegation-run-mode-menu", data-ui="chat-history-filter-menu" and data-ui="chat-history-filter-menu-submenu" (the app’s other popover panels. The row container inside each of these repeats the panel’s name with a -content suffix, spelled chat-input-delegation-run-mode-content for the run-mode menu; the submenu has no separate body. A panel whose caller names none falls back to data-ui="anchored-popover-panel")
  • data-ui="popover-section-header" and data-ui="popover-separator" (the label above a run of menu rows, and the hairline between two runs, wherever a panel above draws them)
  • data-ui="list-row" (a row in a plain vertical list of choices outside a menu and outside the sidebar, such as the assistant mention browser; it also carries the class list-row-geometry. A row that only frames a control it does not own — a checkbox whose click belongs to the checkbox — is not one of these; where one is drawn as a framed choice it is a card and carries the card hooks below)
  • data-ui="menu-item" (every actionable row inside those popover panels; it also carries the class dropdown-item-geometry, and states itself on the same element with aria-checked where the row is a toggle, aria-expanded while its flyout is open, aria-disabled when it is unavailable but still keyboard-reachable, and data-tone="error" on a destructive row. A static status line inside a panel states none of that, so it keeps the geometry class and takes no hook)
  • data-ui="card" (the family default, worn by every framed surface that names no hook of its own: the settings and hub panels, the wells that hold a divided list, the drive cards in the file picker, the framed checkbox rows. Its bands are data-ui="card-media", data-ui="card-header", data-ui="card-body" and data-ui="card-footer", in that order, and the first and last of them round to the frame. data-variant on the frame says which kind of card it is, taking surface, interactive, selectable or expandable, and data-tone carries a non-neutral tone, taking muted, accent, info, success, warning or error. Every card also carries the classes described below)
  • data-ui="entity-row"
  • data-ui="assistant-list-card"
  • data-ui="search-result-card"
  • data-ui="assistant-category-tile" (a category tile on the assistant hub landing view) and data-ui="assistant-hub-version-card" (one version of a hub assistant; it is framed as a button where the whole card opens the version, and as a plain frame where it carries its own action buttons)
  • data-ui="assistant-past-chat-card" (a recent conversation on the assistant welcome screen; a card that separates itself from the pane by fill alone, so it has no border to retune)
  • data-ui="assistant-detail-card" (the body of the assistant configuration modal. It is a spacing wrapper rather than a frame — no border, no fill, no corner of its own — so spacing is the only thing a rule on it can move; a border or a fill declared here paints a box the app never draws)
  • data-ui="delegated-runs-section" (the background-runs bar above the composer. The hook is on the framed bar itself; the wrapper around it is what carries the composer’s width, so a width set here reaches nothing. Its corner reads radius.message rather than radius.card, set as --card-radius on the element itself, which is the one card a rule on the hook cannot move that way: re-declare --theme-radius-message on the hook instead, and the bar and the control inside it move together)
  • data-ui="option-card" (takes data-selected while checked and drops the attribute when not, so [data-selected] on its own is the selector; data-ui="option-card-icon" marks its icon tile and takes it too, and data-ui="option-card-details" the section shown under a checked card)
  • data-ui="tab-rail" (every tab strip: the two settings tab rails, the three markdown/preview switches (assistant description, hub long description, hub version comment), the assistants view switcher, and every segmented control, including the text-size control in the appearance settings and the two inside the sharing dialog. Every tab inside one carries data-ui="tab-rail-tab". data-variant on the rail says which anatomy it is, taking rail for a bare strip of tabs and segmented for the bordered inset track, and data-orientation says which way it is announced, taking horizontal or vertical; the rail also carries the class tab-rail-geometry, a segmented track tab-rail-track-geometry on top of it, and every tab tab-item-geometry. The family reads four private variables: --tab-rail-radius for the corner, defaulting to --theme-radius-control, which the rail never declares so a value set on any ancestor reaches it; --tab-rail-gap between tabs; and --tab-rail-track-padding and --tab-rail-track-border-width, the inset and hairline of a segmented track, which are 0px on a bare rail. A tab derives its own corner as the rail radius minus the track padding, floored at 0px, so track and segments stay concentric at any radius. Write every zero as 0px, never 0: a unitless zero is not a length inside that max(), the whole expression becomes invalid at computed-value time, and every tab goes square with no error anywhere)
  • data-ui="attachment-group" and data-ui="thread-message-card" (attachment group frames and the per-message cards of an email thread. Both are cards, and the thread card derives its corner from the group frame, so re-declaring --card-radius on either moves it and everything nested under it together. Declare --attachment-tile-radius on the card to reshape the attachment chips inside it, their icon tiles follow)
  • data-ui="attachment-tile" (each attachment chip. data-variant says which shape it is drawn as, taking tile for a chip standing on its own, row for the full-width form a selectable row takes inside an email thread or a picker, and bare for a chip whose surrounding surface draws the frame instead; data-media says which face it is showing, taking image for a thumbnail and document for the icon-and-name form, so a rule can reach one without reaching the other. data-ui="attachment-tile-icon" marks the tinted plate behind a document’s file-type glyph; its fill is a dilution of --attachment-tile-icon-tint, which the chip sets per file type and a rule on either hook can re-declare), data-ui="attachment-remove" (the corner button that unstages a file. Deliberately a circle — it holds an icon and no label — so it takes no radius token and radius.pill does not reach it), data-ui="attachment-loading" (the placeholder while a group’s contents load), data-ui="attachment-notice" (the framed row standing where a chip would but which is not a file — a group’s status line, and the wait a file preview shows while it loads. data-tone says which, taking neutral or error, and it takes the chip corner rather than one of its own)
  • data-ui="attachment-row" is retired: the app draws a selectable attachment row as an attachment-tile carrying its own checkbox and never emits this hook. Component kits that draw their own selectable row still do, and the shipped open-webui-like theme still keys on it, so a rule written against it keeps working there — but nothing in the app or the Office add-in answers it.
  • data-ui="addin-chat-header" (the Office add-in’s header row when a component kit supplies a top-left accessory)
  • data-ui="chat-top-bar" (the web chat’s top bar: the column the hidden-mode sidebar toggle floats in, a component kit’s top-left accessory, and the share button. The column it reserves is --chat-top-bar-toggle-column, derived from --theme-spacing-control-min-height (3rem by default) so it follows a theme’s control size; a theme that sizes sidebar-icon buttons outside that token sets the variable itself)
  • data-ui="alert" (the shared inline error, warning, info and success banners; data-tone on the same element says which one, taking error, warning, info or success, so a theme can restyle a single tone or the whole family. A few hand-rolled banners still sit outside this hook and carry only their tone colours)
  • data-ui="spinner" (every loading ring the app and the Office add-in draw, and those of a component kit that adopts the host spinner; declare --spinner-track, --spinner-head, --spinner-thickness and --spinner-duration on it to restyle all of them at once; the head defaults to --theme-fg-secondary and the track to a 25% dilution of it, which keeps the ink a full circle so the ring stays centred as it turns and cannot wash out to a lone arc on a faded control). A ring that carries a visible caption wraps two sub-parts, data-ui="spinner-ring" and data-ui="spinner-label".

Every popover panel (dropdown menus, the chat ”+” menu, the history filter menu and its flyout, the mention and run-mode menus) also carries the class anchored-popover-skin, and every menu row the class dropdown-item-geometry. Both are stable hooks: one rule on the skin restyles the whole popover family, and one rule on the row class reshapes every menu row. Every one of those rows is drawn by the same component, Row variant="menu", which is what emits that class alongside the menu-item hook, so the two always arrive together. The skin also declares --dropdown-item-radius, the derived row corner described above; the rows only read it, so a retune belongs on the skin and never on a row.

Every card carries the same three classes: two on the frame, card-geometry for the corner it takes from --card-radius and card-skin for border, fill and shadow, and one on its body, card-body-geometry, which paints the inset from --card-inset and publishes the corner and inset a nested card derives from. A card with no content of its own has no body, so a rule that has to reach every card belongs on the frame classes. card-skin is the only rule in the family that sets a final value; tone, hover, selection and expansion move the variables it reads, which gives a theme two knobs instead of one:

/* Resting colour only. Hover and selection keep painting their own borders. */ [data-ui="card"] { --card-border: transparent; } /* Every state at once, including hover and selection. */ [data-ui="card"] { border-color: transparent; }

The three variables are --card-border, --card-bg and --card-shadow; an expanded card additionally reads --card-expanded-shadow, which is none at stock and exists so a theme can lift an open card without a rule per card. A handful of surfaces set one of the three on the element itself — a well that has to sit flush on the page, the tool-call panel that sits a step deeper than the family, the sidecar setup panels and their stronger hairline — and a declaration on an element outranks any rule, so a retune that moves the variable leaves those cards on their own value while one that sets border-color or background-color outright still reaches them. The inset comes from a class scale the app picks per surface, so a theme that wants a different padding declares --card-inset on the hook rather than looking for a spacing token — there is no spacing.card or elevation.card in the typed theme.

Messages also expose data-role="user" and data-role="assistant" on the message shell.

message-body wraps the avatar and the message content. On a user message carrying files, message-attachments is a sibling rendered above it, so a fill applied to message-body covers the text without covering the attachments; on an assistant message the attachments stay inside message-body and the hook is absent. Themes that need to work against both shapes can branch on :has([data-ui="message-body"]).

Class Hooks

Alongside the data-ui attributes, the app publishes a set of global class names. Component kits, customer theme.css overlays and the Office add-in all paste these as bare string literals — nothing imports them across the boundary — so they are a public contract with no compiler standing behind them. The declared list lives in one record in the frontend, ERATO_GEOMETRY_CLASS, and a test reads both that record and globals.css, so a class renamed or dropped on the host side fails in CI.

That test exists because on the theme side the failure is invisible. A shipped customer theme once carried a rule keyed on two button geometry class names the app had never emitted. Nothing complained: a selector that matches nothing is valid CSS, the browser logs nothing, and the theme went on building and deploying. The rule simply did nothing for a whole release, and it was caught by eye rather than by a test. A theme repository cannot test for this — it holds no copy of the host’s markup — so the host is the only side where a guard can live, which is what makes the record the contract. Renaming one of these in globals.css breaks every kit and theme that targets it, silently. Add to the list when a class becomes themeable, and ship a migration when one has to change.

ClassSurfaceKindPosition
alert-geometryInline alert banner: corner and paddinggeometry@layer components
anchored-popover-skinEvery popover panel: fill, hairline, shadow; declares --dropdown-item-radiusskinafter the layer
app-shell-skinApp shell backgroundskinafter the layer
attachment-badge-geometryRemove button on a staged attachment; an intrinsic circlegeometry@layer components
attachment-group-frame-geometryAttachment group framegeometryafter the layer
attachment-group-geometryAttachment group outer boxgeometryafter the layer
attachment-group-header-geometryAttachment group header bandgeometryafter the layer
attachment-group-items-geometryAttachment group item areageometryafter the layer
attachment-notice-geometryStatus or loading row inside a groupgeometry@layer components
attachment-notice-icon-skinThat row’s iconskinafter the layer
attachment-notice-label-skinThat row’s labelskinafter the layer
attachment-notice-skinThat row’s fill and border, per data-toneskinafter the layer
attachment-tile-geometryAttachment chip cornergeometry@layer components
attachment-tile-icon-geometryIcon plate behind a document chip’s glyphgeometry@layer components
attachment-tile-icon-skinThat plate’s tintskinafter the layer
avatar-geometryAvatar circlegeometry@layer components
btn-geometry-sm, btn-geometry-md, btn-geometry-lgShared button padding and minimum size, per sizegeometry@layer components
btn-geometry-icon-sm, btn-geometry-icon-md, btn-geometry-icon-lgThe same for icon-only buttonsgeometry@layer components
card-body-geometryCard body inset; publishes the corner and inset a nested card derivesgeometry@layer components
card-geometryCard frame corner, from --card-radiusgeometry@layer components
card-skinCard border, fill and shadow, with tone, hover, selected and expanded variantsskinafter the layer
chat-body-skinConversation body backgroundskinafter the layer
chat-header-skinChat header backgroundskinafter the layer
chat-input-controls-geometryComposer control rowgeometry@layer components
chat-input-shell-geometryComposer shell paddinggeometry@layer components
chat-input-shell-skinComposer shell fill and borderskinafter the layer
chat-input-textarea-geometryComposer textarea boxgeometry@layer components
chat-message-skinMessage shell paintskinafter the layer
dropdown-item-geometryMenu row padding and its derived cornergeometry@layer components
dropdown-panel-chrome-geometryPopover panel insetgeometry@layer components
floating-control-skinFloating and framed sidebar toggle: corner, hairline, shadow, opaque baseskinafter the layer
focus-ringStandard focus-visible ring, with offsetstateafter the layer
focus-ring-insetThe same ring drawn inside the boxstateafter the layer
focus-ring-tightThe same ring with no offsetstateafter the layer
list-row-geometryPlain list row cornergeometryafter the layer
message-frame-geometryFramed alert, worn by the composer’s non-alert notices as wellgeometry@layer components
modal-close-geometryDialog close button; a circlegeometry@layer components
modal-overlay-skinDialog scrimskinafter the layer
modal-section-geometryDialog section paddinggeometry@layer components
modal-shell-frame-geometryDialog maximum width and heightgeometry@layer components
modal-shell-skinDialog panel paintskinafter the layer
option-card-geometryOption card cornergeometry@layer components
page-shell-skinPage shell backgroundskinafter the layer
pill-geometryBadge, chip and status pill capsulegeometry@layer components
sidebar-band-geometrySidebar header and footer band: derived minimum height and paddinggeometryafter the layer
sidebar-content-col-geometrySidebar row content columngeometryafter the layer
sidebar-icon-col-geometrySidebar row icon columngeometryafter the layer
sidebar-icon-col-geometry-avatarIts avatar variantgeometryafter the layer
sidebar-icon-col-geometry-logoIts logo variantgeometryafter the layer
sidebar-inset-geometrySidebar horizontal insetgeometryafter the layer
sidebar-label-col-geometrySidebar row label columngeometryafter the layer
sidebar-row-geometrySidebar row minimum height and cornergeometryafter the layer
sidebar-row-selectedSelected sidebar row fillstateafter the layer
sidebar-section-skinSidebar band fill and dividerskinafter the layer
sidebar-skinSidebar backgroundskinafter the layer
sidebar-trailing-col-geometrySidebar row trailing columngeometryafter the layer
spinner-geometryLoading ringgeometry@layer components
tab-item-geometryOne tabgeometry@layer components
tab-rail-geometryTab stripgeometry@layer components
tab-rail-track-geometrySegmented control trackgeometry@layer components
theme-transitionShared colour, shadow and transform transitionmotionafter the layer
thread-message-card-geometryEmail thread message cardgeometryafter the layer

Kind says what the class sets: geometry is shape and spacing, skin is paint, and state and motion cover the few that are neither.

One more name is declared API but has no row above, because it has no rule of its own: group, Tailwind’s hover-scope marker. Host message controls compile their group-hover: variants down to .group:hover, so a kit that wraps host controls in its own markup has to carry a literal group class for those host styles to reach in.

Which Rule Wins

Position in the table is a position in one output file, and that is the whole mechanism. This is worth stating plainly, because the name invites the wrong picture: the frontend is on Tailwind 3, where @layer is a build-time directive rather than a CSS cascade layer. Tailwind hoists the @layer components block up to where @tailwind components sits and emits no @layer at-rules at all, so the built stylesheet has no cascade layers in it. Nothing here wins on layer precedence, because there are no layers. Everything is specificity, then source order.

The order that comes out of the build is:

  1. Geometry classes, hoisted to the components position
  2. Tailwind utilities
  3. Skin classes, and the fifteen geometry classes marked “after the layer”
  4. theme.css, which is a separate stylesheet

So a caller’s p-0 overrides card-geometry because it comes later at the same specificity, which is exactly what geometry is for: a host component hands out a default shape a caller can adjust without a fight. And card-skin overrides that same p-0 because it comes later still, which is what skin is for: paint the host keeps control of.

The fifteen unlayered geometry classes are the practical catch in that ordering, and the reason the column is worth reading. They predate the convention and sit after the utilities, so a kit’s own rounded-* or p-* utility written on markup that carries sidebar-row-geometry or list-row-geometry does not override it — the class wins. They are frozen where they are rather than corrected, since moving one into the layer would change which rule wins, and that is a visual change, not a refactor. The test pins the exact set so it cannot quietly grow; new geometry classes go inside the layer.

For a theme author all of this collapses to one rule, because theme.css comes after the entire file either way. Match the host selector’s specificity and your rule wins; fall short of it and it loses. There is no case anywhere in this system where a less specific rule wins on position alone. Compound host selectors are where a theme rule falls short — card-skin alone has eleven heads, several of them carrying [data-tone=…] or :hover — so match those heads rather than reaching for !important. The two-knob advice above is the same point from the other side: moving --card-border reaches every state at one stroke, where a border-color rule has to out-specify each variant.

It is worth naming the belief this replaces, since it is a natural one and it is wrong: skins are not unlayered so that theme.css can override them. Being unlayered does nothing for a theme at all. It changes which app rules a class beats — utilities, above all — and changes nothing about theme.css, which is appended to <head> last as a link[data-theme-styles] element and therefore comes after layered and unlayered rules alike. What lets a theme win is where it loads.

Write Every Zero as 0px

A bare 0 is a <number>, not a <length>. Inside calc() or max() that is a type error, and the consequence is silent: the whole expression becomes invalid, the property reading it is invalid at computed-value time and falls back to its initial value, and nothing appears in the console. A var() fallback does not rescue it either, since a fallback fires only when a variable is unset, never when its value is invalid.

The card nest is where this bites. A card body derives what its nested children use:

.card-body-geometry { --card-child-radius: max(0px, var(--card-radius) - var(--card-inset)); --card-child-inset: max(0px, var(--card-inset) - 0.25rem); padding: var(--card-inset); }

A theme that wants flush cards reaches for the inset, reasonably enough:

/* Wrong. The padding collapses as asked; every nested corner goes square. */ [data-ui="card"] { --card-inset: 0; }

padding: 0 is perfectly valid, so the visible half of the change works and the result looks right at the top level. Both derivations are now invalid, though, so nested cards lose their corner and their inset together, and a nest that should stay concentric goes square from the second level down. The fix is one character:

[data-ui="card"] { --card-inset: 0px; }

The rule has no exceptions. In a theme, in a kit, on any custom property a calc() or max() might read, write 0px. Radius, inset and gap variables are all read that way somewhere — the tab rail derives a tab’s corner from --tab-rail-track-padding the same way, and a bare 0 there squares every tab.

Dialogs Are Not Inside the Page Shell

ModalBase renders through a portal into document.body, so dialog markup is not a descendant of app-shell or page-shell. A theme rule scoped under either — [data-ui="page-shell"] [data-ui="card"], say — reaches the whole app except its dialogs. The usual course is that the gap is noticed late and covered by a second, near-duplicate rule; a shipped theme carries two such workarounds for exactly this reason.

Scope dialog rules on the portaled tree itself instead: modal-overlay for the scrim and modal-shell for the panel, both emitted inside the portal. Theme tokens are unaffected either way, since they are written to :root along with the mode attribute and so reach dialog content normally. Only descendant selectors rooted at a shell hook miss it.

Surface Glossary

The names people use for these surfaces rarely match the component names, and the same surface can be drawn by the host or by a component kit. This table maps the everyday name to the hook a theme keys on and to where its corner radius comes from, so a theme can give any of them a different shape without touching per-component rules. Elements that carry a data-ui hook never use Tailwind rounding utilities; a lint rule enforces that, so every such corner is reachable from a theme.

What people call itHookCorner comes fromRetune
Attachment frame, email preview frameattachment-groupradius.inputset radius.input, or re-declare --theme-radius-input or --card-radius on the hook; the thread cards inside follow
Email thread card, the collapsible message rowthread-message-cardthe group frame’s corner minus the group’s inset, floored at 0re-declare --card-radius on the hook; border-radius alone leaves the chips on the old value
Attachment chip, file chip inside a thread cardattachment-tile (data-variant, data-media), class attachment-tile-geometry--attachment-tile-radius, derived from the card but never below 0.25rem; its icon tile follows, never below 0.125remdeclare --attachment-tile-radius on thread-message-card
Icon plate behind a document chip’s file-type glyphattachment-tile-icon, class attachment-tile-icon-geometry--attachment-tile-icon-radius, derived from the chip corner, never below 0.125remdeclare --attachment-tile-icon-radius on the chip; --attachment-tile-icon-tint is its colour
Remove button on a staged attachmentattachment-remove, class attachment-badge-geometry50%, it is a circle and reads no radius tokenstyle the hook; radius.pill deliberately does not reach it
Loading placeholder inside a groupattachment-loadingnone, it frames a bare spinnerstyle the hook; the spinner inside it follows spinner
Status or loading row inside an attachment groupattachment-notice (data-tone), class attachment-notice-skin--attachment-tile-radius, the same corner as the chips beside itdeclare the four --attachment-notice-* variables on the hook; the error tone already moves three
Loading ring, spinnerspinner50%, it is a circle and reads no radius tokendeclare --spinner-track, --spinner-head, --spinner-thickness, --spinner-duration on the hook
Any framed surface with no name of its own (settings and hub panels, list wells, drive cards)card, class card-skinradius.cardset radius.card, or --card-radius on the hook
Entity card in settings (MCP, apps)entity-rowradius.cardset radius.card
Assistant card, search result cardassistant-list-card, search-result-cardradius.cardset radius.card
Assistant hub category tile, version cardassistant-category-tile, assistant-hub-version-cardradius.cardset radius.card
Recent conversation on the assistant welcome screenassistant-past-chat-cardradius.cardset radius.card
Background-runs bar above the composerdelegated-runs-sectionradius.message, set as --card-radius at the sitere-declare --theme-radius-message on the hook
Option card (appearance, Outlook behaviour), its icon tileoption-card, option-card-iconradius.controlre-declare --theme-radius-control on option-card
Tab rails and segmented controls (settings tabs, markdown/preview switch, view switchers)tab-rail, tabs tab-rail-tab--tab-rail-radius (default radius.control); a segmented tab takes the rail’s corner minus the track insetset --tab-rail-radius on the hook; re-declaring --theme-radius-control there still works
Menu panel, ”+” menu, filter menu and flyoutclass anchored-popover-skin, rows menu-itemradius.dropdownset radius.dropdown; rows via --dropdown-item-radius on .anchored-popover-skin
Sidebar rows and nav rowschat-history-item, .sidebar-row-geometry; state via data-selected / aria-currentradius.shellset radius.shell
Sidebar header and footer bands, add-in drawer bandssidebar-header, sidebar-footer; class .sidebar-band-geometrynone, the bands are square and read no radius tokenstyle the hooks; a flush band swaps the geometry class for .sidebar-band-flush
Sidebar and drawer toggle, floating history triggersidebar-toggle (data-surface), class .floating-control-skinradius.shell on a floating or framed one, from the skin; radius.control on a flush oneset radius.shell, or re-declare --theme-radius-shell on the hook
List rows in a modal or card well (mention browser)list-row, .list-row-geometryradius.controlset radius.control, or a rule on .list-row-geometry
Badge, chip, status pillclass pill-geometryradius.pillset radius.pill
Avatar circleclass avatar-geometry50%, a circle reads no radius tokennone, a circle stays a circle
Alert, inline error or warning banneralertradius.base; the framed alerts inside a chat take radius.messageset radius.base, or radius.message for the framed form

Capsules and circles are deliberately kept apart. A badge, chip or status pill is a text label with side padding, so it follows radius.pill and softens along with the theme. An avatar, status dot, spinner or round close button is a fixed-size square rather than a padded label, so it declares 50% and reads no radius token at all, even when it holds an initial. That separation is what lets a theme set radius.pill, or the legacy borderRadius above, to a small value and get square-ish chips without turning every avatar and dot into a rounded square.

radius.base deserves a second look, because the plain alert is its most visible reader: every inline error, warning, info and success banner that does not take the framed form reads it. A theme that raises base rounds all of them along with the rest of the app — under the Open WebUI-like theme’s 0.75rem they are twice as round as the app’s own 0.375rem, and a theme setting only the legacy borderRadius moves them too. Give the banners a corner of their own with a [data-ui="alert"] rule in theme.css if that is not what you want.

Two examples, both from the Open WebUI-like theme:

/* Pills for the chips inside an email thread card; the icon tiles become circles. */ [data-ui="thread-message-card"] { --attachment-tile-radius: var(--theme-radius-pill); } /* The same chips as tight rectangles for a denser theme. */ [data-ui="thread-message-card"] { --attachment-tile-radius: 0.25rem; }

State

Selected, pressed, expanded, current and disabled are announced on the element itself, so a theme can paint them without knowing how the app tracks them.

The ARIA attribute comes first. Where an element already is the widget the state belongs to, that widget’s own ARIA attribute is the channel to key on, and no parallel data- attribute is emitted beside it:

  • aria-selected on the segments of a segmented control and on settings tabs
  • aria-checked on menu rows that toggle something
  • aria-pressed on toggle buttons, and on a card that is itself the toggle rather than a frame around a radio or a checkbox; the shared button also derives its selected tint from it
  • aria-current="page" on the link of the open conversation in the history list
  • aria-expanded on the row of an open submenu, which the app itself styles through the aria-expanded: variant
  • aria-disabled on a control that is inert but must stay reachable

A data- attribute is minted only where the element carrying the hook is not that widget (a wrapper row, an icon tile, a decorative box), and then only as a presence boolean: it is there with the value true while the state holds and absent otherwise, never written as "false", so [data-selected] on its own is a sufficient selector.

Cards are a second deliberate exception. A card states its selection on the frame whether the choice is carried by a radio or checkbox inside it or by the frame itself, so a card that is its own toggle says aria-pressed and takes data-selected beside it. That redundancy is the point: one selector paints selection across the whole card family, instead of a theme having to know which cards wrap a native input and which do not.

The surfaces that carry one:

  • data-ui="chat-history-item" takes data-selected on the open conversation’s row, beside the aria-current on its link
  • the sidebar navigation row takes both aria-current="page" and data-selected, because the active row is drawn without a link and would otherwise announce nothing
  • data-ui="option-card" and data-ui="option-card-icon" take data-selected while the card is checked; the radio input underneath keeps the ARIA
  • data-ui="attachment-tile" takes data-selected only when the row owns a checkbox and is selected. A read-only attachment row never carries it, whether or not it can be previewed. It takes data-invalid while its file has failed a pre-upload check, which warns without deselecting it, so the two attributes can both be present
  • every card — data-ui="card" and the cards that name a hook of their own — takes data-selected while it is the chosen one and data-expanded while its body is open. The aria-expanded stays on the disclosure control inside the header, because that button is the widget being expanded, while the frame carries the state a theme paints. A card that is unavailable takes data-disabled unless it is a native button, which says it on the disabled attribute instead

data-state and data-active are not part of this contract and are emitted nowhere. Rules written against them match nothing.

Three details are easy to read the wrong way:

  • data-pressed on a button is an animation, not a state. It sits on the shared button at all times, reading false at rest and flipping to true for 200 milliseconds after a click, on the same element as aria-pressed, which is the real toggle state. A rule keyed on [data-pressed="true"] paints a flicker on every click and never the sustained state it looks like it should. Key on aria-pressed instead.
  • A control that is unavailable but must stay reachable by keyboard uses aria-disabled together with the aria-disabled: variant for its dimmed look, rather than the native disabled attribute, so the control keeps its place in the tab order and can still explain why it is unavailable. No data-disabled is minted for that case; key on aria-disabled. The data-disabled a card carries is the other case: a frame that is not a native button and so has no disabled attribute to set.
  • The highlight a newly arrived message wears is an exception to all of this: it is a lasting state painted with the hover surface, so a theme that retints hover retints that highlight with it. Giving it a surface of its own would change how it looks, and that is held for a later pass.

Custom Logos

Place logo files in your theme directory:

  • logo.svg - Main application logo (required)
  • logo-dark.svg - Dark mode logo (optional, falls back to logo.svg)

Custom Language Files

You can customize translation strings to match your brand’s tone and terminology. This is particularly useful for:

  • Changing the assistant name (e.g., “Your Company KI Assistent”)
  • Choosing formal vs. informal forms of address (e.g., “Du” vs. “Sie” in German)
  • Adapting welcome screen messages

Directory Structure

Create language files in your theme directory:

public/custom-theme/my-company/ ├── theme.json ├── logo.svg ├── assistant-avatar.svg └── locales/ ├── en/ │ └── messages.po ├── de/ │ └── messages.po └── fr/ └── messages.po

Available Branding Translation IDs

These translation IDs are designed for brand customization and have stable IDs guaranteed across versions:

IDPurposeDefault (en)Default (de)
branding.assistant_nameThe name/label shown for AI messages”Assistant""Assistent”
branding.user_form_of_addressGeneric label for user messages (fallback when no user name available)“You""Du”
branding.welcomeScreen.titleWelcome screen heading”Welcome to AI Assistant""Willkommen beim KI-Assistenten”
branding.welcomeScreen.subtitleWelcome screen subheading”Get expert help…""Erhalten Sie Expertenunterstützung…”
branding.welcomeScreen.descriptionWelcome screen description text”Ask questions…""Stellen Sie Fragen…”
branding.page_title_suffixBrowser tab title suffix”LLM Chat"""

Example: Custom Translations

messages.po (for German):

msgid "" msgstr "" "Language: de\n" #. js-lingui-explicit-id #: src/components/ui/Chat/ChatMessage.tsx msgid "branding.assistant_name" msgstr "Your Company KI Assistent" #. js-lingui-explicit-id #: src/components/ui/Chat/ChatMessage.tsx msgid "branding.user_form_of_address" msgstr "Sie"

Note: The .po files are the source of truth. In development, you can edit both, but only .po files should be version controlled.

For technical details on how the translation override system works, see Internationalization (i18n).

User Display Name Priority

When displaying user messages, the system follows this priority:

  1. User’s actual name (from userProfile.name) - e.g., “Daniel Schmidt”
  2. Form of address (branding.user_form_of_address) - e.g., “Du”, “Sie”, “You”

This allows personalization when user identity is known, while maintaining your brand’s preferred formality level for anonymous users.

Custom Icons

Erato allows you to customize icons throughout the application, including file type icons, status indicators, and action buttons. You can either select from the 1,300+ icons in the iconoir library or provide your own custom SVG files.

Configuration

Add an icons section to your theme.json:

{ "name": "My Company Theme", "theme": { ... }, "icons": { "fileTypes": { "pdf": "MultiplePages", "image": "MediaImage", "video": "./icons/custom-video.svg" }, "status": { "info": "InfoCircle", "warning": "WarningCircle", "error": "Xmark", "success": "CheckCircle" }, "actions": { "copy": "Copy", "edit": "EditPencil", "delete": "Trash" }, "navigation": { "assistants": "PeopleTag", "newChat": "Plus", "search": "Search" } } }

Icon Value Formats

Iconoir Icon Names - Use the PascalCase name from iconoir.com:

"pdf": "MultiplePages"

Custom SVG Files - Use relative paths to SVG files in your theme folder:

"pdf": "./icons/company-pdf.svg"

Relative paths (starting with ./) are resolved to /custom-theme/your-company/icons/.

Icon Categories

CategoryPurposeKeys
fileTypesFile type icons in upload and file previewpdf, image, document, spreadsheet, presentation, text, code, archive, audio, video, other
statusStatus indicators in alerts and tool callsinfo, warning, error, success, in_progress
actionsAction button icons (future extensibility)copy, edit, delete, share, plus, close, check, refresh
navigationSidebar navigation menu item iconsassistants, newChat, search

File Structure with Custom Icons

public/custom-theme/my-company/ ├── theme.json ├── logo.svg ├── icons/ # Custom icon SVG files │ ├── company-pdf.svg │ ├── company-video.svg │ └── custom-error.svg └── locales/

Custom SVG Requirements

When creating custom icons:

  • Design on a 24×24 pixel grid to match iconoir icons
  • Use currentColor for fill/stroke to inherit theme colors:
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"> <path d="..." stroke-width="1.5"/> </svg>
  • Use clean, optimized SVG code

Example with Mixed Icons

{ "icons": { "fileTypes": { "pdf": "./icons/brand-pdf.svg", "image": "MediaImage", "video": "./icons/brand-video.svg", "document": "Page" }, "status": { "error": "./icons/brand-error.svg", "success": "CheckCircle", "warning": "WarningTriangle" }, "navigation": { "assistants": "PeopleTag" } } }

Page Layout Configuration

Control the horizontal alignment and maximum width of page content through your theme configuration. This applies to Assistant pages (list, create, edit, and the assistant chat welcome view) and the Search page.

Configuration

Add a layout section to your theme.json:

{ "name": "My Company Theme", "theme": { ... }, "layout": { "pages": { "assistants": { "alignment": "center", "maxWidth": "4xl" }, "search": { "alignment": "center", "maxWidth": "4xl" }, "headers": { "alignment": "center", "maxWidth": "2xl" } } } }

Alignment Options

ValueDescription
"left"Content aligns to the left of the available space
"center"Content centers horizontally (default)
"right"Content aligns to the right of the available space

Max Width Options

ValuePixel WidthDescription
"2xl"672pxNarrow width (default for headers)
"4xl"896pxStandard width (default for pages)
"6xl"1152pxWide width
"full"100%Full available width (minus padding)

Page Types

Page TypeControls
assistantsAssistant list, create, edit, and assistant chat welcome view
searchSearch page results
headersPage header sections (titles and subtitles)

Examples

Left-aligned with wide content:

{ "layout": { "pages": { "assistants": { "alignment": "left", "maxWidth": "6xl" }, "search": { "alignment": "left", "maxWidth": "6xl" }, "headers": { "alignment": "left", "maxWidth": "4xl" } } } }

Full-width layout:

{ "layout": { "pages": { "assistants": { "alignment": "center", "maxWidth": "full" } } } }

Mixed configuration:

{ "layout": { "pages": { "assistants": { "alignment": "left", "maxWidth": "6xl" }, "search": { "alignment": "center", "maxWidth": "4xl" } } } }

Default Behavior

If no layout configuration is provided, the system uses these defaults:

  • Alignment: center
  • Headers max-width: 2xl (672px)
  • Pages max-width: 4xl (896px)

This ensures backward compatibility with existing themes.

Custom Assistant Avatar

You can customize the assistant’s profile picture by adding an assistant-avatar.svg (or other image format) to your theme directory.

Using the Theme Directory

When you have a theme configured, place the avatar file in:

public/custom-theme/my-company/assistant-avatar.svg

The avatar will be automatically detected and used.

Using Additional Environment Configuration

You can also override the assistant avatar path using the additional_environment configuration:

[frontend] theme = "my-company" additional_environment = { "THEME_ASSISTANT_AVATAR_PATH" = "/custom-theme/my-company/assistant-avatar.svg" }

Supported Image Formats

  • SVG (recommended for scalability and quality)
  • PNG, JPG, or other browser-supported image formats

Fallback Behavior

If no custom avatar is provided, the assistant will display with:

  • A colored circle using the theme’s avatar.assistant.background color
  • The letter “A” in the avatar.assistant.foreground color

Environment Variables

You can also configure theme assets using environment variables (useful for development):

  • VITE_CUSTOMER_NAME - Theme directory name
  • VITE_LOGO_PATH - Override logo path (light mode)
  • VITE_LOGO_DARK_PATH - Override logo path (dark mode)
  • VITE_ASSISTANT_AVATAR_PATH - Override assistant avatar path

Deployment

Docker

Mount your theme directory into the container:

docker run -v ./my-theme:/app/public/custom-theme/my-company erato-image

Kubernetes

Use a ConfigMap to store your theme:

apiVersion: v1 kind: ConfigMap metadata: name: erato-theme data: theme.json: | { "name": "My Theme", "theme": { ... } } --- apiVersion: apps/v1 kind: Deployment metadata: name: erato spec: template: spec: containers: - name: erato volumeMounts: - name: theme mountPath: /app/public/custom-theme/my-company volumes: - name: theme configMap: name: erato-theme

For logos and avatars (binary files), use an init container or a PersistentVolume.

See Also

Last updated on