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:
- Theme Directory - A folder containing all theme assets
- Theme Configuration - JSON file defining colors and styles
- Static Assets - Logos and avatar images
Setting Up a Custom Theme
- Configure the theme name in
erato.toml:
[frontend]
theme = "my-company"- 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.poTheme 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.shellcovers app, page, sidebar, chat shell, modal, and dropdown surfacescolors.messagecovers user and assistant message surfaces plus shared hover and controls surfacescolors.overlaycovers backdrops such as the modal overlayradiuscovers base, shell, input, control, message, modal, dropdown, card, and pill radiispacingcovers shell, message, controls, sidebar density, chat input, and dropdown spacingelevationcovers shell, input, modal, and dropdown shadowslayoutcovers chat content width, chat input width, sidebar width, and underlayout.dropdownthe popover panel’sminWidth,wideWidthandviewportMargin
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.cssloads after the runtime CSS variables fromtheme.jsontheme.cssloads afterfonts.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"anddata-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 classsidebar-section-skinfor fill and divider colour, and in their normal formsidebar-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 forsidebar-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 onsidebar-section-skinintheme.cssre-pads the flush band as well and pushes the drawer’s Settings row off the edge it is meant to span. Put band padding onsidebar-band-geometryinstead and the flush band stays flush. Neither band writes astyleattribute, 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-surfacesays which paint it takes, takingfloatingfor a trigger that sits outside[data-ui="sidebar"],framedfor the same frame in normal flow (the add-in’s chat header row) andflushinside a band that already paints; the first two also carry the classfloating-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-appdown first and paints--theme-shell-sidebarover 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 ondata-surfaceand never ondata-variant, because the control keeps thedata-variant="sidebar-icon"the shared button emits — the attribute the shipped customer themes key their sidebar-control rules on.aria-expandedsays 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"(carriesdata-selected="true"on the open conversation’s row; thearia-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 panelDropdownMenudraws — an instance name rather than a family hook: a caller renames it through thedataUiprop, as the model selector does withmodel-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"anddata-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-contentsuffix, spelledchat-input-delegation-run-mode-contentfor the run-mode menu; the submenu has no separate body. A panel whose caller names none falls back todata-ui="anchored-popover-panel")data-ui="popover-section-header"anddata-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 classlist-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 classdropdown-item-geometry, and states itself on the same element witharia-checkedwhere the row is a toggle,aria-expandedwhile its flyout is open,aria-disabledwhen it is unavailable but still keyboard-reachable, anddata-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 aredata-ui="card-media",data-ui="card-header",data-ui="card-body"anddata-ui="card-footer", in that order, and the first and last of them round to the frame.data-varianton the frame says which kind of card it is, takingsurface,interactive,selectableorexpandable, anddata-tonecarries a non-neutral tone, takingmuted,accent,info,success,warningorerror. 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) anddata-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 readsradius.messagerather thanradius.card, set as--card-radiuson the element itself, which is the one card a rule on the hook cannot move that way: re-declare--theme-radius-messageon the hook instead, and the bar and the control inside it move together)data-ui="option-card"(takesdata-selectedwhile 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, anddata-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 carriesdata-ui="tab-rail-tab".data-varianton the rail says which anatomy it is, takingrailfor a bare strip of tabs andsegmentedfor the bordered inset track, anddata-orientationsays which way it is announced, takinghorizontalorvertical; the rail also carries the classtab-rail-geometry, a segmented tracktab-rail-track-geometryon top of it, and every tabtab-item-geometry. The family reads four private variables:--tab-rail-radiusfor the corner, defaulting to--theme-radius-control, which the rail never declares so a value set on any ancestor reaches it;--tab-rail-gapbetween tabs; and--tab-rail-track-paddingand--tab-rail-track-border-width, the inset and hairline of a segmented track, which are0pxon a bare rail. A tab derives its own corner as the rail radius minus the track padding, floored at0px, so track and segments stay concentric at any radius. Write every zero as0px, never0: a unitless zero is not a length inside thatmax(), the whole expression becomes invalid at computed-value time, and every tab goes square with no error anywhere)data-ui="attachment-group"anddata-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-radiuson either moves it and everything nested under it together. Declare--attachment-tile-radiuson the card to reshape the attachment chips inside it, their icon tiles follow)data-ui="attachment-tile"(each attachment chip.data-variantsays which shape it is drawn as, takingtilefor a chip standing on its own,rowfor the full-width form a selectable row takes inside an email thread or a picker, andbarefor a chip whose surrounding surface draws the frame instead;data-mediasays which face it is showing, takingimagefor a thumbnail anddocumentfor 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 andradius.pilldoes 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-tonesays which, takingneutralorerror, 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 anattachment-tilecarrying its own checkbox and never emits this hook. Component kits that draw their own selectable row still do, and the shippedopen-webui-liketheme 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(3remby default) so it follows a theme’s control size; a theme that sizessidebar-iconbuttons outside that token sets the variable itself)data-ui="alert"(the shared inline error, warning, info and success banners;data-toneon the same element says which one, takingerror,warning,infoorsuccess, 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-thicknessand--spinner-durationon it to restyle all of them at once; the head defaults to--theme-fg-secondaryand 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"anddata-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.
| Class | Surface | Kind | Position |
|---|---|---|---|
alert-geometry | Inline alert banner: corner and padding | geometry | @layer components |
anchored-popover-skin | Every popover panel: fill, hairline, shadow; declares --dropdown-item-radius | skin | after the layer |
app-shell-skin | App shell background | skin | after the layer |
attachment-badge-geometry | Remove button on a staged attachment; an intrinsic circle | geometry | @layer components |
attachment-group-frame-geometry | Attachment group frame | geometry | after the layer |
attachment-group-geometry | Attachment group outer box | geometry | after the layer |
attachment-group-header-geometry | Attachment group header band | geometry | after the layer |
attachment-group-items-geometry | Attachment group item area | geometry | after the layer |
attachment-notice-geometry | Status or loading row inside a group | geometry | @layer components |
attachment-notice-icon-skin | That row’s icon | skin | after the layer |
attachment-notice-label-skin | That row’s label | skin | after the layer |
attachment-notice-skin | That row’s fill and border, per data-tone | skin | after the layer |
attachment-tile-geometry | Attachment chip corner | geometry | @layer components |
attachment-tile-icon-geometry | Icon plate behind a document chip’s glyph | geometry | @layer components |
attachment-tile-icon-skin | That plate’s tint | skin | after the layer |
avatar-geometry | Avatar circle | geometry | @layer components |
btn-geometry-sm, btn-geometry-md, btn-geometry-lg | Shared button padding and minimum size, per size | geometry | @layer components |
btn-geometry-icon-sm, btn-geometry-icon-md, btn-geometry-icon-lg | The same for icon-only buttons | geometry | @layer components |
card-body-geometry | Card body inset; publishes the corner and inset a nested card derives | geometry | @layer components |
card-geometry | Card frame corner, from --card-radius | geometry | @layer components |
card-skin | Card border, fill and shadow, with tone, hover, selected and expanded variants | skin | after the layer |
chat-body-skin | Conversation body background | skin | after the layer |
chat-header-skin | Chat header background | skin | after the layer |
chat-input-controls-geometry | Composer control row | geometry | @layer components |
chat-input-shell-geometry | Composer shell padding | geometry | @layer components |
chat-input-shell-skin | Composer shell fill and border | skin | after the layer |
chat-input-textarea-geometry | Composer textarea box | geometry | @layer components |
chat-message-skin | Message shell paint | skin | after the layer |
dropdown-item-geometry | Menu row padding and its derived corner | geometry | @layer components |
dropdown-panel-chrome-geometry | Popover panel inset | geometry | @layer components |
floating-control-skin | Floating and framed sidebar toggle: corner, hairline, shadow, opaque base | skin | after the layer |
focus-ring | Standard focus-visible ring, with offset | state | after the layer |
focus-ring-inset | The same ring drawn inside the box | state | after the layer |
focus-ring-tight | The same ring with no offset | state | after the layer |
list-row-geometry | Plain list row corner | geometry | after the layer |
message-frame-geometry | Framed alert, worn by the composer’s non-alert notices as well | geometry | @layer components |
modal-close-geometry | Dialog close button; a circle | geometry | @layer components |
modal-overlay-skin | Dialog scrim | skin | after the layer |
modal-section-geometry | Dialog section padding | geometry | @layer components |
modal-shell-frame-geometry | Dialog maximum width and height | geometry | @layer components |
modal-shell-skin | Dialog panel paint | skin | after the layer |
option-card-geometry | Option card corner | geometry | @layer components |
page-shell-skin | Page shell background | skin | after the layer |
pill-geometry | Badge, chip and status pill capsule | geometry | @layer components |
sidebar-band-geometry | Sidebar header and footer band: derived minimum height and padding | geometry | after the layer |
sidebar-content-col-geometry | Sidebar row content column | geometry | after the layer |
sidebar-icon-col-geometry | Sidebar row icon column | geometry | after the layer |
sidebar-icon-col-geometry-avatar | Its avatar variant | geometry | after the layer |
sidebar-icon-col-geometry-logo | Its logo variant | geometry | after the layer |
sidebar-inset-geometry | Sidebar horizontal inset | geometry | after the layer |
sidebar-label-col-geometry | Sidebar row label column | geometry | after the layer |
sidebar-row-geometry | Sidebar row minimum height and corner | geometry | after the layer |
sidebar-row-selected | Selected sidebar row fill | state | after the layer |
sidebar-section-skin | Sidebar band fill and divider | skin | after the layer |
sidebar-skin | Sidebar background | skin | after the layer |
sidebar-trailing-col-geometry | Sidebar row trailing column | geometry | after the layer |
spinner-geometry | Loading ring | geometry | @layer components |
tab-item-geometry | One tab | geometry | @layer components |
tab-rail-geometry | Tab strip | geometry | @layer components |
tab-rail-track-geometry | Segmented control track | geometry | @layer components |
theme-transition | Shared colour, shadow and transform transition | motion | after the layer |
thread-message-card-geometry | Email thread message card | geometry | after 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:
- Geometry classes, hoisted to the components position
- Tailwind utilities
- Skin classes, and the fifteen geometry classes marked “after the layer”
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 it | Hook | Corner comes from | Retune |
|---|---|---|---|
| Attachment frame, email preview frame | attachment-group | radius.input | set 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 row | thread-message-card | the group frame’s corner minus the group’s inset, floored at 0 | re-declare --card-radius on the hook; border-radius alone leaves the chips on the old value |
| Attachment chip, file chip inside a thread card | attachment-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.125rem | declare --attachment-tile-radius on thread-message-card |
| Icon plate behind a document chip’s file-type glyph | attachment-tile-icon, class attachment-tile-icon-geometry | --attachment-tile-icon-radius, derived from the chip corner, never below 0.125rem | declare --attachment-tile-icon-radius on the chip; --attachment-tile-icon-tint is its colour |
| Remove button on a staged attachment | attachment-remove, class attachment-badge-geometry | 50%, it is a circle and reads no radius token | style the hook; radius.pill deliberately does not reach it |
| Loading placeholder inside a group | attachment-loading | none, it frames a bare spinner | style the hook; the spinner inside it follows spinner |
| Status or loading row inside an attachment group | attachment-notice (data-tone), class attachment-notice-skin | --attachment-tile-radius, the same corner as the chips beside it | declare the four --attachment-notice-* variables on the hook; the error tone already moves three |
| Loading ring, spinner | spinner | 50%, it is a circle and reads no radius token | declare --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-skin | radius.card | set radius.card, or --card-radius on the hook |
| Entity card in settings (MCP, apps) | entity-row | radius.card | set radius.card |
| Assistant card, search result card | assistant-list-card, search-result-card | radius.card | set radius.card |
| Assistant hub category tile, version card | assistant-category-tile, assistant-hub-version-card | radius.card | set radius.card |
| Recent conversation on the assistant welcome screen | assistant-past-chat-card | radius.card | set radius.card |
| Background-runs bar above the composer | delegated-runs-section | radius.message, set as --card-radius at the site | re-declare --theme-radius-message on the hook |
| Option card (appearance, Outlook behaviour), its icon tile | option-card, option-card-icon | radius.control | re-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 inset | set --tab-rail-radius on the hook; re-declaring --theme-radius-control there still works |
| Menu panel, ”+” menu, filter menu and flyout | class anchored-popover-skin, rows menu-item | radius.dropdown | set radius.dropdown; rows via --dropdown-item-radius on .anchored-popover-skin |
| Sidebar rows and nav rows | chat-history-item, .sidebar-row-geometry; state via data-selected / aria-current | radius.shell | set radius.shell |
| Sidebar header and footer bands, add-in drawer bands | sidebar-header, sidebar-footer; class .sidebar-band-geometry | none, the bands are square and read no radius token | style the hooks; a flush band swaps the geometry class for .sidebar-band-flush |
| Sidebar and drawer toggle, floating history trigger | sidebar-toggle (data-surface), class .floating-control-skin | radius.shell on a floating or framed one, from the skin; radius.control on a flush one | set 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-geometry | radius.control | set radius.control, or a rule on .list-row-geometry |
| Badge, chip, status pill | class pill-geometry | radius.pill | set radius.pill |
| Avatar circle | class avatar-geometry | 50%, a circle reads no radius token | none, a circle stays a circle |
| Alert, inline error or warning banner | alert | radius.base; the framed alerts inside a chat take radius.message | set 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-selectedon the segments of a segmented control and on settings tabsaria-checkedon menu rows that toggle somethingaria-pressedon 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 itaria-current="page"on the link of the open conversation in the history listaria-expandedon the row of an open submenu, which the app itself styles through thearia-expanded:variantaria-disabledon 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"takesdata-selectedon the open conversation’s row, beside thearia-currenton its link- the sidebar navigation row takes both
aria-current="page"anddata-selected, because the active row is drawn without a link and would otherwise announce nothing data-ui="option-card"anddata-ui="option-card-icon"takedata-selectedwhile the card is checked; the radio input underneath keeps the ARIAdata-ui="attachment-tile"takesdata-selectedonly 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 takesdata-invalidwhile 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 — takesdata-selectedwhile it is the chosen one anddata-expandedwhile its body is open. Thearia-expandedstays 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 takesdata-disabledunless it is a native button, which says it on thedisabledattribute 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-pressedon a button is an animation, not a state. It sits on the shared button at all times, readingfalseat rest and flipping totruefor 200 milliseconds after a click, on the same element asaria-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 onaria-pressedinstead.- A control that is unavailable but must stay reachable by keyboard uses
aria-disabledtogether with thearia-disabled:variant for its dimmed look, rather than the nativedisabledattribute, so the control keeps its place in the tab order and can still explain why it is unavailable. Nodata-disabledis minted for that case; key onaria-disabled. Thedata-disableda card carries is the other case: a frame that is not a native button and so has nodisabledattribute 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 tologo.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.poAvailable Branding Translation IDs
These translation IDs are designed for brand customization and have stable IDs guaranteed across versions:
| ID | Purpose | Default (en) | Default (de) |
|---|---|---|---|
branding.assistant_name | The name/label shown for AI messages | ”Assistant" | "Assistent” |
branding.user_form_of_address | Generic label for user messages (fallback when no user name available) | “You" | "Du” |
branding.welcomeScreen.title | Welcome screen heading | ”Welcome to AI Assistant" | "Willkommen beim KI-Assistenten” |
branding.welcomeScreen.subtitle | Welcome screen subheading | ”Get expert help…" | "Erhalten Sie Expertenunterstützung…” |
branding.welcomeScreen.description | Welcome screen description text | ”Ask questions…" | "Stellen Sie Fragen…” |
branding.page_title_suffix | Browser 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
.pofiles are the source of truth. In development, you can edit both, but only.pofiles 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:
- User’s actual name (from
userProfile.name) - e.g., “Daniel Schmidt” - 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
| Category | Purpose | Keys |
|---|---|---|
fileTypes | File type icons in upload and file preview | pdf, image, document, spreadsheet, presentation, text, code, archive, audio, video, other |
status | Status indicators in alerts and tool calls | info, warning, error, success, in_progress |
actions | Action button icons (future extensibility) | copy, edit, delete, share, plus, close, check, refresh |
navigation | Sidebar navigation menu item icons | assistants, 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
currentColorfor 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
| Value | Description |
|---|---|
"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
| Value | Pixel Width | Description |
|---|---|---|
"2xl" | 672px | Narrow width (default for headers) |
"4xl" | 896px | Standard width (default for pages) |
"6xl" | 1152px | Wide width |
"full" | 100% | Full available width (minus padding) |
Page Types
| Page Type | Controls |
|---|---|
assistants | Assistant list, create, edit, and assistant chat welcome view |
search | Search page results |
headers | Page 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.svgThe 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.backgroundcolor - The letter “A” in the
avatar.assistant.foregroundcolor
Environment Variables
You can also configure theme assets using environment variables (useful for development):
VITE_CUSTOMER_NAME- Theme directory nameVITE_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-imageKubernetes
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-themeFor logos and avatars (binary files), use an init container or a PersistentVolume.
See Also
- Internationalization (i18n) - Language support and how translation overrides work technically
- Configuration Reference - Complete configuration options
- Frontend README - Detailed theme development guide