Light tokens are a default. This catalog is the override
The project CSS Variables page holds the light (or default) --name values. Those emit as one static map on the widget. That is not theming.
Two host strategies then break. A class body can nest @media (prefers-color-scheme: dark), but a token cannot. And when the host puts dark on html — outside the published key — [key] .dark\:bg-black never sees it. Project themes are the missing catalog: an allowlisted host condition plus the token overrides that apply under it. Light values stay on CSS Variables. This page stores overrides only.
The legal emit reads the host and applies tokens only under the published key. It never sets properties on html, :root, or body.
When to use it / when not to
| You want | Without project themes | With this catalog |
|---|---|---|
| A widget whose tokens are light-by-default to paint dark values when the host (or OS) is dark | Light --background is what the embed always paints. | RuleCMS ships the override under prefers-color-scheme: dark and/or html.dark. The embed matches Preview on a dark host. |
A host that already sets the same --names under its own .dark | Works today. Do not double-define. | Turn published inclusion off. Composer and Preview still use the catalog. |
dark or data-theme on html (or another ancestor of the widget). A class inside the widget is not enough — the host marker lives outside the published key.The project catalog
Open an environment, then Themes. The page is project-scoped: Development, Staging, and Production share it.
- Preview dumps the catalog as the emitted wrapped stylesheet — with
.rulecms-render-surfacein place of the published-key selector. It is not a:rootlisting. - Edit is a condition select plus name + value rows (the same pair UI as CSS Variables). Pick one allowlisted condition —
prefers-color-scheme: dark,prefers-color-scheme: light,html.dark,[data-theme=dark], or[data-theme=light]. Do not paste a CSS prelude. Invalid names and values are refused per row. Catalog Save is explicit.
Each entry is one host condition and its overrides:
html.dark [data-rulecms-published-key="env---w"] > * {
--background: #0a0a0a;
--foreground: #fafafa;
}At most five conditions (one of each allowlisted pair). At most 200 variable pairs across all theme entries — the same cap as the token catalog. Values reuse the CSS Variables sanitizer: no url(, no @, no braces, no ;.
What the renderer does with the catalog
| Surface | What happens |
|---|---|
| Composer canvas and Preview | Always injects the wrapped stylesheet. The published-inclusion flag is ignored here so authors still see the overrides while they work. For html.dark / data-theme, Preview will not flip unless the composer chrome is already in that state — the rules are still emitted so a dark host iframe can match. |
| Published widget (React SDK and HTML custom element) | The widget fetch includes the catalog when inclusion is on. The renderer emits one <style> that reads the host and applies tokens on boxed children of the published-key span. |
You do not republish a widget to restyle a theme. The catalog is joined when the host fetches the widget. The next uncached fetch picks up the current overrides.
Published widgets
On the project's Themes page, the Published widgets accordion holds two radios. Changing a radio saves immediately. Catalog Save is separate.
- Include these theme overrides when published widgets are rendered. A non-empty catalog is sent with the widget and the renderer wraps the overrides. This is the default.
- My apps already theme these tokens. Do not include them again when the widget is being rendered. Published widgets still carry the light token names, but RuleCMS does not ship the host-condition wrap. Your host stylesheet must set the same
--namesunder its own.dark.
How a published widget receives the catalog
Hosts already fetch the widget once. RuleCMS hangs the catalog on that same response as a sibling of the widget, its collections, CSS variables, class definitions, fonts, and keyframes — not folded into itemList.
{
"success": true,
"data": {
"widget": { "publishedKey": "…", "itemList": "…", "…": "…" },
"childCollections": { },
"themes": [
{
"condition": { "kind": "html-class", "value": "dark" },
"variables": { "--background": "#0a0a0a", "--foreground": "#fafafa" }
}
]
}
}themes is omitted when the catalog is empty, when inclusion is off, or if the catalog cannot be read. When inclusion is off, the same response also sends includeThemes: false so renderers that still receive a map must not emit it. Missing on the payload means include (legacy clients). The widget still loads.
The React renderer (@rulecms/widget-react) and the HTML custom element turn a non-empty catalog into one <style>. Both emit shapes:
html.dark [data-rulecms-published-key="env---w"] > * {
--background: #0a0a0a;
--foreground: #fafafa;
}
@media (prefers-color-scheme: dark) {
[data-rulecms-published-key="env---w"] > * {
--background: #0a0a0a;
}
}Good to know
- One catalog per project. Not per environment, not per widget.
- Light tokens stay on CSS Variables. This page is the dark (or light-media) override only.
- React and the HTML embed this pass. Other framework SDKs do not emit this catalog yet.
- Leave the catalog empty if the host already themes the same names. Token names on elements still work. Nothing extra is sent to the published page.
Related docs
- CSS Variables — the light / default token catalog
- Class Definitions — utility bodies; a class can nest
@media, a token cannot - Fonts — the typeface catalog
- Keyframes — named
@keyframes - CSS Properties — typed
@propertyso overridden tokens can interpolate - Widget chrome — color-scheme and selection on the widget root; themes change token values
- HTML Custom Element Embed
- Development Integration
- API Reference — the public widget GET, including
themes - Projects, Environments & Tokens
- The Composer