Contents

Fonts

Maya already has a homepage hero. The headline on that hero should be Geist — her brand face — not Times. A token that points at --font-geist-sans is not a face. This page is the project catalog: the template of typefaces copied onto each new widget. After that the widget owns the copy. Editing here does not restyle widgets that already exist.

What you can do

You already opened Maya's homepage hero from Widgets and started composing. The headline on that hero should be Geist — her brand face — not Times. A token that points at --font-geist-sans is not a face. A class named font-sans is only a pointer at that name. The typeface never lived in those catalogs.

Two everyday jobs:

  • Load the face once for the next widget. Add Geist here, with --font-geist-sans if her utilities already point at that name. A widget she creates after that starts with the face already filled in.
  • Leave inclusion off when the host already loads Geist. Her product stylesheet can own the files. The hero still points at --font-geist-sans. RuleCMS does not ship a second copy.

That same pattern covers a system stack that never downloads a file, a Google family with specific weights and subsets, and a color-font palette on the same card. Authors stay in the catalog form. Engineers keep one vocabulary.

How a typeface catalog thinks

A setting can store a family name. A token can store var(--font-geist-sans). Someone still has to load the face. The host stylesheet can do that — Maya's product already does, on body or a theme class. RuleCMS can do it too, in a catalog of families, optional tokens, and sources.

Tokens cannot fetch a face. Class bodies wrap as a descendant rule, so a pasted @font-face is ignored. That is why this catalog exists: RuleCMS loads the face and, when you ask, sets font-family on the widget so Preview and the published embed match.

This catalog is the template for the next widget, not a live link to widgets that already exist. When Maya creates a widget, RuleCMS copies these faces onto it — including the inclusion radios. After that the widget owns the copy. Composer, Preview, and the published embed read that widget, not this page. The Projects guide already draws that line. This page is the typeface catalog itself.

Here is the case that surprises people. Maya points the hero at var(--font-geist-sans) and expects Geist. If that name is missing, CSS drops the entire font-family declaration. It does not walk the comma list to sans-serif. The fallback is Times.

A second surprise: she edits Geist on the project and expects the Production Default Widget — the hero she already built — to pick up the face. It does not. Existing widgets stay on the snapshot they received at create until someone opens that widget's CSS Settings and clicks Sync from Project.

The renderer does not read this catalog. Composer, Preview, and the published widget GET read the widget's own catalog. This project catalog is copied onto a widget only when that widget is created.

A new widget receives a snapshot of this catalog, including the inclusion flags. Widgets that already exist are not updated automatically. Existing widgets can pull those updates later from the widget's own CSS Settings page. Copy, promote, and restore copy the source widget's catalog, not this one.

SurfaceWhat happens
Composer canvas and PreviewDo not read this catalog. They read the widget's draft columns.
Published widget (React SDK and HTML custom element)Reads that widget's published columns. Inclusion on that widget decides whether the embed carries them.

You need a Project CSS Catalog group at View, Manage, or Admin. View can open this page. Manage is enough to add a face and flip the inclusion radios. Those radios set the default copied onto new widgets. They do not change widgets that already exist.

Put Geist on the hero

You do this on environment home. The Default Widget in Production already exists — it will not pick up a new face until someone syncs. A widget she creates after this save starts with Geist already there.

  1. Open the organization → team → project → environment you want to work in.
  2. Under CSS catalog, click Fonts. You land on this project page — Development, Staging, and Production share it.
  3. Open Edit. Click Add font. Family: Geist. CSS variable: --font-geist-sans. Source: Google — load this family from the allowlist. Pick the weights and subsets she needs. Leave Use as the widget's default typeface checked so the hero headline uses it. Save Changes.
  4. Create a new widget from the orange Widgets card — that widget receives a snapshot of this catalog. Or open a widget that already exists, go to CSS Settings, and click Sync from Project so the hero picks up Geist.

That is a safe stop. The next widget starts with Geist instead of Times. Come back here when the next widget should start from a richer set of faces, or open that widget's catalog when only the hero should change.

Family, source, and the optional token

Edit is a card per family — not a pasted @font-face. Reach for each field when the job matches:

FieldWhen you reach for it
FamilyThe face name — Geist, Inter. Not a CSS file. For Google, you pick from the allowlist.
CSS variable (optional)The token this face defines, for example --font-geist-sans. Utilities and class bodies may keep pointing at it. A token that says var(--font-geist-sans) without this entry is the Times bug.
FallbacksSegoe UI, system-ui, sans-serif. Comma-separated family names. RuleCMS builds the file URL; you pick the family — you never paste a network path here.
Source: stack-onlyUse faces the browser already has. No download. That matches “looks like a modern sans site.” It will never be Geist.
Source: GoogleAn allowlisted family plus weights and subsets. RuleCMS writes the Google Fonts CSS URL. You never paste a gstatic path.
Use as the widget's default typefaceAt most one row. Sets font-family on the widget's first real children. Other rows only mint a token and load the face, so a class or leaf can point at them.
The composer font-family picker is a different tool. That drawer control is an allowlisted stack (system faces). It does not load Geist. This catalog is who defines --font-geist-sans.

Color palettes

Color fonts (COLRv1) can ship more than one set of ink. The host registers those sets with @font-palette-values, then a leaf or class points at the name with font-palette. That at-rule is document-global — a class body cannot register it, and tokens reject @. Palettes are optional fields on the same font card, not a second catalog. Keep them here so adding Geist is still the first click.

Add a palette when the face needs a brand set of override colors and the embed host will not register it. Leave palettes empty when the host already owns font-palette, or when the face is not a color font — stack-only rows accept them, but they usually do nothing useful.

FieldWhen you reach for it
Palette nameA dashed ident such as --brand. RuleCMS emits @font-palette-values --brand. At most four palettes per face.
Override colorsIndex plus a color: hex, a named color, or var(--token). No url(. At most eight overrides per palette.
Use as the widget's default paletteAt most one row in this catalog. Sets font-palette: --brand on the widget's first real children. Other palettes only register; a class can still point at them.

The Fonts inclusion radios also control palettes. When published widgets exclude fonts, RuleCMS does not emit @font-palette-values either. There is no separate palettes toggle.

@font-palette-values --brand {
  font-family: Geist;
  override-colors: 0 #111111, 1 var(--brand-accent);
}

The project catalog

Open an environment, then Fonts. That page is the catalog for the project, not for one environment: Development, Staging, and Production share it.

To change one widget, open its CSS Settings — that guide is the override for this type.

Every new widget in the project can start from the same faces.

  • Preview shows the CSS this catalog will emit — a Google Fonts stylesheet link when a face is loaded, plus the token and widget font-family on the render surface. That listing is for authors. Published widgets do not dump a :root rule onto your page.
  • Edit is a card per family. Add font starts a row. Invalid fields are refused per row. Save Changes stays disabled until the catalog is clean.

At most 12 fonts and 8 fallbacks per face. Google families, weights, and subsets come from an allowlist — RuleCMS builds the file URL; you pick the family.

A project has no fonts row until someone saves for the first time. Until then, a new widget starts without RuleCMS-owned faces and relies on the host typeface — the Times path if that host never registered the face.

Updates for existing widgets

Editing the project catalog never changes a widget by itself. A person must open the widget and click Sync from Project.

Widgets are per-environment (Development, Staging, Production), but the project catalog is shared across all three. Syncing a Production widget pulls from the same project values a Development widget would.

There is no project-side action that pushes settings to many widgets. Syncing is always started from a widget — one type from its feature page, or all nine from the hub.

Your site probably loads them too — and that is fine

The original type model is still the right one for a product that already loads faces: the host stylesheet registers Geist, and RuleCMS widgets inherit it. A Next.js host that already ran its font loader is one of those cases — not a requirement, and not something you configure inside RuleCMS.

Teams that also fill the RuleCMS catalog usually keep the same family name in both places. By default RuleCMS still includes those faces on the published widget. The rest of your page is untouched: injection is scoped to the widget, not dumped on :root.

That default is the useful one when:

  • The widget is on a page that never loaded Geist (a landing page, a partner site, an email-adjacent HTML shell).
  • Editors need the published embed to match Preview even if the host forgot the face.
  • You are still rolling the typeface out on the host and the catalog is ahead of the stylesheet.

It is the wrong default when the host stylesheet is the only load you want at runtime — especially if RuleCMS and the host might disagree for a while.

You do not have to choose between RuleCMS and your stylesheet. Most teams keep the same family name in both places. RuleCMS copies this catalog onto each new widget by default, including the inclusion flag, so a new embed can look correct even on a page that never loaded Geist. If that double load is a problem, turn inclusion off.

Turn off inclusion on published widgets

On the project's Fonts page, the Published widgets accordion holds two radios. These radios set the default copied onto new widgets. Changing them does not change widgets that already exist. Changing a radio saves immediately. Catalog Save is separate.

The first radio is selected by default:

  • Include these fonts when published widgets are rendered. A non-empty catalog is sent with the widget and the renderer loads the faces inside the widget — including any color palettes on those faces. This is how RuleCMS makes Preview and the live embed match.
  • My apps already load these fonts. Do not include them again when the widget is being rendered. Published widgets still point at --font-geist-sans, but RuleCMS does not ship the files. Your host must register the face. Use this when the app already loads Geist.
Composer and Preview still use the widget catalog when inclusion is off. These radios only set the default copied onto a new widget. They do not change widgets that already exist. Empty catalog plus the setting on is the same as off: there is nothing to include.
If you turn inclusion off, the host must register every face the widgets use. A missing var() on the live page drops the whole font-family — Times again. Check the published widget on a real route after you flip the setting.

How a published widget receives the catalog

Hosts already fetch the widget once. When inclusion is on and the catalog is not empty, RuleCMS hangs those faces on that same response as a sibling of the widget, its collections, CSS variables, and class definitions — not folded into itemList, not copied onto each collection. The field is fontFaces so the renderer can load the files without guessing which list is type.

{
  "success": true,
  "data": {
    "widget": { "publishedKey": "…", "itemList": "…", "…": "…" },
    "childCollections": { },
    "fontFaces": [
      {
        "family": "Geist",
        "cssVariable": "--font-geist-sans",
        "fallbacks": ["Segoe UI", "system-ui", "sans-serif"],
        "applyToWidget": true,
        "source": { "kind": "google", "weights": [400, 700], "subsets": ["latin"] },
        "palettes": [
          {
            "name": "--brand",
            "overrideColors": [
              { "index": 0, "color": "#111111" },
              { "index": 1, "color": "var(--brand-accent)" }
            ]
          }
        ]
      }
    ]
  }
}

fontFaces 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 includeFontFaces: false so renderers that still receive a list must not emit it. The widget still loads. You never lose a page because a face row is missing.

The React renderer (@rulecms/widget-react) and the HTML custom element (which bundles that renderer) turn a non-empty catalog into a Google stylesheet link when a face is loaded, plus one scoped <style> on the widget's first real children. @font-face (when emitted) is unscoped — a face has to register on the document. The token and font-family land on [data-rulecms-published-key] > *, not on :root:

[data-rulecms-published-key="env---widget-abc"] > * {
  --font-geist-sans: Geist, "Segoe UI", system-ui, sans-serif;
  font-family: var(--font-geist-sans);
}

Nested collections inherit. Other widgets on the same page keep their own catalogs.

The Development draft fetch (Development Integration) reads the same fields from the draft widget row, so a dev. typeface preview on your site matches composer.

After a widget is created, restyling a face means editing that widget's catalog (or the host stylesheet). Editing this page does not change widgets that already exist.

If the catalog is empty, nothing extra is emitted. Host faces still work. Widgets that never used a RuleCMS typeface are unchanged.

React and the HTML embed this pass. Other framework SDKs do not emit this catalog yet.

What to read next

This page is the story and the first walkthrough. The token catalog that points at a face, the other catalogs, and the request your app already sends live on the pages below.

How this fits

A widget is the content. This catalog loads the faces that widget paints with. It does not replace the widget, and it does not replace Maya's stylesheet. Publishing is how a draft catalog reaches a Staging or Production token. The Composer is where a setting stores a family name or a pointer at --font-geist-sans.

This catalog is the template for new widgets. It is shared across Development, Staging, and Production. The renderer reads the widget, not this page. Existing widgets are not updated automatically when you edit here; they can pull the new values from CSS Settings.

  • Light DOM does not mean the host body wins. Once RuleCMS sets font-family on the widget's children, host type no longer inherits into that tree. Turn inclusion off if you want the host face to win.
  • Leave the catalog empty if the host already loads the same faces. Pointers in tokens and classes still work. Nothing extra is sent to the published page.
  • Ask whoever owns the stylesheet which faces are already loaded. A missing var(--font-geist-sans) on the live page drops the whole declaration. Times is the surprising case, not a second sans.