Contents

Themes

Light tokens on CSS Variables are a default. Until the project catalog overrides those names under html.dark or prefers-color-scheme, a dark host still paints the light values. Project themes let RuleCMS emit a structured wrap so composer, Preview, and published widgets can follow the host — or leave theming to your site.

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 wantWithout project themesWith this catalog
A widget whose tokens are light-by-default to paint dark values when the host (or OS) is darkLight --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 .darkWorks today. Do not double-define.Turn published inclusion off. Composer and Preview still use the catalog.
Class-strategy hosts must put 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-surface in place of the published-key selector. It is not a :root listing.
  • 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

SurfaceWhat happens
Composer canvas and PreviewAlways 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 --names under its own .dark.
Composer and Preview still use the catalog when inclusion is off. Only the published (and Development-draft) embed on your site stops carrying the extra rules.
If you turn inclusion off, the host must theme every token name the widgets use under its own dark strategy. A missing override on the live page leaves the light CSS Variables values in place.

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