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:
- Put Geist on this hero without teaching Times. Add the face here and the canvas follows. Edits stay on this widget. They do not write back to the project.
- Pull later project faces onto this widget. When the template moves, Sync from Project copies those families here. Entries that exist only on this widget stay.
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 belongs to this widget. A new widget starts as a snapshot of the project template. After that, edits here stay here. Composer and Preview load these draft faces. Publish is how they reach the published (and dev.) embed.
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.
Composer and Preview read this widget's draft catalog. The published embed and the Development (dev.) draft GET read the published widget's catalog. Publish is how a draft catalog goes live.
A new widget starts as a copy of the project catalog. After that, edits here do not write back to the project. Later project edits do not come back here unless you click Sync from Project.
| Surface | What happens |
|---|---|
| Composer canvas and Preview | Always inject from this widget's draft catalog when it is non-empty. The published-inclusion flag is ignored here so authors still see the result while they work. |
| Published widget (React SDK and HTML custom element) | The widget fetch includes this widget's catalog when inclusion is on. Nested collections inherit. You do not get a copy per collection. |
You need widget edit for this environment — the same permission as the composer — to change faces or click Sync from Project. Viewing CSS settings is a separate, lower permission. A viewer sees the catalog and no edit button.
Put Geist on the hero
You do this on the widget management page — the same place you open the composer. If the project already has Geist, Sync is faster than filling the card again. If only this hero should own the face, add it here.
- Open the organization → team → project → environment → Maya's homepage hero (or the Default Widget).
- Click CSS Settings. You land on Widget CSS Settings.
- On the Fonts card, click Edit Fonts (or View Fonts to scan first). The Sync from Project button also lives on this catalog page and on the hub.
- To pull the project template: click Sync from Project. To add a face only this widget needs: open Edit, click Add font, pick Geist, set
--font-geist-sans, then Save Changes. - On Staging or Production, publish when the live embed should carry the new faces. Development streams the draft — see Development Integration.
That is a safe stop. The canvas already shows Geist. Publish is how visitors see it. Syncing updates the draft only — the live site does not move until she publishes again.
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:
| Field | When you reach for it |
|---|---|
| Family | The 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. |
| Fallbacks | Segoe 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-only | Use faces the browser already has. No download. That matches “looks like a modern sans site.” It will never be Geist. |
| Source: Google | An 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 typeface | At 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. |
--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.
| Field | When you reach for it |
|---|---|
| Palette name | A dashed ident such as --brand. RuleCMS emits @font-palette-values --brand. At most four palettes per face. |
| Override colors | Index plus a color: hex, a named color, or var(--token). No url(. At most eight overrides per palette. |
| Use as the widget's default palette | At 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);
}This widget's catalog
Open the widget, then CSS Settings, then Fonts. This catalog belongs to this widget.
To change the template for the next widget you create, edit the project catalog.
Headlines on this widget can use the faces you define here.
- Preview shows the CSS this catalog will emit — a Google Fonts stylesheet link when a face is loaded, plus the token and widget
font-familyon the render surface. That listing is for authors. Published widgets do not dump a:rootrule 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 new widget starts as a copy of the project fonts catalog. Until someone saves faces here (or the project had none to copy), this widget relies on the host typeface — the Times path if that host never registered the face.
Sync from the project
Once this widget exists, its CSS settings are its own. The project catalog does not update them automatically. A Sync from Project button lives on this Fonts page — preview and edit — and on the widget's CSS Settings hub.
Syncing copies the project's version of an entry onto this widget. When both sides share an id, the project's value replaces the widget's. Entries that exist only on this widget are kept.
The button reads Synced with Project and is disabled when there is nothing to pull. Tiles on the CSS Settings hub show Project updates available when the project has updates for that type.
Syncing updates the draft. Publish the widget for live embeds to change. You can sync one type from this page or all nine from the hub.
Editing the project catalog never changes a widget by itself. A person must 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.
When the button is disabled
- Unsaved edits — the helper reads Save or discard your changes before syncing. That stop exists so a later Save cannot overwrite what was just synced.
- No project catalog — the helper reads This project has no CSS settings to sync.
- Already in sync — the button itself reads Synced with Project and stays disabled.
Who can sync
Sync is governed by the widget edit permission for that environment — the same permission needed to change the widget in the composer. Viewing CSS settings is a separate, lower permission, and a viewer sees no button at all. Project CSS permission is not required: anyone who can edit the widget can pull project values onto it.
- Missing permission — users who can view CSS settings but cannot edit the widget in that environment do not see the button.
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.
Turn off inclusion on published widgets
On this widget's Fonts page, the Published widgets accordion holds two radios. These radios control whether this widget's published and dev. embed carry the definitions. Composer and Preview still use this widget's catalog when inclusion is off. 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.
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.
The canvas already shows Geist when the row is saved. Publish is how those draft faces reach the live embed. (Published traffic may sit in widget-cache for a short TTL — the same delay itemList already has.)
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.
- CSS Variables — A token that points at
--font-geist-sansis not a face. Put the name there after this catalog defines it. - Class Definitions — A class named
font-sansis only a pointer. The body still needs a face this catalog loaded. - Fonts — The project template for the next widget you create.
- CSS Properties — Typed
@property; another unscoped at-rule. - Keyframes, Position try, Cascade layers, Themes, and Widget chrome — the rest of the project templates on the same home. Widget copies sit under Widgets in the sidebar.
- Generate Tailwind CSS — Compile the class names this widget already uses. That flow can add tokens here; it does not replace this catalog.
- Projects, Environments & Tokens — The pipeline, and project CSS as the template for the next widget.
- Development Integration — Preview the live Development draft with a
dev.token. - HTML Custom Element Embed — The script tag that mounts a widget in light DOM so host faces still reach it.
- API Reference — The public widget GET, including
fontFaces. - MDN: @font-face
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 belongs to this widget. Copy and promote copy these columns as-is. The project catalog is only the starting snapshot. Use Sync from Project on this page or the CSS Settings hub to pull later project updates.
- Light DOM does not mean the host
bodywins. Once RuleCMS setsfont-familyon 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.