Theming: the rest of it

Embed
Preview of the card
<a href="https://forumulate.com/guides/theming-advanced"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/bdd2db62-fc99-42cb-be4d-056524031aa6/social-card.svg?v=3e7ae33eec8b" alt="Theming: the rest of it" width="600" height="315"></a>

Theme your site covers the keys and what each one changes. This page is the parts it leaves out: two undocumented keys, the merge semantics of PATCH, how site themes, theme components and reader themes stack, the analytics tags and the custom <head> (including which of them a reader can actually refuse), how to shape the generated link-preview card, which CSS variables and selectors you may rely on, the fonts and images your pages are allowed to load from somewhere other than us, and the image widths our asset URLs accept.

Two keys in more detail

notFound — your own 404 page

{
  "notFound": {
    "title": "We moved that one",
    "body": "Try the search, or start from the guides index.",
    "pageId": "b0662f97-6c2e-4f0b-9d44-1b8f5e3a7c21",
    "hidePage": true,
    "links": [{ "label": "All guides", "href": "/guides" }],
    "showSearch": true,
    "showSections": true,
  },
}

Key

Limit

title

1–120 characters. With pageId it is the tab title and the fallback's heading

body

up to 600 characters. Shown only when no page body renders

pageId

a uuid of a page in this space — on a site's config.theme, a page in any of its spaces

hidePage

boolean, default true: take the pageId page out of the navigation, sitemap and search

links

up to 8 link objects

showSearch

boolean, default true. false leaves the search box off

showSections

boolean, default true. false leaves the list of sections this address publishes off

hideSearch

boolean — the older spelling of showSearch: false, still honoured

redirect

none (default) | home: a miss on a documentation page goes to the space's front door (307)

pageId is the 404's body: the reader renders that page's blocks, with your header and footer around them and (unless switched off) the search box and the section list underneath. The answer is still a 404 status with noindex, so a crawler is told the truth — a soft 404 is a not-found page answered with a 200, and this is not one. A pageId that is not there when you save is a 422 naming the key. If the page later cannot be fetched (deleted, unpublished, its space made private) the reader falls back to the title, body and links, never to an error screen. hidePage does its work with the page's own hideFromNav and noindex flags, so the page details panel shows them, and switching hidePage off later leaves the flags for you to clear there. pageSpaceId is written by the API when it checks pageId; do not set it.

ads

{
  "ads": {
    "network": "…",
    "publisherId": "…",
    "slotId": "…",
    "size": "728x90",
    "enabled": true,
  },
}

Key

Notes

network

1–40 characters. A preset network name, or custom.

publisherId

1–120 characters.

slotId

up to 120 characters, optional.

size

WIDTHxHEIGHT, e.g. 728x90. Anything else is a 422: size must be WIDTHxHEIGHT, e.g. 728x90.

enabled

Defaults to true.

With network: "custom" you also supply scriptUrl, slotHtml and configVar.

Some networks cannot run on a forumulate.com address. A network whose script refuses to run inside a frame is refused with a 422 ads.network: "…cannot run on a forumulate.com address — it needs a verified custom domain." The check is that the space is actually served on a live domain, not merely that one is verified.

PUT, PATCH, and how to remove a key

PUT   /orgs/{orgId}/spaces/{spaceId}/theme   — replaces the whole theme
PATCH /orgs/{orgId}/spaces/{spaceId}/theme   — merges

PUT overwrites the entire theme object. Keys you omit are gone. Read with GET and send the union, or use PATCH.

PATCH merges, with three rules:

Nested objects

merge key by key

Arrays and scalars

replace wholesale

An explicit null

clears the field

That last one is the answer to "how do I remove a logo". Not an empty string, not omitting it — null:

PATCH /orgs/{orgId}/spaces/{spaceId}/theme
  { "logoUrl": null, "announcement": null }

null clears a key of any type: a string, an object ({"chrome": null}), an array ({"chrome": {"footerColumns": null}}), one key inside an object ({"chrome": {"cta": null}}) or one entry of a map ({"settings": {"old_key": null}}). The one place it is refused is INSIDE an array element — {"chrome": {"footerColumns": [{"heading": null}]}} is a 422, because arrays replace wholesale, so a null there would be stored rather than applied.

"" is accepted for the URL keys and is not the same thing: it stores an empty string. If you are clearing a logo, send null.

Because arrays replace rather than merge, a PATCH sending one chrome.footerColumns entry replaces all of them. Send the full array.

Both answer 422 for an unrecognised key, with the path named:

{
  "error": {
    "code": "validation.failed",
    "message": "Unrecognised fields in the request body.",
    "details": {
      "_": [
        "Unrecognised: chrome.footerColumns.0.title. Not fields we have — they would be ignored, so they are reported here.",
      ],
    },
  },
}

This is the one body in the API that refuses unknown keys outright instead of dropping them and reporting them in X-Unrecognized-Fields. A misspelled theme key is otherwise invisible: the page renders, just not the way you asked. It cost one customer a day on chrome.footerColumns[].title where the field is heading.

The size limits, in characters

Key

Limit

customCss

20,000 characters

customJs

20,000 characters

mobile.css

10,000 characters

each cssSnippets[].css

10,000 characters

cssSnippets

50 entries

elementStyles

400 entries

settingsSchema

100 entries

scriptOrigins

20 entries

chrome.footer.html

20,000 characters

chrome.footer.css

10,000 characters

a nav item's panel.html

20,000 characters

a nav item's panel.css

10,000 characters

Characters, not bytes. A 20,000-character stylesheet of multi-byte characters is accepted and is more than 20,000 bytes on the wire. Exceeding a limit is a 422 validation.failed with the field path in the error.

If you are near 20,000, split into cssSnippets — 50 × 10,000 is a great deal more room, each snippet is individually toggleable, and a broken one is easier to find.

How themes stack

Three layers at render time, in order. Each later layer wins.

site theme  →  space theme  →  the reader's pick

Theme components are not one of them. This page used to list them as a fourth, live layer; they are a source you copy from, and the section after this one says what that actually means.

1. Site theme under space theme. A space that belongs to a site inherits the site's theme as a base, and the space's own theme wins per field. So a site can set the fonts and a space can override just the accent colour.

2. The reader's pick is not a merge at all. A reader theme is a CSS cascade override: its compiled CSS is injected into a <style> element appended to the document head client-side, after the server-rendered theme, so it wins per token. Tokens the alternate does not declare fall through to the space theme.

GET PUT  /orgs/{orgId}/spaces/{spaceId}/reader-themes
  { "themes": [{ "name": "High contrast", "theme": { … } }] }

Up to 8, each a partial theme. The reader's choice is stored in their own browser under cflx-reader-theme:<spaceId>; "Default" removes the override. We never see it.

Where the picker appears today

Only on forum reader surfaces (/f/…). Stored alternates are served to every surface in the readerThemes of GET /public/orgs/{orgId}/spaces/{spaceId}/chrome, and the control that offers them is mounted on the forum reader and nowhere else — so on a docs, blog, changelog or roadmap space you can write eight palettes, read all eight back, and see no picker.

That is a gap and not a setting: there is nothing to configure that will make it appear. If you want readers to choose, either put the choice on a forum space or wait — and in the meantime the light/dark toggle, which is a separate control and reaches every surface, is the one that works everywhere. The alternates you store now are not wasted: they are already in the payload and will be offered when the picker reaches the other readers.

Header and navigation keys you can mix

Every key below is a theme key (scope: theme — the space's, or the site's for every member space), optional, and works under every look; unset renders the header exactly as it did before the key existed. A look never decides any of them — see Mix and match: looks are patches.

Key

Shape

Unset

What it does

chrome.header.paint

page | brand

page

brand: the bar in your brand, with an ink that reads on it

chrome.header.order

array of search, links, actions, themeToggle, signIn, ctas, menu (each once, ≤ 7)

today's order

The end group's slots, in this order; slots you leave out follow in today's order. Supersedes signInStyle.order

chrome.header.menuButton.phone

auto | always | never

auto

auto: on a phone page with a forum drawer only the drawer's button shows; always draws the ⋮ menu beside it too; never draws no phone menu button

chrome.header.menuButton.desktop

boolean

false

☰ at the header's end from 1024px, listing your header, site and footer links — even when the header shows no links. A ☰ with nothing to list at a width is not drawn there

chrome.headerLinks[].placement

bar | menu | both

bar

Where the link lives on a wide screen: menu lists it ONLY in the ☰ menu (the button is then drawn at every width), both in the row and the menu. A phone folds every link into the menu either way

chrome.brandText

auto | show | hide, or { show, source, phone }

auto

source: space (a lone space's header names the space) or org (the organization's name). phone: "hide" drops the words below 640px

chrome.logoPhone

full | mark

full

mark: below 640px the header draws logoMark instead of the wide logoUrl

chrome.searchStyle.label

≤ 40 characters, or ""

"Search"

"": an icon-only pill (the glyph and the key hint) that sizes to its content — width becomes its maximum. Screen readers still hear "Search"

chrome.searchStyle.kbdStyle

caps | plain

caps

plain: the key hint as grey text, no keycap border

chrome.searchStyle.shrink

boolean

true

false: the pill keeps its width; the links fold into the menu first

chrome.searchStyle.iconFrame

none | circle | square

none

A 32px ring around the search: "icon" glyph

chrome.signInStyle.phone

menu | bar

menu (forums: bar when no button holds the bar)

bar keeps "Sign in" in the phone header row

chrome.sidebarToggle

true, or { placement, desktop, placementPhone }

off

desktop: "collapse": from 1024px the button folds the forum sidebar away (remembered per browser) and the list takes the width. On a phone page with no drawer the button opens the header menu

chrome.sidebarToggle.placementPhone

start | end

its placement

The drawer button's side of the PHONE row (below 768px): start before the brand, end after every other control. The wide row keeps placement. No layer default

chrome.header.menuButton.phoneSide

start | end

end

The ☰ menu button's side of the phone row; start puts it before the brand and its panel opens from the start edge. The wide row is unchanged. No layer default

chrome.subnav.endLinks

≤ 4 header items (dropdowns allowed)

none

Drawn at the END of the section row — "APIs & SDKs ▾ · Help ▾"

announcement.phone

full | lead

full

Below 768px, lead drops text and keeps lead and the button on one line

announcement.inset

boolean

true

false with span: "content" and placement: "below-header": the strip is FLUSH — the content column's full width, straight under the header (no gap), square. Read only for a content strip. No layer default

announcement.bleed

boolean

false

Round 6: with inset: false (a flush content strip), true runs it past the column's gutters — from the sidebar's border to the rail's border (with no rail beside it, the content area's edge), the edges of docs.pageHead.rule: "bleed". From 768px; a phone's strip is edge to edge anyway. No layer default

announcement.radius

integer px, 0–24

today's (8px; the chrome's copy rounds its foot only)

The content strip's corner radius from 768px; below that every strip is full-bleed and square. Read only for a content strip. No layer default

docs.sidebar.searchKbd

mod-k | slash | none

follows chrome.searchStyle.kbd

The rail search box's key hint

docs.sidebar.groupsOpen

all | current

each collection's nav.collapsed (current for a documentation tree of more than five groups)

current: every group closed but the one holding the page

docs.sidebar.footerPin

boolean

false

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.sidebar.groupLink

link | toggle

link (a collection heading opens its landing page)

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.hub.layout

cards | list

cards

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.hub.columns

1 | 2 | 3

2

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.hub.descriptions

boolean

true

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.hub.updated

boolean

true

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.hub.readingTime

boolean

true

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.hub.cover

boolean

true

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.hub.count

boolean

true

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.hub.breadcrumb

boolean

true

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.hub.pager

boolean

true

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.hub.children

groups | inline

groups

The rail's footerLinks pinned to the bottom of the full-height rail under a full-width rule

docs.mobileNav.scrollToActive

boolean

true

false: the phone drawer opens at its top instead of scrolled to the current page

docs.variantSwitcher.labelPlacement

inside | above

inside

above: the card switcher's label as a caption over it ("Select your SDK")

docs.variantSwitcher.show

auto | always

auto

always: on every page of its scope, not only pages with synced tabs

Fixes in the same round that need no key: a centred search (searchPlacement: "center") collapses to a search glyph on ONE phone row; the phone bar never shrinks the brand or the version pill (the search folds to a glyph first, then buttons move to the menu); linksAlign: "after-search" puts the links right after the pill under every frame; the drawer's head never scrolls away; the announcement adds no blank band on a phone.

Both phone-side keys only ORDER the phone row: which controls fold into the menu on a narrow screen is the same whichever side they sit on.

Mix and match: two headers no preset draws

An icon-only search in a ring, the org's name, a mark on phones, and the buttons before the toggle:

{
  "chrome": {
    "search": "icon",
    "searchStyle": { "label": "", "kbd": "mod-k", "kbdStyle": "plain", "iconFrame": "circle" },
    "brandText": { "show": "show", "source": "org", "phone": "hide" },
    "logoPhone": "mark",
    "header": { "order": ["ctas", "signIn", "search", "themeToggle", "menu"] }
  }
}

A forum header with a desktop sidebar fold, a site menu and sign-in kept on the phone bar:

{
  "chrome": {
    "sidebarToggle": { "placement": "start", "desktop": "collapse" },
    "header": { "menuButton": { "desktop": true, "phone": "always" } },
    "signInStyle": { "look": "outline", "phone": "bar" }
  }
}

A phone row with the menu first and the drawer button last, under a flush square-cornered notice on the content column:

{
  "chrome": {
    "sidebarToggle": { "placement": "start", "placementPhone": "end" },
    "header": { "menuButton": { "phoneSide": "start", "glyph": "two-bar" } }
  },
  "announcement": {
    "text": "v5 is out",
    "placement": "below-header",
    "span": "content",
    "inset": false,
    "radius": 4
  }
}

Who sees what: audience, the account menu and "My activity"

audience on every item you place

Every piece of reader furniture you place takes audience:

Value

Shown to

all (unset)

everyone — what every item did before the key existed

signedIn

a reader who is signed in

signedOut

a reader who is not

It is accepted on chrome.headerLinks[] (and their dropdown children[] and mega-menu column links), chrome.cta, chrome.secondaryCta, chrome.ctas[], chrome.topBar.links[], chrome.subnav.links[] / endLinks[], chrome.footerLinks[], chrome.footerColumns[] (the whole column) and their links[], announcement, the docs rail's docs.sidebar.anchors[] / footerLinks[], the forum sidebar's forum.sidebar.custom[], and every block of a page or front page (attrs.audience; in markdown {: audience=signedOut} after a block, or audience="signedIn" on a directive). In the console: the Audience select beside each header link, button and CTA, and in a block's hooks panel. In the text boxes for footer links and columns, add a third column: Pricing | /pricing | signed-out (a column heading takes it too: Members | signed-in).

The default for a sign-in or sign-up door is signedOut. A button whose action is signIn/signUp, a link to a sign-in or sign-up route (/login, /signup, /register, /join, …), and a button labelled like one ("Start free", "Sign up", "Log in", "Get started free", "Create an account") are shown to signed-out readers only unless you say otherwise — a signed-in reader is no longer shown "Start free" beside their own avatar. Set audience: "all" to keep such an item for everyone.

How it works, and why nothing flashes: reader pages are cached and shared, so the server renders every item with data-audience. A tiny script before first paint marks <html data-viewer="in"> or "out" from a readable fm_signed_in flag the API sets beside the session cookie (a flag only — no identity), and CSS hides what is not for this reader. The reader's own "who am I" request then confirms it and corrects a stale flag. This is visibility, not access control: the item is in the page's HTML either way. Content only some readers may read belongs in an audience block with a claim/condition (filtered on the server), not here.

Style hooks: html[data-viewer="in"] / html[data-viewer="out"] are stable, so your own CSS can key on them too.

chrome.accountMenu — the avatar's menu, on every surface

A signed-in reader's avatar opens the account menu on docs, blog, changelog, forum, sites and custom domains alike.

Key

Shape

Unset

What it does

chrome.accountMenu.items

array (1–16) of activity, profile, bookmarks, notifications, preferences, account, signOut, divider, or { "label", "href", "icon"?, "audience"? }

activity, profile, bookmarks, notifications, preferences, divider, account, signOut

The entries, in order. Your own links take the header menus' icon vocabulary (a glyph, a Lucide name or an image)

chrome.accountMenu.showName

boolean

false

The reader's name beside the avatar

chrome.accountMenu.showAvatar

boolean

true

false together with showName draws the name alone

chrome.accountMenu.bell

boolean

on a forum

The notifications bell beside the menu

activity ("My activity") and account ("Account & privacy") open /me on the current space. The forum entries — profile, bookmarks, notifications, preferences and the bell — open your organization's forum when the current space is not one (its first public forum), and are left out when you have none. Dividers collapse when the entries around them drop out.

"My activity" at <base>/me

Every reader surface serves /me — on our addresses, on a custom domain and under a path mount (acme.com/docs/me), and at a site's root. It is the reader's control center for what they made in your organization (every public space): posts and topics, replies and comments (forum, blog and changelog replies and docs page comments), reactions and votes, bookmarks, what they watch (topics, spaces, categories, tags) and their uploads — newest first, filtered by space, searchable, paged. Each row can be opened, edited (a reply, within your edit window), deleted, unreacted, unbookmarked or unwatched — one at a time or as a selection — under exactly the rules the single-item controls apply (a topic others replied to stays; your edit window and daily deletion limits hold); a row that cannot be changed says why.

"Account & privacy" (/me?tab=account) holds the reader's name, username and picture, a link to their notification preferences, their signed-in devices ("Sign out everywhere else"), a download of their activity here, and the ways out: delete all my content in this community (removing an account from a single organization is deliberately not offered — the account is the person's across every community) and erasing the whole account, which says plainly that it reaches every community. /me is reserved: a page or post with the slug me is answered by this page instead.

API: GET /public/orgs/{orgId}/me/activity?type=&spaceId=&q=&cursor=, POST /public/orgs/{orgId}/me/activity/bulk ({"action","ids"}, or {"action":"delete","all":true}), GET /public/orgs/{orgId}/me/export.

Mix and match

Signed-out readers see "Start free" and a sign-up announcement; members see "My activity" in the header and a members' notice; the account menu leads with a link to your own app:

{
  "announcement": {
    "text": "Members: the March AMA is live",
    "href": "/forum/ama",
    "audience": "signedIn"
  },
  "chrome": {
    "ctas": [
      { "label": "Start free", "href": "/signup" },
      { "label": "My activity", "href": "/me", "style": "outline", "audience": "signedIn" }
    ],
    "headerLinks": [
      { "label": "Pricing", "href": "/pricing", "audience": "signedOut" },
      { "label": "Docs", "href": "/docs" }
    ],
    "accountMenu": {
      "items": [
        { "label": "Open the app", "href": "https://app.example.com", "icon": "link" },
        "divider",
        "activity",
        "profile",
        "notifications",
        "account",
        "signOut"
      ],
      "showName": true
    }
  }
}

"Start free" needs no audience — it is a sign-up door, so it is already for signed-out readers only. Every key above is independent: set any one without the rest.

The language switcher: i18n.switcher

A space with more than one language draws a language switcher. Where it sits and how it looks is theme.i18n.switcher. Every key is independent, and each one resolves in this order:

  1. the space's own theme;

  2. its site's theme;

  3. the organization's default: Settings → Language switcher, or PUT /orgs/{orgId}/language-switcher with the same keys;

  4. what the reader did before the key existed.

Set the switcher once for the organization, and change one key on one space when that space needs something different.

Key

Values

Unset

enabled

false hides it (same as placement: "none")

shown

placement

header (nav bar, end) · header-start (after the brand) · header-overflow (the header's menu) · sidebar · footer · content-top · floating (a corner control) · none

docs: sidebar; every other type: content-top

style

select (a dropdown) · pills (a row of links) · compact (a tight row of codes: EN · ES)

select

names

native (Español) · english (Spanish) · code (ES)

native (code with compact)

flags

true puts a flag before each name

off

flagCountries

{ "en": "US", "pt-BR": "BR" }: the country whose flag stands for a language

the region in the tag, else the usual country

locales

["es", "default", "fr"]: which languages are listed, in this order. default is the original

every declared language, in the order you declared them

customLabels

{ "default": "English (US)", "es": "Español" }: a label per code; overrides names

—

missing

when this page has no translation in a language: home (link to that language's home page, marked "not translated") · disabled (listed but not a link) · hide (list only the languages this page is translated into)

home

remember

true: a later page in another language shows a small notice offering that page in the reader's chosen language

off

{
  "i18n": {
    "switcher": {
      "placement": "header",
      "style": "compact",
      "flags": true,
      "flagCountries": { "default": "US" },
      "locales": ["default", "es"],
      "missing": "hide",
      "remember": true
    }
  }
}

The page's address sets its language. /docs/es/webhooks is a Spanish page and /docs/webhooks is a page in the space's own language. Everything around the content follows the address: the header, the account menu, dialogs, dates and toasts. A reader's earlier choice, their account language and their browser language never change it. remember only offers a link to the translation. It never switches the page. That is also why the page can be cached by its URL.

The header placements, footer and floating are rendered on the server, in place, so the switcher never moves after the page loads. floating and the remember notice are fixed-position, so neither shifts the layout. Every language is a real link with hreflang. The current one has aria-current="page". A disabled one has aria-disabled="true". The dropdown supports the arrow keys, Home and End, typing a letter to jump, and Escape.

In the studio, double-click the switcher to edit these keys in the inspector. To restyle it, use the hooks [data-fx-language-switcher], [data-fx-language-option] (one language in a row), [data-lang-flag] and [data-fx-language-suggest] (the remember notice), or element styles on any of them. [data-language-placement] tells you where it is drawn. theme.docs.localeSwitcher (the older docs-only key) is still read: its placement and style apply where i18n.switcher leaves them unset.

Theme components are a copy, not a layer

POST …/theme/apply resolves the component and deep-merges the result into the space's live theme, once. There is no ongoing link:

  • editing the component afterwards changes nothing in the spaces that wore it — apply it again, per space,

  • deleting it leaves those spaces exactly as they look,

  • GET …/theme on the space shows the applied values as if you had typed them, because at that point you did.

Where components do compose is among themselves, before the copy is taken. A component may have children (parentThemeId), and GET /orgs/{orgId}/theme-components/{id}/composite is the parent with its enabled children layered on in creation order — the thing apply actually copies. In that resolution most fields are last-wins, but CSS is additive:

customCss

concatenated with a newline, child later — so the child wins the cascade

cssSnippets, elementStyles

concatenated

settings, settingsSchema

merged by key, child winning

everything else

child replaces

So a design system that has to stay in step across many spaces is a site theme (see Sites), which is a live layer. A component is for handing a look to a space once, exporting it, or publishing it to the registry.

The eight built-in presets

POST …/theme/apply with a presetId instead of a componentId applies one of ours. Each is a partial theme of schema fields only — colours, typeface, scheme, width, and for four of them a full palette. No customCss, so applying one is safe anywhere and cannot bring markup assumptions with it.

presetId

Name

Scheme

Type

Width

What it is

classic

Classic

system

sans

wide

Indigo accent on a light page. The neutral.

midnight

Midnight

dark

sans

wide

Always dark, violet glow. Full palette.

editorial

Editorial

light

serif

standard

Serif long-form, warm red. Full palette.

terminal

Terminal

dark

mono

wide

Monospace on black, phosphor green.

sunrise

Sunrise

light

sans

full

Warm coral, edge to edge.

slate

Slate

system

sans

standard

Muted blue-grey; gets out of the way.

forest

Forest

light

sans

wide

Deep green on a light page.

grape

Grape

system

sans

wide

Purple with a magenta accent.

A preset is a merge, like a component: anything it does not mention — your logo, your customCss, your chrome — survives. An unknown presetId is a 404.

Note the contentWidth column. Six of the eight set wide or full, so applying one changes your layout as well as your colours. If that is not what you wanted, set Content width back in the studio's Look panel — choose Default to remove the key entirely — or patch contentWidth over the API. Neither the preset nor the width touches the "on this page" rail; that is onThisPage, the On this page picker beside it.

Every theme endpoint

The theming guide documents four. There are fourteen, plus the three origin endpoints under Asking what a host actually allows.

Call

What

GET PUT PATCH /orgs/{orgId}/spaces/{spaceId}/theme

The live theme.

POST /orgs/{orgId}/spaces/{spaceId}/theme/apply

Apply a named theme wholesale.

GET PUT PATCH DELETE /orgs/{orgId}/spaces/{spaceId}/theme/draft

A draft — see Draft a theme, and preview it.

POST /orgs/{orgId}/spaces/{spaceId}/theme/draft/publish

Publish the draft. 409 if the live theme moved.

GET PUT /orgs/{orgId}/spaces/{spaceId}/reader-themes

Alternates a reader may pick.

GET POST /orgs/{orgId}/theme-components

Reusable theme fragments.

POST /orgs/{orgId}/theme-components/import

Import one.

PATCH DELETE /orgs/{orgId}/theme-components/{id}

Edit, remove.

GET /orgs/{orgId}/theme-components/{id}/composite

The component resolved against its parents.

GET /orgs/{orgId}/theme-components/{id}/export

Export it.

GET /orgs/{orgId}/theme-registry

Themes published for installation.

POST /orgs/{orgId}/theme-registry/publish

Publish one.

POST /orgs/{orgId}/theme-registry/{slug}/install

Install one.

DELETE /orgs/{orgId}/theme-registry/{slug}

Unpublish.

The live-theme answers (GET, PUT and PATCH …/theme) are { "theme": …, "warnings": [] }. warnings lists what the stored theme says that readers will not see — today, a motion.targets entry whose animation no longer exists because the site removed that keyframe (code: "motion.unknown_animation", with the key). A write that would introduce such a reference is a 422 naming the key and the valid names instead; see Motion in the theming guide. It also lists colour pairs your palette / paletteDark sets that fall under WCAG AA contrast in light or dark mode (code: "contrast.low", key = the slot to change) — the visual editor's own check, as a warning; the save still succeeds. The same check covers primaryColor (3:1 on the page), components.link.color/colorDark (4.5:1 on background and surface) and elementStyles rules that set both a color and a background. Site theme saves (POST/PATCH …/sites) and front-page saves (PUT …/front-page, which also measures each feed's card colours and moreButton.color) answer the same warnings as contrastWarnings.

Draft, then publish, rather than editing the live theme while readers are on it. It is one more call and it is the difference between a preview and an incident.

Analytics tags, and the custom <head>

Not a theme key and not in the theming guide at all, which is why nobody found it:

GET PUT /orgs/{orgId}/spaces/{spaceId}/analytics-tags
{
  "ga4": "G-XXXXXXX",
  "gtm": "GTM-XXXXXX",
  "segment": "<write key>",
  "intercom": "<app id>",
  "customHead": "<meta name=\"google-site-verification\" content=\"…\">"
}

Keys merge: a key you omit keeps its stored value, and "" clears it. customHead is limited to 20,000 characters.

IDs are format-checked, because they are pasted into inline scripts: ga4 is G- plus 4–20 uppercase letters/digits, gtm is GTM- plus 4–12, segment is an 8–64 character alphanumeric write key and intercom a 6–16 character lowercase app id. Anything else is a 422; a malformed value stored before this check is ignored by the reader. It is a space-level setting, so every reader surface of the space gets it — not only the docs pages.

Plausible, Fathom, Umami, Matomo, Simple Analytics and PostHog use the same route, and the organization can set defaults for every space. See Analytics on your reader.

What customHead can contain, and where each part lands

customHead is parsed and emitted per tag, not pasted into the page as one blob:

In your snippet

Where it goes

Which hosts

<meta name=… content=…>

a real <meta> in the document head

every host

<link rel=… href=…>

a real <link> in the document head

every host

<script src=…>

a loaded script

verified custom domain only

<script>…</script> or bare JS

one inline script

verified custom domain only

<style>, <base>, <iframe>, <object>, <embed>

dropped

—

A snippet containing no tag at all is treated as plain JavaScript, which is what every snippet written before this was.

The snippet is split on the server. On a host where its scripts are not emitted, the script source is not sent to the browser either — it is absent from the page's HTML and from its serialised page data, not merely left unexecuted.

Three refusals worth knowing, because each one used to be a silent pass:

  • http-equiv on a <meta> is dropped — that is how a meta sets a CSP or a refresh redirect, which is control of the page rather than head content.

  • javascript: and data: in a href or src are dropped; a href must be https://, // or a rooted path.

  • rel="preload", rel="modulepreload" and rel="import" are dropped: they load code.

Event attributes (onload=…) and anything else not on the small allowlist are stripped from the tags that are kept.

This used to emit the whole snippet as the body of one inline <script>. So a verification meta, a stylesheet link and a <script src> — the three things anybody actually puts in a head — became a syntax error inside a script and vanished without a word.

Tag

Category

Can a reader refuse it?

ga4, gtm, segment, and the privacy-first vendors

analytics

Yes. Nothing loads before a grant.

intercom

functional

No — see below.

customHead scripts

functional

No — see below.

customHead meta and link tags

none

No. They are inert and always emitted.

customJs (a theme key)

functional

No — see below.

<script src> in nav panel / footer HTML

functional

No — see below.

functional is a tier a reader cannot decline. It covers the things a site needs in order to work the way the reader asked — their language, their theme, the support channel they are about to use — and no consent UI offers it as a choice, so it is granted from the first paint. These docs used to say customJs was "behind functional-tier cookie consent", which read as "nothing runs until a banner is answered". It does not: on a reader surface it runs immediately, and that is deliberate.

What actually holds a script back is therefore the host, not consent:

  • analytics tags wait for a real grant, on any host.

  • Executable customHead, customJs and intercom run only on a verified custom domain. On forumulate.com and on a *.forumulate.com subdomain they are not emitted at all — our origin serves /api same-origin, so an owner's script there could call it as the logged-in reader. If you tested a customHead script on a platform subdomain and saw nothing, this is why, and no setting changes it.

  • There is no exception. customJs used to run on every host; it now follows the same rule. Its window.__theme_settings line is our data, not your script, and is still emitted everywhere.

  • An external <script src> in a nav item's panel.html or in chrome.footer.html follows the same rule: loaded once per page, with the nonce, on a verified custom domain only, and its origin is allowed for you. Inline code and on… handlers in that HTML never run, on any host.

The same rule covers scriptOrigins, ad networks and chat-widget connectors. Where owner and vendor scripts run in the theming guide puts all of them in one table.

Seeing the allowlist a reader gets

The reader's Content Security Policy is built from two public lists, and you can read both without a token to check a vendor or an API is on them. Each answers { "origins": [...] }, and only for a public space:

  • GET /public/orgs/{orgId}/spaces/{spaceId}/script-origins: every origin the space's pages may load a script from. It is the union of theme.scriptOrigins (up to 20), the chat widgets of enabled connectors, your consent manager and analytics tags, and the external <script src> origins in your header panels, footer HTML and theme components.

  • GET /public/orgs/{orgId}/spaces/{spaceId}/api-origins: every origin the space's API reference may send requests to (the CSP connect-src, and the servers the "Try it" proxy accepts). It is the union of theme.apiOrigins, the servers your imported specs name, and the servers named by the openapi blocks of published pages, https:// origins only.

If a widget paints but never loads, or "Try it" is refused, the origin it needs is missing from one of these.

Every public page already has an Open Graph image — social-card.png, generated per page. seo.socialImageUrl replaces it with a picture of your own, which is all-or-nothing: either hand-build a PNG per page, or accept our dark slate. seo.socialCard is the middle: the same card, in your colours, with your logo and your typeface. seo.twitterSite ("@acme", with or without the @; "" clears it) is published as twitter:site on every page of the space, so a card shared to X is credited to your account.

Site-wide <meta> and <link> tags (verification tokens, theme-color, …) are not an SEO key: put them in the analytics door's customHead (above), whose meta and link tags render on every host.

The other seo keys

PUT …/seo merges key by key. Besides the card keys above:

Key

Meaning

description

The space's own description, the fallback for every page that has none.

noindex

true keeps the whole space out of search engines and the sitemap.

siteName

The name <title> and og:site_name carry. Unset uses the brand (theme.brandName), then the organization — but brandName is the header lockup, so a lockup like "/ docs" should come with an explicit siteName. "" clears it.

titleTemplate

The title pattern, %s exactly once where the page title goes: "%s · Strata", "Strata — %s". Unset is <page> · <siteName>. A title that would run past 60 characters stays the bare page title.

indexHidden

false keeps pages hidden from the navigation (metadata.hideFromNav) out of search: no sitemap entry, no tag-hub entry, noindex on the page. Unset indexes them (they are published, only not listed).

twitterSite, verification, contentLanguage, socialImageUrl

As described above and in the console's Search & sharing panel.

The cards, uploads (/api/assets/…) and proxied content images are open to crawlers in robots.txt on the platform address too; the rest of /api/ stays disallowed.

PUT /orgs/{orgId}/spaces/{spaceId}/seo
{
  "socialCard": {
    "background": "#0B1020",
    "backgroundTo": "#141B36",
    "accent": "#6E56CF",
    "titleColor": "#F5F6FA",
    "font": "serif",
    "logoUrl": "https://cdn.acme.test/wordmark.png",
    "footer": "acme.com",
    "accentBar": true
  }
}

Key

What it is

accent

The left stripe, and the eyebrow unless eyebrowColor says otherwise. Defaults to the space's theme.primaryColor, then to ours.

background

The card background. Flat on its own.

backgroundTo

With background, makes it a diagonal gradient.

titleColor

The headline colour.

eyebrowColor

The small label above the headline.

font

sans | serif | mono — set in DejaVu Sans, DejaVu Serif and DejaVu Sans Mono.

logoUrl

Drawn top-right, scaled into a 180×72 box. "" removes it.

footer

Replaces the credit line, ≤ 80 characters. "" removes the line.

accentBar

false removes the stripe down the left edge.

Colours are 6-digit hex (#4F46E5), the same rule a theme colour follows. Every key is optional and each falls back to the card as it has always rendered, so a space that sets none of this gets the image it got yesterday. The keys merge on write: setting accent next week will not drop the logoUrl you set today.

logoUrl is fetched server-side and embedded in the image, because the PNG every scraper reads is rasterised with no network — a remote href would render in a browser looking at the SVG and be missing from the PNG that actually gets shared. So: an https URL that is publicly reachable, or a path from your asset library (/api/assets/<assetId>, read straight from storage — it must be an upload of this organization's, in a public space or none), or an inline data: raster; a raster or SVG image, under 512 KB. Any other path is refused, because the card has nowhere to resolve it. If the logo cannot be read the card still renders, without it.

theme.whiteLabel: true removes the credit line regardless, as it does everywhere else. footer: "" removes just the line without claiming white-label.

The template applies to every generated card: the space card, blog posts, forum topics, roadmap items, changelog releases, docs pages and course lessons. A docs page with no template keeps its own card design; once a template is set, its card is the templated one.

When a change shows up. Each generated card's URL carries a fingerprint of its look — social-card.png?v=<fingerprint>, built from socialCard, theme.primaryColor and theme.whiteLabel. Change any of them and the next page render advertises a new URL, so nothing — our edge, your CDN, or a scraper's own image cache — can keep serving the old card under it. Saving the template evicts the reader caches the same way a theme save does, so the new URL is in the page head within seconds. Links that were already shared keep the image the network captured at the time; most networks let you re-scrape a URL (Facebook's Sharing Debugger, LinkedIn's Post Inspector) to pick up the new page head.

Which CSS variables you may rely on

Stable: target these

The stable list lives in one place: CSS variables worth targeting in the theming guide. The theme's names on it are --reader-*, --font-* or --setting-*, scoped to .forumulate-reader; the rest are the layout variables a block or a styled section publishes on its own element. Those are the names we keep working. A variable is only emitted for a key you set, which is what lets a site theme and a space theme stack.

This page used to carry a second copy of that list, and the two disagreed about whether --bg and --signal were stable. They are not. Both guides now say the same thing: the published names are stable and the names below are internal.

Internal — do not target these

--bg, --surface, --panel, --ink, --ink-3, --muted, --line, --line-strong, --line-control, --signal, --signal-fg, --signal-wash, --accent, --accent-wash, --grid, --hl-comment, --hl-keyword, --hl-string, --hl-number, --hl-title, --hl-builtin, --hl-tag, --hl-attr, --hl-meta, --hl-emphasis.

These are the names our own stylesheet reads. Some of them a theme does write — palette.background and palette.surface set --bg and --surface as well as their published --reader-* names, because the only way to repaint them used to be to guess the internal name. Reading one from customCss works today; targeting one is a private contract you lose on our next refactor, and --line-control is not written by a theme at all.

The ten --hl-* variables are our shipped syntax palette, and they are the clearest case of why this list exists: they were the only way to recolour code highlighting, they are declared three times (light, the OS dark query, and a pinned [data-theme='dark']), and overriding one from customCss means overriding it in all three places or getting it in one mode only. The published names are --reader-syntax-<group> and the theme key is components.syntax / components.syntaxDark, which gets the mode gates right for you. Each .hljs-* rule now reads the published token with the --hl-* one as its fallback, so a theme that sets nothing highlights code exactly as before.

If a published variable does not paint something you need, that is worth telling us — it is a missing token, not a reason to reach for an internal name.

Which SELECTORS you may rely on — the fx-1 hook set

The variables above are a contract about colour. This is the contract about structure: the selectors that reach the parts of a reader page, so that customCss above the colour level does not have to be written against our Tailwind utility classes.

It needed saying. The header was <header class="border-b">, the brand was header a.mr-auto > span, the CTA was rounded-md bg-brand text-white and the light/dark control was [role=group][aria-label=Theme] — a selector that stops matching the moment the label is translated. A customer matched their product with about 3 KB of CSS written against names like those, and they were right to say it would break on our next refactor.

The promise. The names in this section do not change. We will add to the set; we will not rename or remove a name in it. If one ever has to break, it breaks as a new version (fx-2) published alongside fx-1, not in place of it. Everything not in this section — our Tailwind classes, our element nesting, the order of children — is an internal detail and does change.

Every hook is inside .forumulate-reader, so scope your rules with it if you want reader-only effect. That class is on exactly one element per page — the reader shell — which is what makes .forumulate-reader > div a selector with a single meaning.

The chrome and the page

Hook

The element

[data-fx-header]

The header bar. Also what palette.header_background paints

[data-fx-nav]

The row inside the header that holds the brand, the links and the CTA

[data-fx-logo]

The whole brand lockup — mark, wordmark image and words — as one link

[data-fx-brand-name]

The words inside the lockup (theme.brandName)

[data-fx-cta]

The header's call-to-action button (chrome.cta)

[data-fx-cta-secondary]

The header's second button (chrome.secondaryCta), beside the first in every copy of the header

[data-fx-nav-item]

One top-level header item (chrome.headerLinks[]), plain or with a menu. An open one carries [data-open="true"]

[data-fx-nav-panel]

The dropdown, mega menu or HTML panel an item opens; on a phone, the accordion body

[data-fx-nav-link]

A link inside a panel (a dropdown entry, a column link, a panel footer link)

[data-fx-nav-featured]

A mega menu's image card (panel.featured)

[data-fx-theme-toggle]

The light/dark control, in all three chrome.themeToggle.style shapes

[data-fx-main]

The wrapper around the page itself, between header and footer

[data-fx-footer]

The footer

[data-fx-footer-col]

One footer column (chrome.footerColumns[])

[data-fx-cookie-settings]

The consent control, wherever chrome.footer.cookieLinkPlacement puts it

[data-fx-footer-html]

Your own footer markup (chrome.footer.html), sanitised, with its CSS scoped to it

[data-fx-footer-newsletter]

The footer's newsletter form (chrome.footer.newsletter); its consent note is [data-newsletter-consent]

[data-fx-sidebar]

The docs reader's left rail

[data-fx-sidebar-logo]

The brand lockup at the top of that rail (chrome.sidebarBrand)

[data-fx-search]

The search box in that rail

[data-fx-select]

Any reader dropdown's box — a filter, a form field, the theme switcher, a version or language picker (components.select)

[data-fx-select-menu]

A dropdown's open list (it floats above the page; components.popover paints it)

[data-fx-select-option]

One option in that list — [aria-selected="true"] is the chosen one, [data-active] the highlighted one

[data-fx-language-switcher]

A language switcher's box, wherever i18n.switcher.placement puts it

[data-fx-language-option]

One language in a switcher drawn as a row (style: pills or compact); [aria-current="page"] is the page's own

[data-fx-language-suggest]

The i18n.switcher.remember notice offering this page in the reader's language

[data-fx-aside]

The on-this-page rail on the right

[data-fx-article]

The article body on a docs-style page — this is where you set a width

[data-fx-page-title]

The page's <h1>

[data-fx-outlet]

A theme component outlet a component has filled; [data-outlet='<name>'] says which

[data-fx-component]

One theme component's markup inside an outlet; [data-component='<slug>'] says which

[data-fx-top-bar]

The utility row above the header (chrome.topBar)

[data-fx-subnav]

The section row below the header (chrome.subnav)

[data-fx-nav-divider]

The rule headerLinks[].divider draws before an item

[data-fx-header-search]

The header's search control (chrome.search)

[data-header-group]

With chrome.searchPlacement: "center": the header row's three groups, ='start' (brand and links), ='mid' (search), ='end' (controls). display: contents below 768px

[data-phone-bar-link]

A headerLinks[].phone: "bar" link in the phone row

[data-nl-ink], [data-nl-line]

A header link with its own ink / border (--nl-ink, --nl-ink-dark, --nl-line, --nl-line-dark)

[data-phone-fold], [data-phone-unfold]

A pinned button that leaves the phone row below 420px, and its copy in the menu panel

[data-forum-menu-slot]

The empty slot in the header's menu panel the forum sidebar folds into

[data-ann-frame]

The frame around an announcement.span: "content" strip

[data-fx-footer-edge]

The shape along the footer's top (chrome.footer.background.edge)

[data-fx-top-bar-tab]

The tab a chrome.topBar.style: "tab" bar draws its links in

[data-fx-header-title]

The post title a header shows once the title scrolls away (chrome.header.scrollTitle); the header carries [data-header-title-shown] while it shows

[data-fx-brand-prefix]

The brand name's first part in its own ink (chrome.header.brandPrefix)

[data-fx-cta-divider]

The rule between the controls and the buttons (chrome.header.ctaDivider)

[data-fx-top-bar-label]

A utility-bar link's words (chrome.topBar.links[]); topBar.phone: "icons" hides them visually on a phone

[data-fx-footer-heading]

A footer column's heading (chrome.footer.headingInk / headingFont / headingSize)

[data-fx-footer-brand]

The footer's brand block — lockup, description, social marks (chrome.footer.brandPlacement)

[data-fx-footer-status]

The footer's live status pill (chrome.footer.status); [data-status] is up, degraded, down, …

[data-fx-index-header]

The generated title bar above an index; [data-fx-index-title] is its heading (layout.indexHeader)

[data-fx-lede]

A standfirst under a title (typography.levels.lede)

[data-fx-entry-title]

An entry's title in an index (typography.levels.entryTitle)

[data-fx-topic-title]

A forum topic's title (typography.levels.topicTitle)

[data-fx-meta]

A metadata row — date, byline, reading time (components.meta)

[data-fx-footer-art]

The footer's art band (chrome.footer.art); [data-art] is preset or image, [data-fade] its fade

[data-fx-footer-crumbs]

The footer's breadcrumb row (chrome.footer.breadcrumb); each crumb is [data-footer-crumb] (mark, home, page)

[data-fx-docs-drawer]

The docs rail when theme.docs.mobileNav.style is drawer; [data-drawer-ready] once it is a sheet, html[data-docs-drawer-open] while open

[data-fx-docs-sections]

The docs section switcher (theme.docs.sections, sidebar placement); [data-dn-sec-style] names its style

[data-fx-header-slot]

An empty slot in the header for page-scoped controls: [data-fx-header-slot='brand'] after the brand, [data-fx-header-slot='end'] first in the controls. The docs version and locale switchers portal into them

[data-fx-ask-entry]

An Ask AI launcher outside the floating button (theme.docs.ask.placement); [data-ask-ai-entry] is header or sidebar

[data-fx-header-crumb]

The docs section after the brand (chrome.header.sectionCrumb); [data-sep] holds the separator

[data-fx-status-banner]

A status page's overall banner (theme.status.banner); [data-status-overall] is up, degraded, maintenance, down or unknown

[data-fx-status-group]

A status page's component group ([data-component-group] holds its name)

[data-fx-status-row]

One component row; [data-component-status] on its state word

[data-fx-status-strip]

A component's day strip (theme.status.bars)

[data-fx-status-bar]

One day in the strip; [data-day] is ok, partial, bad or none

[data-fx-status-legend]

The strip's legend (theme.status.legend)

[data-fx-status-incident]

An open incident's card (theme.status.incidents)

[data-fx-status-maintenance]

A scheduled maintenance card

[data-fx-status-subscribe]

A status page's subscribe band

[data-fx-status-kpis]

The KPI tiles band (spaceBand{band:"kpis"}); each tile is [data-kpi]

[data-fx-course-hero]

A course landing's hero (theme.courses.landing); [data-course-hero] is the layout

[data-fx-course-outline]

A course's outline; [data-outline-style] is theme.courses.outline.style, [data-course-outline] is page or rail

[data-fx-course-lesson-row]

One lesson in the outline; [data-active] on the lesson being read

[data-fx-course-sidebar]

A lesson's curriculum rail (theme.courses.lesson.sidebar)

[data-fx-course-progress]

A course progress bar; [data-progress-style] is theme.courses.progress.style

[data-fx-course-end-card]

The end-of-lesson "Next up" card

[data-fx-course-certificate]

A completion certificate; [data-certificate-layout] is theme.courses.certificate.layout

[data-fx-faq-item]

One FAQ question with its answer (theme.faq.item); [data-page-id] names the page, [open] while open

[data-fx-faq-question]

A question's row (the <summary>)

[data-fx-faq-answer]

A question's answer, with its feedback row

[data-fx-faq-section]

A FAQ section; [data-faq-section] on its heading holds the name

[data-fx-faq-search]

The FAQ search box, in the hero or inline (theme.faq.search)

[data-fx-faq-category-card]

A FAQ category card (theme.faq.categories)

| [data-fx-legal-row] | One legal register row (list, split or card) | | [data-fx-legal-facts] | The facts line under a legal document's title (effective, updated, edition) | | [data-fx-legal-kicker] | The small line above a legal document's title (theme.legal.head.kicker) | | [data-fx-blog-hub-head] | A blog hub's head (back link, eyebrow, title, counts) on archive, tag, author, series and search pages | [data-fx-blog-hub-list] | A blog hub's own list of posts | [data-fx-blog-post-marks] | The claps and restack row under a post (theme.post.marks) | [data-fx-blog-post-sponsor] | A post's sponsor card (theme.post.sponsor) | [data-fx-helpful-prompt] | The "Was this helpful?" prompt under a post or a release; [data-helpful-thanks] once answered | [data-fx-blog-archive-calendar] | The archive's month calendar (theme.blog.archive.calendar); [data-archive-calendar] holds the month | [data-fx-blog-membership-offer] | A blog's membership offer: plans and the checkout button (/membership, paid posts)

The interactive blocks

The interactive content blocks carry their own hooks, on every page that uses them:

Hook

The element

[data-fx-carousel]

A carousel (also a testimonials carousel). Layout: --car-per, --car-per-md, --car-per-sm, --car-gap

[data-fx-carousel-slide]

One slide

[data-fx-gallery]

A gallery grid. Layout: --gal-cols, --gal-gap, --gal-aspect

[data-fx-lightbox]

The full-screen picture viewer (a <dialog>)

[data-fx-pricing]

A pricing block, cards or table. Layout: --pr-cols

[data-fx-pricing-plan]

One plan card; the recommended one also carries [data-highlighted]

[data-fx-testimonials]

A testimonials block. Layout: --tm-cols

[data-fx-testimonial]

One quote

[data-fx-logo-wall]

A logo wall. Layout: --logo-cols, --logo-h

[data-fx-stats]

A stats block. Layout: --st-cols

[data-fx-stat]

One number with its label

[data-fx-marquee]

A marquee (also a scrolling logo wall's row). --mq-duration, --mq-gap

[data-fx-modal]

A button-with-dialog; the dialog is .doc-modal-dialog inside it

[data-fx-compare]

A before/after slider. --cmp is the divider's position

[data-fx-layer]

A floating layer (a picture or a giant word). --ly-x, --ly-y, --ly-w, --ly-rot, --ly-rate

[data-fx-story]

A sticky story. --story-top is where its picture sticks

[data-fx-story-step]

One step of a story; the one being read carries [data-active]

[data-fx-playground]

The fallback picture of a code demo (demos do not run yet)

[data-fx-hero]

A hero block; [data-layout] is center, split or below. --hero-title-size, --hero-measure

[data-fx-person]

A person card; [data-layout] is card or row. --person-photo is the photo's size

[data-fx-office-map]

An office map; [data-cols] is its cards to a row

[data-fx-office]

One place in an office map: its map, then its name, address and note

[data-fx-map]

A street map (office map, venue); [data-aspect] is wide, landscape or square

They take the same promise as the rest of fx-1, and every name above is also a theme.motion.targets key, so an owner can animate a stat or a plan card the way they animate the header.

Every type's pages: the type stamp and the shared parts

Each reader page's own root says what it is, the same way on every type with reader pages, so one rule can reach "every status history page" or "every item page":

Attribute

Value

[data-reader-type]

docs, forum, blog, roadmap, changelog, status, legal, faq, careers, contact, events, courses, custom

[data-reader-surface]

index, item, sub, hub, category, account, search or embed

[data-reader-page]

Which sub-page or hub (history, agenda, tag, archive…), where there is one

[data-reader-item]

The id of the one item on an item page (announcement.pages matches it)

[data-reader-section]

The section the page belongs to (chrome.header.sectionCrumb shows it)

[data-reader-type='status'][data-reader-surface='sub'][data-reader-page='history'] [data-fx-section-title] {
  letter-spacing: 0.08em;
}

The parts every type's pages share:

Hook

The element

[data-fx-section]

A titled region of a page

[data-fx-section-title]

Its heading — any heading outside the article; [data-type-level] says which level of the type scale it takes

[data-fx-entry-list]

A list page's list

[data-fx-entry]

One row or card in it; its title is [data-fx-entry-title] (typography.levels.entryTitle)

[data-fx-panel]

A boxed card or panel (components.radius and components.shadow reach it)

[data-fx-cover]

A cover or hero picture (components.image.radius)

[data-fx-empty]

An empty state

[data-fx-pager]

Previous/next or page-number navigation

[data-fx-filters]

A filter or sort row above a list

[data-fx-tabs]

A row of view tabs

[data-fx-actions]

An item's row of buttons (subscribe, RSVP, apply, share)

[data-fx-ad-slot]

Your ad (ads)

The front page

A composed front page's live blocks carry these, on the space's front page, a site's front door and anywhere else a feed sits:

Hook

The element

[data-fx-feed]

A spaceFeed or spaceCategories block's wrapper. [data-feed-layout] says its layout, [data-feed-type] the type of the space a feed reads, [data-feed-categories] a category rail

[data-fx-feed-item]

One card in it; a featured item also carries [data-feed-featured]

Both are theme.motion.targets keys (feed, feed-item), so a feed's cards can reveal as the reader scrolls to them.

You rarely need a hook for that, though: theme.motion.reveal.targets: ["everything"] (the recommended choice, and what every motion preset uses) reveals every top-level block of every body and every entry of every index page — feed cards, topic rows, forum posts, releases, roadmap cards, events, roles, lessons, FAQ questions, status groups — in reading order, staggered. The narrower targets (code, tables, media, embeds, quotes, tabs, steps, accordions, buttons, sections, items, …) and the block types each one covers are listed under Motion in the theming guide. Your own CSS can read the reveal's state from these attributes:

Attribute

Means

html[data-reader-motion]

Reveals are armed (the motion script ran); before it, and without script, nothing is hidden

[data-motion-in]

This block or entry has revealed (or was on screen when the page opened)

[data-motion-part]

A piece of a block taller than the screen, which reveals piece by piece instead of as a whole

A blog post

The parts a post template (theme.post) adds to a blog post. A post with no template renders none of them.

Hook

The element

[data-fx-masthead]

The band behind the post's head (post.masthead)

[data-fx-breadcrumb]

The trail above the post (post.breadcrumb: trail)

[data-fx-kicker]

The line above the title (post.header.kicker)

[data-fx-meta]

The post's byline row, and a date or read time placed on its own (components.meta)

[data-fx-lede]

The dek under the title (post.header.showStandfirst)

[data-fx-byline]

The post's authors, wherever they are placed

[data-fx-post-rail]

The rail beside the words (post.rail)

[data-share-links]

The share control (not an fx hook: it predates them). [data-share-style], [data-share-frame] and [data-share-position] say how, in what and where

[data-fx-author-card]

The "Written by" cards (post.authorCard)

[data-fx-series-nav]

The series navigation (post.seriesNav)

[data-fx-suggested]

More to read as a live feed (post.suggested.style: thumbs|cards)

Each fx hook here is also a theme.motion.targets key.

Three hooks that are classes, not attributes

Our own stylesheet already names these by class, on every one of them, in @layer components. A second attribute beside each would be two contracts for one element — and the card block is rendered by a shared package, so it is not even ours to attribute. The class is the hook, with the same promise and the same version:

Hook

Selector

button

.forumulate-reader .btn, .btn-primary, .btn-ghost

input

.forumulate-reader .input

card

.forumulate-reader .card (content cards), .surface (panels)

Already in the markup, and also stable

These predate the set and are in it: [data-space-chrome], [data-site-chrome], [data-reader-controls] (anything that disappears in print), [data-docs-layout], [data-reader-aside], [data-mobile-nav], [data-space-menu], [data-page-actions], [data-copy-page], [data-announcement-banner], [data-reader-consent-footer], [data-changelog-layout], [data-article-layout], [data-page-embed], [data-block-t] (one per content block type), [data-badge-style], [data-rss], [data-release], [data-reader-measure] (every long-form element that keeps to layout.measure: a legal document's article, a blog post, release or roadmap item body), and the classes .forumulate-reader, .forumulate-prose, .page-actions-btn, .announcement-banner, .reader-nav-disclosure, .plate-label, .cf-code, .cf-copy.

View-transition names

With theme.motion.pageTransition set, a move between reader pages runs inside a native view transition where the browser supports one, and for its duration the main region ([data-fx-main]) is named reader-main and the header ([data-fx-header]) reader-header. Those two names are part of the same stable set:

::view-transition-new(reader-main) {
  animation-duration: 600ms;
}

The header group is pinned (no animation); the rest of the page is root. Put data-no-view-transition on an element to keep the links inside it out of this. See Motion in THEMING.md for exactly when it runs.

Hooks you put on a block yourself

The hooks above reach OUR chrome. To reach one particular block of YOUR page, give the block its own name. Every block takes three optional attributes, in the editor (the # beside the drag handle, or a section's Style popover), in the API's block JSON and on any block that has a markdown directive:

Attribute

Rendered as

Rule

anchor

the element's id → #pricing

a lowercase slug, unique on the page (see below)

className

class="u-hero u-wide"

up to four lowercase names, each published as u-…

hook

data-fx-block="hero"

a lowercase slug

.forumulate-reader [data-fx-block='hero'] h2 {
  letter-spacing: -0.02em;
}
.forumulate-reader .u-wide {
  max-width: none;
}

u- is a namespace we will never use for a class of our own, so a name you pick cannot collide with ours, now or later. An anchor is unique per page: a second block with the same anchor, or a heading whose text slugs to it, gets -2 appended, exactly as two headings with the same words do. An anchor on a heading replaces the heading's own slug (and the link beside it follows); on a section it replaces the section key as its id. The on-this-page rail reads whichever id the element ends up with.

A styled section also publishes its values as custom properties you can move from customCss without !important: --sec-pt, --sec-pb, --sec-px, --sec-min-h, --sec-radius, --sec-border, --sec-bg-color, --sec-bg-image, --sec-bg-size, --sec-bg-position, and the choices as attributes: [data-scheme], [data-bleed], [data-align], [data-valign], [data-shadow], [data-bg-attach]. Its background is .doc-section-bg, the last child of the section.

A block's own look

A few blocks take look keys of their own, in the block JSON, the editor (the # panel beside the drag handle, or a grid cell's style popover) and markdown. Unset, each renders exactly what it always did:

Block

Key

Values

Markdown

heading, paragraph

textAlign

start, center, end

## Title {align=center}

heading

variant

default, eyebrow — a small kicker

### News {variant=eyebrow}

bulletList, orderedList

marker

default, arrow, dash (en dash), emdash, none — a theme default is components.list

{: marker=arrow} after it

columns

stackOn

sm (a phone — the default), md, never

:::columns{stackOn="md"}

column

hideOn

["phone"], ["tablet"] or both

::column{hideOn="phone"}

gridItem

shadow

none, sm, md, lg

::gridItem{… shadow="md"}

gridItem

radius

0–64 (px) or none, sm, md, lg, full

::gridItem{… radius="16"}

grid

cols

1–12 — one column wraps a single card

:::grid{cols="1"}

section

role

layout — a look band, not a slot

—

heading, paragraph

size

px 8–160, a length, or {lg, md, sm}

## Big {size=lg:44,sm:28}

heading, paragraph

color

a colour token (brand, ink …) or a #hex

Note {color=brand}

heading

weight

a whole number, 100–900

## Ship {weight=800}

heading

tracking

letter-spacing in em, -0.2–0.5

## Ship {tracking=-0.03}

gridItem

border

true (the line colour) or a colour

—

section

gap

a spacing token, px, or page

—

spacer

height

a spacing token, px, a length, or {lg, md, sm}

:::spacer{height="lg:xl,sm:md"}:::

paragraph, bulletList, orderedList

link

inherit — the block's links take its own colour

{: link=inherit} / Text {link=inherit}

bulletList, orderedList

color

a colour token or a #hex

{: color=muted} after it

paragraph, bulletList, orderedList

weight

a whole number, 100–900

{: weight=500} after it

bulletList, orderedList

itemGap

the room between items: a spacing token or px 0–400 — a theme default is components.list.itemGap

{: itemGap=12} after it

columns

spaceBefore, spaceAfter, spaceAround

the row's room above / below (spaceAround both; a side overrides it): a length or {lg, md, sm}

:::columns{spaceAround="lg:96px,sm:40px"}

They publish as [data-text-align], [data-variant="eyebrow"], [data-marker], [data-stack-on], [data-hide-on], and on a cell [data-shadow], [data-radius] with --cell-radius, and a sized or coloured text block as [data-size] / [data-ink] with --blk-fs-lg/md/sm and --blk-ink, and a heading's own weight and tracking as [data-weight] / [data-tracking] with --blk-fw and --blk-ls. Those two outweigh typography.levels.<h1…h6>.weight/.tracking, which set every heading of a level; the block keys are for the one heading that differs (a hero's 800 at -0.03em). null clears either. An eyebrow or an aligned heading keeps its look whatever typography.levels says, because it was set on that one block; restyle every eyebrow with typography.levels.eyebrow (or an elementStyles rule on h2[data-variant="eyebrow"], which now outweighs ours).

A section's own rhythm and dark band. gap puts that space between the blocks inside a section and takes their own margins away (page uses the front page's flow gap). background.colorDark, gradientDark and imageDark paint the band in dark mode, each falling back to its light twin. A spacer block is an empty band of a set height, for the gap a margin cannot say. A picture with align: center now centres on a composed front page too, and a captioned picture follows its section's contentAlign.

A band inside a band. A section may hold ONE more section — a centred head inside a full-bleed band, then left-aligned feeds under it — and no deeper: a third level is a 422 that names its path ("can sit inside one other section at most"). A section still never sits inside a callout, a quote or any other block's prose. A card inside a band is a grid cell: give a grid with cols: 1 one gridItem with bg, pad, shadow and radius, and put it in the section.

A section used only for layout — a full-width band, some padding — is a look band, not one of the page shape's slots, whenever its key names no slot the shape declares, or when it says role: "layout". Publishing and conformance read straight through it, so a post can use one without being told its required slots are empty.

Reach for a setting before a hook

Several of the things people historically wrote CSS for are settings now, and a setting is the thing we keep working: chrome.sticky, chrome.height, chrome.align, chrome.ctaStyle, chrome.showSignIn, chrome.themeToggle (including its radius and activeColor), layout.measure, chrome.sidebarBrand, chrome.footer.logo / logoMark, chrome.footer.cookieLinkPlacement, theme.brandName, theme.colorScheme and palette.header_background / header_primary. Position our chrome with those settings, not with elementStyles: a theme that moves the header by hand is a theme that breaks on our next markup change. transform is accepted only when it repaints in place — scale(), rotate(), skew() — and a translate…, matrix… or perspective is a 422 naming the declaration.

Size, pictures, underlines and layering

elementStyles also takes the properties an editorial layout is built from — an overlapping headline card, covers cropped to one ratio, a coloured offset underline, a sticky rail, a frosted panel. Each one either repaints or reflows the element inside its own box, or moves it inside its own block, and its values are narrowed so it cannot do more:

Property

Takes

aspect-ratio

auto, a number, or width / height (each up to 100) — 16 / 9

object-fit, object-position

cover, contain, fill, none, scale-down; one or two keywords, lengths or percentages

height, min-height, max-height

auto, none, fit-content, min-content, max-content, or a length up to 200vh / 4000px / 100%

text-decoration-line, -style, -color, -thickness, text-underline-offset

the usual values; thickness up to 20px / 1em, offset within ±40px / ±4em, a colour never a url()

position

static, relative, sticky, or absolute inside a named block (below). Never fixed.

inset, top, right, bottom, left

auto or a length within ±100%, ±50vh, ±800px or ±50em

z-index

auto or a whole number 0–10

isolation, mix-blend-mode

auto/isolate; any blend mode

backdrop-filter

blur() up to 40px, brightness(), contrast(), grayscale(), invert(), opacity(), saturate(), sepia(), hue-rotate() — no url()

order, grid-column, grid-row, grid-area, grid-template-columns, grid-template-rows, align-self, justify-self

line numbers, span N, auto and track lists; two cells given the same grid-area overlap

flex-wrap, flex-direction, align-content, flex-grow, flex-shrink, flex-basis

nowrap/wrap/wrap-reverse; row/column (and -reverse); the alignment keywords; a number 0–99; auto, content or a length — beside display, gap, justify-content and align-items

An elementStyles rule is anchored one class heavier than the reader root (:root .forumulate-reader …), so a rule on an element our own stylesheet also styles — an eyebrow, a block's alignment — wins, the way a per-element rule should.

Why those limits:

  • fixed is never accepted. A fixed element is placed against the viewport, so it could sit over the consent banner, the studio's staff bar or sign-in — the three things a theme must never be able to cover.

  • absolute only inside a block. The rule's selector has to name a descendant of a block you gave a hook — [data-fx-block="lead"] .card, not .card and not the block itself. We then mark that block contain: layout paint, so it is the element's containing box and its clip: however it is offset, it cannot leave the block.

  • Layers stop at 10. Our sticky header is layer 30 and the overlays above it higher, so a card can be layered over its neighbours and never over us.

  • Sizes stop at 200vh / 4000px, so a block cannot be made endless.

The Kilter replica's lead story — a white headline card pulled up over the bottom of a 16:9 cover — is four rules and no customCss:

{
  "elementStyles": [
    {
      "selector": "[data-fx-block=\"lead\"] article img",
      "declarations": { "aspect-ratio": "16 / 9", "object-fit": "cover" }
    },
    {
      "selector": "[data-fx-block=\"lead\"] article h3",
      "declarations": {
        "position": "relative",
        "z-index": "2",
        "margin-top": "-96px",
        "margin-right": "15%",
        "padding": "20px 24px 0",
        "background-color": "#ffffff"
      }
    },
    {
      "selector": "article a",
      "declarations": {
        "text-decoration-line": "underline",
        "text-decoration-color": "#5200ff",
        "text-decoration-thickness": "2px",
        "text-underline-offset": "4px"
      }
    },
    {
      "selector": "[data-fx-block=\"lead\"] article h3",
      "declarations": { "margin-top": "-40px", "margin-right": "0" },
      "media": "phone"
    }
  ]
}

A value outside these is a 422 that names the declaration and says why — elementStyles.1.declarations.z-index: z-index is auto or a whole number from 0 to 10… — and a misspelled property names the one you probably meant (aspect-ration → "Did you mean aspect-ratio?").

Variants of an element rule

An elementStyles rule may carry the condition it applies under — the studio's visual editor writes these, and you can too:

Key

Values

Compiles to

media

tablet, phone

@media (max-width: 1024px) / @media (max-width: 640px)

state

hover, focus, active

:hover, :focus-visible, :active on every selector in a list

scheme

light, dark

the same gates the dark palette uses

page

a page or post id

only where that piece is on screen, header and footer included

{
  "elementStyles": [
    {
      "selector": ".btn",
      "declarations": { "background-color": "#0f766e" },
      "media": "phone",
      "state": "hover"
    }
  ]
}

A rule without them is the rule it always was. Phone and tablet rules are emitted after the rest, so at equal specificity the narrower screen wins.

Fonts

The three type roles

customFontFamily sets the heading face and the body face to one family, which is the right answer for a brand with one typeface and no help at all for a brand with three. Three keys say it directly:

Key

Sets

Used by

headingFontFamily

--font-display

Every h1–h3 in an article, page and card headings, chrome

bodyFontFamily

--font-body

Body text, and the reader root's own font-family

monoFontFamily

--font-mono

code, pre, kbd, samp

{
  "customFontUrl": "https://cdn.acme.com/fonts/acme.css",
  "headingFontFamily": "Acme Display",
  "bodyFontFamily": "Acme Text",
  "monoFontFamily": "Acme Mono"
}

What falls back to what, because it is not a single chain:

  • each role falls back to customFontFamily, then to the built-in font choice, then to ours. A theme that only sets customFontFamily is unchanged.

  • bodyFontFamily does not become the heading face. If you set only the body face, headings keep ours — retyping every heading in a space from a key that does not mention headings would be a surprise.

  • monoFontFamily never falls back to a proportional family. Code set in a text face is a bug, not a brand choice, so it falls back to font: "mono" and then to the system monospace stack.

Naming a family does not load it. The faces have to arrive through customFontUrl or an @font-face rule in your own CSS, and the origin they come from has to be in the page's policy — which is the next section. A family that is named and not loaded resolves silently to the next entry in the stack, so "the font did not change" and "the font did not load" look identical.

Which origins your pages may load fonts, styles and images from

A reader page sends a Content-Security-Policy, so a stylesheet, a font or an image on a host that is not in it does not load — the browser refuses the request and logs it to the console. These are always allowed, on every host:

Directive

Always allowed

style-src

the page's own origin, inline styles, our site, fonts.googleapis.com

font-src

the page's own origin, data:, our site, fonts.gstatic.com, fonts.googleapis.com

img-src

the page's own origin, data:, blob:, our site, our asset CDN

media-src

the page's own origin, data:, blob:, our site, our asset CDN

Anything else has to be named by your theme, and we work most of it out for you:

Where it comes from

Added to

customFontUrl

style-src, font-src

fonts.faces[].src

style-src, font-src

a url(…) in customCss, in an enabled cssSnippets entry, or in mobile.css

style-src, font-src

logoUrl, logoMark, faviconUrl

img-src

fontOrigins

style-src, font-src

imgOrigins

img-src

mediaOrigins

media-src

frameOrigins

frame-src

You do not need the API for any of the declared lists: the theme editor's Allowed origins section edits fontOrigins, imgOrigins, mediaOrigins, frameOrigins and apiOrigins as rows, checks each row with the same rule the API applies (a wildcard or a path is flagged before you save, and only valid rows are saved), shows each list's cap, and underneath shows the effective policy per address — what this theme adds to each directive at each address the space is served on, as last saved. scriptOrigins lives in the studio's Scripts & ads panel, next to the custom-domain rule that governs it.

Three rules worth knowing before you debug one of these:

  1. An @import you write is removed, and so is its origin. We strip every @import out of theme CSS before a reader sees it, so a font loaded that way never loads — and an origin that only an @import names is deliberately not added to the policy, because a permission for a request the browser can never make is a false statement about the page. Load a hosted stylesheet with customFontUrl instead, and use @font-face { src: url(…) } for files you host yourself.

  2. The rules are the same on every host. A domain of your own, a claimed *.forumulate.com subdomain and the space's address on forumulate.com build this part of the policy from the same theme, so a font that loads at one of your addresses loads at all of them. (This was not true before 2026-10-08: at the forumulate.com address the theme's origins were not consulted at all, so a font on your own CDN was refused there and worked on your subdomain. GET /orgs/{orgId}/spaces/{spaceId}/addresses lists the addresses a space has — do not assemble one by hand.)

  3. Each origin you declare must be an exact https origin, such as https://cdn.acme.com. In fontOrigins, fontOriginsRemoved, imgOrigins, mediaOrigins, apiOrigins and scriptOrigins, a wildcard (https://*.acme.com), a scheme on its own (https:), a plain-http origin or an origin with a path is refused with a 422. The whole write fails, nothing is stored, and the error names the entry, for example "fontOrigins.0": ["Must be an exact https origin, e.g. https://cdn.example.com"]. These forms would mean "any host", and keeping the list narrow is the point (see imgOrigins below). Each list holds at most 20 entries. When the policy is built, at most 12 origins reach each directive: the ones derived from your theme come first, then the ones you declared, and anything after the twelfth is left out.

fontOrigins is the escape hatch for a URL we cannot see — a face fetched by a script, or a stylesheet that redirects to a second host. fontOriginsRemoved is the opposite: it takes an origin back out, including one we derived from your own CSS, and it is subtracted where the policy is built, so it keeps holding on the next request.

imgOrigins needs its own paragraph, because img-src is narrow for a reason that is not about you. Every external image in a document is fetched once by our proxy into the bucket our CDN serves, so your readers' browsers never ask a third party for a picture and no third party learns who is reading. A logo is the one image that cannot work that way: you typed the URL into a setting, and the page chrome loads it directly. So the three brand keys are derived automatically, and imgOrigins is for the images derivation cannot see — a sprite named inside your own CSS, an image your own header markup pulls. Declaring one adds those hosts and nothing else: it does not turn the proxying off, document images are still rewritten, and there is no way to express "any host" in it.

mediaOrigins is the same shape for media-src, which is what a video block and a section's background video load through. A video in your library (an upload, /api/assets/…) needs nothing: our origin and our asset CDN are always allowed. A video on another host is never proxied — a stream is not a picture we can fetch once — so its exact origin has to be named here, for example "mediaOrigins": ["https://videos.acme.com"], or the reader's browser refuses it and the player stays on its poster. Same rules as the rest: exact https origins only, a wildcard is refused by name with a 422.

apiOrigins is the same shape for connect-src: the API origins your reference documents, so "Try it" can reach the API it describes. Imported OpenAPI specs contribute their own servers[] automatically; this is for a hand-written page, or a templated server URL we will not guess at.

frameOrigins — sites your embeds may frame

frame-src names the providers an embed block recognises (YouTube, Vimeo, Calendly, Typeform, Tally, Spotify, Google Maps and the rest — the full list is in the authoring reference) and nothing else, so an embed of any other site renders as a link. frameOrigins is how you say "I trust this host":

PATCH /orgs/{orgId}/spaces/{spaceId}/theme
{ "frameOrigins": ["https://js.stripe.com", "https://share.hsforms.com"] }

An embed whose URL is on one of these origins is then framed as written, on every address the space is published at — the renderer and the policy read the same list, so a host is never framed on one address and refused on another. The frame is still sandboxed (it may run its own script, keep its own cookies, submit its own forms and open a new tab; it may not navigate your page or open a dialog over it), loads lazily, and is sent no referrer.

The rules are stricter than the other lists and the refusals say why:

  • One exact https origin per entry. https://*.acme.com is refused with Wildcards are not accepted ("https://*.acme.com"). Name each origin exactly…; https: is refused as a scheme, http://… as not https, and https://acme.com/forms as having a path (the message names the origin to use instead).

  • At most 12 entries, which is exactly what reaches frame-src — a thirteenth would be stored and never allowed, so it is refused instead.

  • Owner-authored pages frame these hosts (docs, blog posts, releases, legal, events, contact copy, intros). Forum posts and replies do not.

GET …/theme/origins reports them per host under contributes["frame-src"].

Asking what a host actually allows

Call

What

GET /orgs/{orgId}/spaces/{spaceId}/theme/origins

Every origin this theme names, where each was found, and the effective policy per host.

DELETE /orgs/{orgId}/spaces/{spaceId}/theme/origins/{origin}

Stop allowing one (adds it to fontOriginsRemoved).

POST /orgs/{orgId}/spaces/{spaceId}/theme/origins/{origin}

Allow it again.

{origin} is URL-encoded (https%3A%2F%2Fcdn.acme.com). The response:

{
  "origins": [
    { "origin": "https://cdn.acme.com", "source": "customCss" },
    { "origin": "https://cdn.jsdelivr.net", "source": "customFontUrl" }
  ],
  "allowed": ["https://cdn.acme.com"],
  "removed": [],
  "hosts": [
    {
      "host": "docs.acme.com",
      "kind": "domain",
      "url": "https://docs.acme.com",
      "contributes": {
        "font-src": ["https://cdn.acme.com"],
        "style-src": ["https://cdn.acme.com"],
        "img-src": [],
        "media-src": [],
        "script-src": [],
        "frame-src": []
      },
      "notes": []
    },
    {
      "host": "forumulate.com",
      "kind": "platform",
      "url": "https://forumulate.com",
      "contributes": {
        "font-src": ["https://cdn.acme.com"],
        "style-src": ["https://cdn.acme.com"],
        "img-src": [],
        "media-src": [],
        "script-src": [],
        "frame-src": []
      },
      "notes": []
    }
  ]
}

origins is every font and stylesheet origin the theme names, with source saying what named it (customCss, cssSnippets, mobileCss, customFontUrl, fontFaces, stylesheet, declared) so you can find one without reading your own CSS. allowed and removed are the stored fontOrigins/fontOriginsRemoved lists. Image origins are not in origins — they are derived from the brand keys and imgOrigins, and they show up per host under contributes["img-src"]; likewise mediaOrigins under contributes["media-src"] and frameOrigins under contributes["frame-src"]. All six directives are always present, empty or not.

hosts is the part to read when something is blocked: one entry per address this space is published at, with the origins that address really adds to each directive, and notes for anything that is true of that address and not the others. The only such note today is on a forumulate.com address: a third-party script there shares an origin with /api, so a connector widget whose provider marks itself custom-domain-only is not placed on the page even though its origin is in the policy.

One more undocumented key

showLanguageSwitcher decides whether the reader offers the interface-language control. Left unset it follows the rule rather than a value — shown on a space that publishes more than one content locale, hidden otherwise, because the control changes our chrome and not your words, and on a one-language site it reads as a promise of translations that do not exist. Set it true to keep it everywhere, which is what you want if your readers do not all speak the language you publish in.

Brand assets, and the ?w= image widths

logoUrl, logoMark and faviconUrl take an https URL up to 2048 characters, a path from your asset library — /api/assets/<assetId>, which is what the console's Upload button writes — or an inline data: image, which costs no request and does not depend on a host staying up. A library path is published absolute wherever it leaves the page — the tab icon, the installed-app manifest, the Organization logo in the page's JSON-LD — on the address the reader is on, and on a space mounted at a path of your own domain (acme.com/docs) every image goes through that mount's _api route rather than your root, which is not ours to serve. //host/… is refused: it has no scheme, but a browser reads it as another site. theme.mobile.logoUrl takes the same shapes, and so do chrome.footer.logo and chrome.footer.logoMark — the footer's own lockup, which used to be forced to repeat the header's.

The footer slots take one extra answer the header's do not: false, meaning "nothing in this slot". Unset or "" keeps the header's image, so a theme written before those keys existed renders exactly as it did. See the theming guide for the table.

The accepted forms are an http(s) URL, a library path, a data: raster (png, jpeg, gif, webp, avif, bmp, ico) and data:image/svg+xml. At 2048 characters an inline image holds roughly 1.5 KB, so it is a mark rather than an illustration.

SVG, and what we strip out of it

SVG uploads are accepted, and so is data:image/svg+xml in the three brand keys. Both are sanitised first, and the sanitised document is what we store and serve — so what a GET hands back is what will be rendered.

An SVG is a document, not a picture: it can carry <script>, a <foreignObject> with HTML inside it, an <a> that navigates, <animate> that rewrites attributes, an <image> or an @import-ing <style> that reaches a third-party server, and an <!ENTITY> that reads a file or expands to a gigabyte. Served from a host of ours, that would be somebody else's script running on our origin. So the sanitiser is an element and attribute allowlist:

  • removed with everything inside them: script, style, foreignObject, a, animate, animateTransform, set, iframe, image, metadata, switch, handler;

  • removed: every on* attribute, every attribute not on the allowlist, any attribute value containing javascript: or data:, an href/xlink:href — or a url(…) in fill, clip-path, mask or filter — that points anywhere but inside the same document, and a style attribute containing url(, @import or expression(;

  • kept: filter effects. <filter> with feGaussianBlur, feDropShadow, feOffset, feFlood, feComposite, feColorMatrix, feBlend and feMerge/feMergeNode (and their in, in2, result, stdDeviation, flood-color, operator, values, mode… attributes) are paint operations on what the drawing already drew, so a drop-shadowed illustration keeps its shadow. feImage is not kept: it loads a URL;

  • refused outright, not stripped: a file declaring a <!DOCTYPE or an <!ENTITY> (a drawing program does not write one), and anything with no <svg root. These answer 415 media.unsupported on upload and 422 in a theme, because storing a blank picture and reporting success is worse than refusing.

In practice a logo exported from a drawing program goes through untouched. One with a tracking pixel, a hyperlink or an embedded font fetch comes out without them. If your mark depends on an external font, name the family with headingFontFamily and load it with customFontUrl rather than embedding it.

.svg has to be on your organization's files.authorized_extensions list, which it is by default. An uploaded SVG is served with Content-Disposition: attachment on direct navigation, so it downloads instead of rendering as a page on our origin; embedding it with <img src> is unaffected, and a script inside an SVG loaded as an image is inert in any case.

Where each one appears:

  • faviconUrl applies to every reader surface. The browser tab shows your icon on the docs, blog, forum, roadmap, changelog, events, courses, status, FAQ, jobs, legal and contact readers, on their 404 pages too, on our domain and on yours.

  • logoUrl and logoMark appear in the reader header and footer, in the docs reader's sidebar (unless chrome.sidebarBrand is hide), in the per-space web manifest and in generated OpenGraph images. Inside a site, the header and footer take them from the site theme, not the space's.

The widths

Append ?w= to an asset URL and you get that width instead of the original. Only these six widths are accepted:

32   64   128   256   512   1024
<img src="/api/assets/9f1e…?w=256" alt="" />

Any other value is a 400, not a rounding:

{
  "error": {
    "code": "validation.failed",
    "message": "Unsupported width \"900\". Allowed: 32, 64, 128, 256, 512, 1024.",
  },
}

A repeated ?w=64&w=64 is also a 400 — "Pass one width." An empty ?w= is treated as absent.

Refused rather than rounded on purpose: silently serving the nearest allowed width would still let a caller mint unbounded distinct URLs, each one a distinct cache entry, which is the amplification the allow-list exists to stop. The width is parsed before any lookup, so a refused width costs no lookup and says nothing about whether the asset exists.

?w= is the only transform parameter. There is no h, q, format, dpr or fit.

When the width is ignored and you get the original

The transform is skipped, silently, when:

  • the file is not a JPEG, PNG, WebP or AVIF. SVG above all — it is a vector and a script container, never rasterised. GIF is excluded because it is almost always animated.

  • the image is animated (a multi-page WebP).

  • the source cannot be measured from its header.

  • the source is larger than your organization's maximum image megapixels. It is never decoded in that case.

  • the source is already no wider than you asked. We do not upscale.

  • the re-encode came out bigger than the original, which happens with small palette PNGs.

The format is preserved across the resize, so the content type stays true.

A variant is served with Cache-Control: public, max-age=86400, immutable and an ETag of "<assetId>-<size>-w<width>", so a conditional request gets a 304. An asset in a private space is served private, max-age=60 instead, whatever the width.

The caveat: a CDN bucket path has no ?w=

This is the part to know. If an asset lives in a public space and the deployment has an asset CDN configured, the URL you are handed is the storage bucket's own object key:

https://cdn.forumulate.com/<orgId>/<assetId>/logo.png

Nothing of ours runs for that URL. The CDN is bound straight to the bucket, so there is no ?w= to honour — an unknown query parameter is not an error, it is simply ignored, and you get the full-size object.

URL shape

?w=

/api/assets/<assetId>

works

https://cdn.…/<orgId>/<assetId>/<filename>

ignored — full size served

any URL you pasted from elsewhere

ignored; it is not ours

So: assets in private spaces always go through /api/assets/{id} and always support ?w=. Assets in public spaces may be on the bucket path. If you need a specific width for a public logo, upload it at that width rather than relying on the transform.

The same rule applies to the automatic rewriting we do on your behalf: only our own /api/assets/{id} path is rewritten to add a width. A logo may be any URL the owner pasted — an external host, a data URI, or a bucket key — and those are returned untouched.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.

Asset

Operation

Request

What it does

Shortcuts

Asset_list

GET /orgs/{orgId}/spaces/{spaceId}/assets

List assets in a space

forumulate assets list<br>client.assets.list()

Asset_upload

POST /orgs/{orgId}/spaces/{spaceId}/assets

Create an asset in a space

forumulate assets upload<br>client.assets.upload()

Asset_catalog

GET /orgs/{orgId}/assets

List assets in an organization

forumulate assets all<br>client.assets.catalog()

Asset_remove

DELETE /orgs/{orgId}/spaces/{spaceId}/assets/{assetId}

Delete the asset

forumulate assets delete<br>client.assets.remove()

Asset_serve

GET /assets/{id}

Get the asset

forumulate assets download<br>client.assets.serve()

My Activity

Operation

Request

What it does

Shortcuts

MyActivity_list

GET /public/orgs/{orgId}/me/activity

The signed-in reader's own activity in this organization

client.users.activity.list()

MyActivity_bulk

POST /public/orgs/{orgId}/me/activity/bulk

Remove, unreact, unbookmark or unwatch several of your rows at once

client.users.activity.bulk()

MyActivity_exportAll

GET /public/orgs/{orgId}/me/export

Download your activity in this organization (JSON)

client.users.activity.exportAll()

Space Settings reader themes

Operation

Request

What it does

Shortcuts

SpaceSettings_getReaderThemes

GET /orgs/{orgId}/spaces/{spaceId}/reader-themes

List reader themes in a space

client.spaces.settings.getReaderThemes()

SpaceSettings_setReaderThemes

PUT /orgs/{orgId}/spaces/{spaceId}/reader-themes

Replace reader themes in a space

client.spaces.settings.setReaderThemes()

Space Settings theme

Operation

Request

What it does

Shortcuts

SpaceSettings_getTheme

GET /orgs/{orgId}/spaces/{spaceId}/theme

The live theme

forumulate theme get<br>client.spaces.settings.getTheme()

SpaceSettings_setTheme

PUT /orgs/{orgId}/spaces/{spaceId}/theme

Replace the live theme

forumulate theme set<br>client.spaces.settings.setTheme()

SpaceSettings_patchTheme

PATCH /orgs/{orgId}/spaces/{spaceId}/theme

Merge into the live theme

forumulate theme patch<br>client.spaces.settings.patchTheme()

SpaceSettings_getThemeDraft

GET /orgs/{orgId}/spaces/{spaceId}/theme/draft

The theme draft, if there is one

forumulate theme draft get<br>client.spaces.settings.getThemeDraft()

SpaceSettings_setThemeDraft

PUT /orgs/{orgId}/spaces/{spaceId}/theme/draft

Save a theme draft

forumulate theme draft set<br>client.spaces.settings.setThemeDraft()

SpaceSettings_patchThemeDraft

PATCH /orgs/{orgId}/spaces/{spaceId}/theme/draft

Merge into the theme draft

forumulate theme draft patch<br>client.spaces.settings.patchThemeDraft()

SpaceSettings_discardThemeDraft

DELETE /orgs/{orgId}/spaces/{spaceId}/theme/draft

Discard the theme draft

forumulate theme draft discard<br>client.spaces.settings.discardThemeDraft()

SpaceSettings_publishThemeDraft

POST /orgs/{orgId}/spaces/{spaceId}/theme/draft/publish

Make the theme draft live

forumulate theme draft publish<br>client.spaces.settings.publishThemeDraft()

Theme Component theme components

Operation

Request

What it does

Shortcuts

ThemeComponent_gallery

GET /orgs/{orgId}/theme-components

List theme components in an organization

client.themes.component.gallery()

ThemeComponent_save

POST /orgs/{orgId}/theme-components

Create a theme component in an organization

client.themes.component.save()

ThemeComponent_patch

PATCH /orgs/{orgId}/theme-components/{id}

Update the theme component

client.themes.component.patch()

ThemeComponent_remove

DELETE /orgs/{orgId}/theme-components/{id}

Delete the theme component

client.themes.component.remove()

ThemeComponent_composite

GET /orgs/{orgId}/theme-components/{id}/composite

Get the composite in an organization

client.themes.component.composite()

ThemeComponent_import

POST /orgs/{orgId}/theme-components/import

Import the theme component

client.themes.component.import()

ThemeComponent_export

GET /orgs/{orgId}/theme-components/{id}/export

Export the theme component

client.themes.component.export()

Theme Origins

Operation

Request

What it does

Shortcuts

ThemeOrigins_themeOrigins

GET /orgs/{orgId}/spaces/{spaceId}/theme/origins

List origins in a space

client.themes.origins.themeOrigins()

ThemeOrigins_restoreThemeOrigin

POST /orgs/{orgId}/spaces/{spaceId}/theme/origins/{origin}

Create the origin

client.themes.origins.restoreThemeOrigin()

ThemeOrigins_removeThemeOrigin

DELETE /orgs/{orgId}/spaces/{spaceId}/theme/origins/{origin}

Delete the origin

client.themes.origins.removeThemeOrigin()

The Forumulate teamUpdated