Theme your site

Embed
Preview of the card
<a href="https://forumulate.com/guides/theming"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/69270dd2-b188-4caa-a5ac-e5a73ec15554/social-card.svg?v=3e7ae33eec8b" alt="Theme your site" width="600" height="315"></a>

How a space's look is set over the API: every key, what each one changes, which reader surfaces honour it, and which CSS variables are safe to target.

The console's Look area writes the same object this document describes. Anything you can do in the console you can do here, and vice versa.


The endpoints

Method

Path

Semantics

GET

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

{ theme } — the stored object, nothing added

PUT

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

Replaces the whole theme. Keys you omit are gone

PATCH

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

Deep-merges into the stored theme. Nested objects merge key by key

GET

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

Alternate looks a reader may switch to

PUT

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

{ themes: [{ name, theme }] }, max 8

GET

/public/orgs/{orgId}/spaces/{spaceId}/chrome

What the reader actually gets: theme, chrome, announcement, readerThemes

GET

/public/orgs/{orgId}/i18n/catalog

Every interface string you may override, as key → the English we ship

When a write shows up. A theme, preset or reader-theme write evicts the reader's caches for the whole space — our data cache, the rendered pages, the CDN and the CSP origin lists the reader's policy is built from — so it is visible within a couple of seconds, like publishing a page. A new font or image origin is allowed on the same request as the CSS that loads it (Crossly CU-33); if the eviction call itself fails, the origin lists refresh within one minute. Saving a theme DRAFT deliberately does not: the caches hold the published theme, and a preview swaps the draft in per request.

The body of PUT/PATCH .../theme is the theme object itself, not { theme: … }. GET wraps it; the writes do not.

PATCH is the one to reach for. PUT with a partial body is the most common way to lose a theme: it is a replace, so PUT {"primaryColor": "#ff00aa"} deletes the custom font, the chrome and the custom CSS that were there a second ago.

Unknown keys are refused

These routes answer 422 naming any key they do not recognise, with its full path:

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

This is deliberately stricter than the rest of the API, where an unrecognised body field is reported on the X-Unrecognized-Fields response header and otherwise dropped. On a theme there is nothing else to notice: the write answers 200, the read-back echoes what your client sent, and the only symptom is a page that looks wrong. So a key we do not honour is an error here, not a shrug.

The same goes for what is inside a document you send — a front page, a page's content: a block's attributes are checked, and so are the keys of an attribute that is itself an object. A section's padding takes top, bottom and x, so "padding": {"left": 24} is named as doc.content[0].attrs.padding.left (a 422 on a front page, the X-Unrecognized-Fields header on page content) instead of vanishing.

Who can write it

space.update. Reading the private theme needs space.read; the public /chrome read needs nothing beyond the space being public (or a valid share token).


Which surfaces honour which keys

Every reader surface is wrapped in one .forumulate-reader shell, and the space's theme is compiled into one <style> scoped to it — so colours, fonts, widths, custom CSS and custom JS apply to all of them: docs (/p/), blog (/b/), forum (/f/), roadmap (/r/), changelog (/c/), events (/e/), courses (/co/), status (/st/), FAQ (/faq/), jobs (/jobs/), legal (/legal/), contact (/contact/), and every one of those on a custom domain.

One shell, one stylesheet. The class is on exactly one element per page, so a rule written with the child combinator — .forumulate-reader > div — means one thing. (It used to be on the page's own root as well, nested inside the shell, which made that selector match at two levels and put the same stylesheet in the document twice.)

faviconUrl is universal too. It is not CSS, so it is not in that <style> — every reader page's metadata names it instead, which puts the brand's icon in the browser tab on all of the surfaces above, not just the docs reader.

The keys that are not universal:

Key

Honoured on

chrome

A space that belongs to no site. A space inside a site wears the site's header/footer instead (set it on the site, not the space). chrome.sidebarBrand is the exception — the docs rail belongs to the space, so the space's value is read either way (the site's is only its default)

logoUrl, logoMark, brandName

The reader header and footer, the docs reader's sidebar (unless chrome.sidebarBrand is hide), the per-space web manifest, generated OpenGraph images, and the header/footer of a site (set on the site theme)

faviconUrl

Not limited — every reader surface listed above, including its 404 page. See the paragraph above the table

announcement

Every reader page, rendered by the chrome above the header and spanning the full page width. Not shown in an embed (?embed=1)

scriptOrigins, ads

Only on your own domain — never on forumulate.com or a *.forumulate.com subdomain, where a third-party script would be same-site with /api/*. Ads there render in a sandboxed iframe

customJs

Every reader surface on your own domain — never on forumulate.com or a *.forumulate.com subdomain, the same rule as every owner script. It runs once the page has loaded and hydrated (so its DOM edits never fight React's); no consent decision holds it back. See Custom code

<script src> in chrome HTML

An external https:// script in a nav panel.html or chrome.footer.html: only on your own domain, loaded like a customHead script. Inline code and on… handlers never run. See Scripts in an HTML panel or the footer HTML

stringOverrides

Reader surfaces only. The console never resolves a space theme

motion

Every reader surface, including the 404, the legal register and the site index. Not in an embed (?embed=1) and not in the console's editing canvas

mobile

Compiled into one @media (max-width: 640px) block, every surface

post, footnotes

Blog posts (/b/…/<slug>). The post's layout, head, rail, cover, share, more-to-read and series navigation. See A blog post's layout

i18n.urlStyle

How translations are addressed on every reader surface: path (unset) gives each declared language its own path after the space's address (/docs/es/<page>); query keeps ?locale=<code>. A site's value applies to its spaces; a space's own wins. See Language addresses

i18n.switcher

Where the content-language switcher sits on every translated surface (header | sidebar | footer | content-top | none) and its look (select | pills). Site default, space override. See Where the language switcher goes

components.select

Every reader dropdown: the box, option highlight and check, or style: "native" for OS controls. See Selects and the language switcher

components.quote

Every reader surface: each plain blockquote. A quote's own variant/align wins

newsroom

Every reader surface that draws a body: the product card, sources and corrections blocks' defaults (a block's own attribute wins), and newsroom.reminders — the "Remind me" box under a blog post. See Newsroom extras

If a space belongs to a site, the site theme is the base and the space theme is layered over it, key by key — so a site sets the shared look and one space can still override contentWidth or a palette slot without repeating the rest.


The keys

Colour

Key

Type

What it changes

primaryColor

#rrggbb

The brand. Sets --reader-brand (prose links, rules, callout edges) and --signal (every button, pill and chip). The text on a solid brand button follows automatically — it goes dark on a light brand

primaryColorDark

#rrggbb

The brand in dark mode, when it should differ from primaryColor (both vocabularies, under the two dark gates). A paletteDark slot still wins

accentColor

#rrggbb

The secondary accent: --reader-accent and --accent

palette

object, see below

Named colour slots, each an independent override. Applies in every mode

paletteDark

same shape

The same slots, applied only in dark mode. A slot you leave out here falls through to palette

colorScheme

system | light | dark

system (default) follows the reader's OS. light/dark pin the reader to one mode — a brand that is "always dark" can say so, and the pin is applied as data-theme on <html> before first paint, so it moves the whole page and not only the reader tokens. A reader who explicitly uses the light/dark toggle still wins. The pin supplies only the colours you did not set: your primaryColor, accentColor, palette, paletteDark, components and customCss win in either mode

colorSchemeStrict

boolean

With colorScheme light/dark: make the pin absolute. Without it the pin yields to a light/dark choice the viewer stored — on any space of the same domain. Strict ignores that choice and hides the reader's theme toggle. Means nothing with system

Palette slots

{ "palette": { "background": "#101014", "surface": "#18181f", "primary": "#e9e5db" } }

Slot

Paints

background

The page canvas. Also sets --bg

surface

Cards, panels, dropdowns. Also sets --surface

primary

--reader-primary; read as the running text when ink is unset

secondary

--reader-secondary; read as the page canvas when background is unset

tertiary

--reader-tertiary

quaternary

--reader-quaternary

header_background

The header bar's background. Also sets --reader-header-background

header_primary

The header bar's text, links and brand. Also sets --reader-header-primary

highlight

--reader-highlight

danger

--reader-danger; the danger callout's bar and wash

success

--reader-success; the success callout's bar and wash

love

--reader-love

ink

Running text. Also sets --reader-ink and --ink

heading

Every heading and page title. Also sets --reader-heading, --ink-heading

muted

Secondary text: dates, bylines, captions. Also --reader-muted, --muted

line

Hairline rules and borders. Also sets --reader-line and --line

footer_background

The footer band. Also sets --reader-footer-background

footer_primary

The footer's text and links. Also sets --reader-footer-primary

info

--reader-info; the info callout's bar and wash (unset: the brand)

warning

--reader-warning; the warning callout's bar and wash

tint

boolean — mix the brand a few percent into --bg, --surface, --panel and --line (in paletteDark: over the dark board). Explicit slots still win

background, surface, header_background and header_primary are wired to the tokens our own stylesheet paints with, so setting them repaints the page — the two header slots paint [data-fx-header], and header_primary also rebinds --ink and --muted inside the bar so the links in it follow the brand rather than staying our grey. The rest are the Discourse colour-scheme names, published so an imported scheme maps across one-to-one and so a theme author can build on them from customCss. info, warning, danger and success paint the callouts of the same name (each falls back to today's colour when unset), and paletteDark re-tints them. highlight, love, tertiary and quaternary are not read by our components; to use one, read it in customCss: .doc-callout { border-color: var(--reader-highlight) }.

{ "primaryColor": "#5e6ad2", "palette": { "tint": true, "info": "#2563eb", "warning": "#d97706" } }

Text colours: ink, headings, secondary text, rules

ink, heading, muted and line are the four colours a page of text is made of, and the four tokens every reader surface reads (--ink, --ink-heading, --muted, --line). Before these slots existed the only way to a near-black body under pure-black headings was customCss against those internal names; now it is two keys, light and dark:

{
  "palette": { "ink": "#1d1d1f", "heading": "#000000", "muted": "#6e6e73", "line": "#d2d2d7" },
  "paletteDark": { "ink": "#e8e8ed", "heading": "#ffffff", "muted": "#a1a1a6", "line": "#424245" }
}
  • heading colours every heading and page title through a weightless rule, so a heading that carries a colour of its own (a level colour from typography.levels, a card title in the brand) still wins. A section painted in a scheme of its own starts from that scheme's ink.

  • palette applies in every mode, like every palette slot. For a TEXT slot that is how a near-black ink would have stayed near-black on the dark board — so a palette.ink, heading or muted that does not read on our dark paper, and that paletteDark leaves unset, is moved along its own hue until it does, in dark mode only. Set the paletteDark slot to choose the dark colour yourself.

  • footer_background and footer_primary paint the footer the way the two header slots paint the header: the band's fill, and its text — --ink, --muted and --line are rebound inside the footer so its links follow.

secondary and primary are read as the page and the text when background / ink are not set — the meaning Discourse gives them, and the one our own Midnight, Editorial and Terminal presets were drawn with. tertiary and quaternary are published as --reader-tertiary / --reader-quaternary for your CSS only. Prefer background/ink/heading/muted: they always win.

Every colour is a full six-digit hex with the leading #. A bare 0b835b or a three-digit #0b8 is accepted and normalised (and the normalisation is reported on X-Field-Warnings). Anything else is a 422.

A section in its own scheme

A page is in one mode, but a band of it does not have to be. A section block can carry scheme: "dark" (or "light") and is then painted in that scheme whatever the page is in — a dark hero on a light page, a light pricing table on a dark one. It wears your palette for that scheme, not ours: in a dark section your paletteDark slots apply over palette, and the brand and accent inks are the ones chosen for the dark board; in a light section on a dark page the dark-only slots step aside. Nothing changes for a section without the key. The block side — backgrounds, spacing, the video background — is in Authoring: sections, video and block hooks.

Type

Key

Type

What it changes

font

sans | serif | mono

The built-in choice. Rebinds the reader's --font-display and --font-body, so it reaches body text and headings

customFontUrl

https URL, ≤ 2048

A stylesheet that declares the face — Google Fonts, or your own @font-face. Loaded as @import at the top of the reader's stylesheet

customFontFamily

string, ≤ 120

The family name to apply from it, e.g. Geist. Both keys are required: a family with no stylesheet to load it is a font that silently falls back, so it is ignored

{
  "customFontUrl": "https://cdn.example.com/geist.css",
  "customFontFamily": "Geist"
}

That rebinds --font-display and --font-body inside .forumulate-reader, so it reaches paragraphs, article headings, page titles, card headings and the chrome. It does not change the console: those variables are also set on <html>, and the theme's copy is scoped to the reader subtree.

The type scale (typography)

The family keys say which face. typography says how big, how tight and how heavy — the type system a design file states, as keys, so it no longer has to be customCss against .forumulate-prose h2.

{
  "typography": {
    "scale": "major-third",
    "baseSize": 17,
    "fluid": { "minViewport": 360, "maxViewport": 1280, "minScale": 0.8 },
    "levels": {
      "h1": { "lineHeight": 1.1, "tracking": -0.02, "weight": 700 },
      "body": { "lineHeight": 1.65 },
      "code": { "size": 14 }
    },
    "headingTransform": "none",
    "variation": { "heading": "\"wght\" 650, \"opsz\" 48" },
    "features": { "body": "\"ss01\", \"tnum\" 1" },
    "opticalSizing": "auto",
    "headingWrap": "balance",
    "bodyWrap": "pretty"
  }
}

Key

Shape

scale

minor-second (1.067) · major-second (1.125) · minor-third (1.2) · major-third (1.25) · perfect-fourth (1.333) · augmented-fourth (1.414) · perfect-fifth (1.5) · golden (1.618)

baseSize

px, 12–24. The body size the scale grows from. Default 16. Set alone, it sizes the body text and nothing else

fluid

{ enabled, minViewport (240–1600, default 360), maxViewport (480–3840, default 1280), minScale (0.5–1, default 0.8), bodyMinScale (0.75–1, default 1) }

levels

One key per level — h1 … h6, body, small, code, and the hook levels below — each { size (px 8–160, a length, or one per screen size), lineHeight (0.8–3 unitless, or one per screen size), tracking (em, −0.2–0.5), weight (100–1000), color, colorDark, font, case, rule, spaceBefore, gradient, variation, features }. small is figure captions and <small>

headingTransform

none | uppercase | lowercase | capitalize, on every heading level

variation

font-variation-settings per role: { heading, body, code }

features

font-feature-settings per role: { heading, body, code }

opticalSizing

auto | none (font-optical-sizing)

headingWrap

wrap | balance | pretty — balance evens out a two-line title

bodyWrap

wrap | pretty — pretty avoids a one-word last line

numerals

proportional (default) | tabular — prose keeps the font's own figures and only times, tables and counters line up; tabular sets every figure on the page in fixed width

One heading, not a level. levels.h2.weight / .tracking set every h2. A single heading that differs — a hero at 800 and −0.03em — takes its own block keys, weight (100–900) and tracking (em, −0.2–0.5), from the block's look panel, the API or markdown (## Ship {weight=800 tracking=-0.03}); they outweigh the level's. See theming-advanced for the block look table.

How a size is decided. A level's own levels.<level>.size wins. Otherwise, with a scale, it is baseSize × ratio^step: h1 is four steps up, h2 three, h3 two, h4 one, h5 half a step, h6 and body sit on the base, small is one step down and code half a step down (a monospace face runs wide). With no scale, only the levels you sized explicitly change — and baseSize on its own sizes the body. A level you did not size keeps our stylesheet's size.

Fluid. With fluid present, every size becomes a clamp() that grows linearly from minViewport to maxViewport: headings start at minScale of their size, body text at bodyMinScale (default 1, so text does not shrink). Sizes are emitted in rem, so a reader who raised their browser's text size still gets larger text.

Settings strings are a closed vocabulary: quoted four-letter OpenType tags, comma-separated, each with a number (variation) or an optional number or on/off (features) — "wght" 650, "opsz" 32 or "ss01", "liga" off — or normal. Anything else is a 422 naming the key.

What it emits. One custom property per value your settings produce, on .forumulate-reader, and rules that apply them to the prose, its headings and the page title ([data-fx-page-title] takes h1):

Variable

Value

--reader-fs-<level>

The size — a rem, or a clamp() when fluid

--reader-lh-<level>

The line height (unitless)

--reader-tracking-<level>

The letter-spacing, in em

--reader-fw-<level>

The weight

<level> is h1–h6, body, small or code. Read them from your own CSS — a custom block that wants the h2 size uses var(--reader-fs-h2) instead of restating it. Unset, nothing is emitted: a theme with no typography compiles to exactly the stylesheet it did before the key existed.

mobile.fontScale multiplies the scale on phones: the sized levels' tokens are redefined under the phone media query, so every level scales together.

The studio inspector's per-element rules take font-variation-settings, font-feature-settings and font-optical-sizing too, for one element.

Colour per level, the standfirst and entry titles

Every level takes color and colorDark beside its size and weight — a brand-coloured h3 under black h2s:

{
  "typography": {
    "levels": { "h1": { "color": "#000000", "colorDark": "#ffffff" }, "h3": { "color": "#0066cc" } }
  }
}

A level colour outweighs palette.heading. Two more levels are carried by a documented hook rather than a prose element, and are sized only when you size them (a scale alone never touches them):

Level

Hook

What it is

lede

[data-fx-lede]

The standfirst under a title — a post's dek, the docs standfirst

entryTitle

[data-fx-entry-title]

An entry's title in an index — a changelog release

{
  "typography": {
    "levels": {
      "lede": { "size": 22, "lineHeight": 1.4 },
      "entryTitle": { "size": 28, "weight": 600 }
    }
  }
}

They publish --reader-fs-lede, --reader-fs-entry-title and the matching -lh-, -tracking- and -fw- tokens.

One size per screen size

Every size-like key in a theme — a level's size here, a level's spaceBefore, components.image.margin, a block's size, the spacer's height, and the feed, post and chrome keys that say they take one — accepts one value, or one per screen size:

{ "typography": { "levels": { "h1": { "size": { "lg": 56, "md": 44, "sm": 32 } } } } }

lg is the desktop and holds at every width nothing smaller overrides; md takes over at 1024px and below, sm at 640px and below — the same breakpoints elementStyles and mobile use. A step you leave out falls through to the next larger one, and a value with no lg leaves the desktop at the key's own default, so {"sm": 28} changes phones and nothing else. A misspelt step ({"phone": 28}) is a 422 that names it.

A length is px as a number, a unit string (px, rem, em, ch, vh, svh, dvh, vw, %), unitless 0, or clamp() / min() / max() of those with at most one + or - per argument — the shape every fluid formula takes: "clamp(2rem, 1.2rem + 3vw, 4rem)". A level size given as a number is still px, and still goes fluid with fluid and scales with mobile.fontScale; a length or a per-size object is used exactly as written (mobile.fontScale still reaches one that has no sm step). fluid and mobile.fontScale reach the hook levels below too.

More per level: face, case, a rule, space above, gradient text

Key

Shape

What it does

font

heading | body | mono | a family name your space loads

The face for this level — a dek set in the display face (lede.font: "heading")

case

none | upper | lower | capitalize

Letter case for this level

rule

{ color, colorDark, width (1–12 px), position: top | bottom | start, gap (0–64 px, default 12), hang, extent }

A hairline above (or under) the prose headings of this level — the editorial section head. start is a vertical bar at the leading edge; hang: true hangs it in the margin so the words keep the column's edge; extent: "first-line" (round 6) keeps a start bar one line tall on a multi-line heading (unset all)

spaceBefore

a length, or one per screen size

The space above this level in running text

gradient

{ stops: [2–3 hex], angle (0–360, default 90), stopsDark? }

Text painted with a gradient through the letters; the first stop is the fallback colour

rule and spaceBefore reach the prose headings only, not an index entry that wears a level (data-type-level). Two more hook levels:

Level

Hook

What it is

eyebrow

a heading block with variant: eyebrow

The small kicker over a headline. Its color/colorDark publish --reader-eyebrow, which every eyebrow reads

indexTitle

[data-fx-index-title]

An index's own title — the bar above a changelog or a blog index — sized apart from its entries (entryTitle)

topicTitle

[data-fx-topic-title]

A forum topic's title, sized apart from every other page title (h1)

{
  "typography": {
    "levels": {
      "eyebrow": {
        "color": "#c2410c",
        "colorDark": "#fdba74",
        "size": 12,
        "tracking": 0.14,
        "case": "upper"
      },
      "h2": {
        "rule": { "color": "#111111", "width": 2 },
        "spaceBefore": { "lg": "3.5rem", "sm": "2.5rem" }
      },
      "h1": { "gradient": { "stops": ["#7c3aed", "#db2777"], "angle": 120 } },
      "lede": { "font": "heading", "size": 22 }
    }
  }
}

Round-4 keys (2026-10): per-screen leading, per-level OpenType, captions

Key

Shape

What it does

levels.<l>.lineHeight

0.8–3, or { "sm": 1.5, "lg": 1.55 }

One leading per screen size; --reader-lh-<level> changes at 1024px and 640px

levels.<l>.variation

a font-variation-settings string

This level's own axes ("\"wdth\" 75") — wherever the level reaches, a feed card's title included. The role-wide variation.heading is prose headings only

levels.<l>.features

a font-feature-settings string

This level's own OpenType features

levels.small

the usual level keys

Figure captions and <small> — set captions with { "size": 12, "weight": 600, "color": "#86868b" }

Top-level (not under typography):

Key

Shape

What it does

headingAnchors

hover | always | none

The "link to this section" glyph beside a heading. Unset: on hover on a desktop, quietly visible on a touch screen. hover hides it on touch screens; none drops it (the heading's id stays). Front pages and eyebrow headings never show it

{ "headingAnchors": "hover" }

The eyebrow level replaces the elementStyles recipe this guide used to give (h2[data-variant="eyebrow"]). That recipe works too now — an element rule is as heavy as our own eyebrow rule and comes after it — but the level is the setting we keep working.

Your own font files (fonts.faces)

customCss cannot @import — the sanitiser strips it, so what the policy allows and what the sheet loads agree. A face hand-written as @font-face in customCss works, but fonts.faces is the structured form, and its file's origin is added to the reader's font-src automatically, exactly like customFontUrl's:

{
  "fonts": {
    "faces": [
      {
        "family": "Brand Sans",
        "src": "https://cdn.example.com/fonts/brand-sans.woff2",
        "format": "woff2",
        "weight": "100 900",
        "style": "normal",
        "display": "swap"
      }
    ]
  },
  "headingFontFamily": "Brand Sans",
  "bodyFontFamily": "Brand Sans"
}

Key

Shape

family

The name the family keys then use. No quotes, braces or semicolons

src

https URL of the file. Its origin goes into font-src (see theme/origins)

format

woff2 | woff | truetype | opentype

weight

"400", or a variable font's range "100 900"

style

normal | italic | oblique

display

auto | block | swap | fallback | optional — unset: fonts.display

unicodeRange

e.g. "U+0000-00FF, U+0131"

Up to 24 faces. Declaring a face does not apply it — name its family in headingFontFamily, bodyFontFamily or monoFontFamily.

When a font arrives late (fonts.display)

fonts.display is the font-display for the theme's own fonts: the faces of customFontUrl's stylesheet and every fonts.faces entry that sets no display of its own. Unset it is optional: the face is preloaded and used whenever it is there for the first paint (and from the cache on every page after), and a face that arrives later than that is skipped for this view, so the words never re-wrap under the reader. swap always shows the face, at the cost of the page jumping when it lands on a slow first load.

{ "fonts": { "display": "swap" } }

Uploading the file instead of hosting it. A font can be uploaded to your asset library like an image: .woff2, .woff, .ttf and .otf are accepted, recognised from the file's own bytes rather than its name. An uploaded font is always handed out at an absolute https address on our API (https://<api host>/api/assets/<id>), never at the CDN bucket's, and is served with Access-Control-Allow-Origin: * — @font-face always fetches in CORS mode, so without that header the face would load as nothing on your own domain. Put that absolute URL in src. A bare /api/assets/<id> path is refused, because src must start with https://. The font-face fields in the console's theme editor have an upload button that does all of this for you and fills in src and format.

Layout

Key

Type

What it changes

contentWidth

standard | wide | full

How wide every reader surface may grow — see the table below. Unset is not the same as wide on a docs article; see below

onThisPage

auto | always | never

Whether a surface carries the "on this page" rail. auto (default) lets each surface decide; see below

layout.measure

"52rem", "70ch", "832px"

The longest a line of long-form text may run, whatever the surface's width. Unset = no cap of its own; see below

layout.frame

wide | column | full

How wide the header's and footer's inner rows run. Unset = wide, the shell, the same on every page; column lines them up with each page; see below

layout.background

{ image, imageDark, size, position, fixed, decoration, color, colorDark }

The page's backdrop behind every surface: an image (size: cover default | contain | auto; position: top default | center) and/or a decoration — grid, dots, stripes or a soft brand gradient at the top. color is the decoration's ink (default 6% of the text colour). Unset = none

layout.heroDecor

{ style, color, colorDark, height, intensity }

Decorative art behind the top of every index (blog, changelog and forum fronts, the blog's list pages): style grid | dots | stripes (the layout.background patterns, fading out from the top) | glow (a soft two-stop brand radial) | none. color tints the lines or the glow (default: 10% of the text colour for lines, the brand for the glow; colorDark for dark mode); height 120–900 px (default 420); intensity 0–100 (default 100). Pure CSS behind the content, so nothing loads and nothing moves. Unset = none (CL-6)

layout.floatRoom

auto | 0–240 (px)

Room kept clear at the foot of every page for a floating embed (a chat bubble in the corner), so the last lines and the pager can scroll out from under it. A number is that many px at every width; 0 turns it off. auto (unset) keeps ~88px below 768px, and only on pages that load a floating embed (a connected chat widget). Published as --reader-float-room

layout.guides.gutter

{ pattern: "stripes" | "dots", width?, color? }

Fills the two gutters just outside layout.guides with hatching or dots. width is each gutter ("2.5rem" default, rem or px); color defaults to the guides' colour

mobile.fontScale

0.75–1.25

Multiplier on body and heading type on phones

mobile.density

compact | cozy

Tighter or roomier spacing on phones

mobile.logoUrl

https URL, library path or data image

A smaller logo used only on phones

mobile.hide

string[], ≤ 50

Reader selectors hidden on phones

mobile.css

string, ≤ 10 000

Raw phone-only CSS, wrapped in the media query for you

What contentWidth actually sets

Four variables, one per kind of surface, all on .forumulate-reader:

Variable

The surfaces that read it

standard

unset (fallback)

wide

full

--reader-max

The page-spanning shell: header, footer, the docs reader's nav + article + rail row

80rem

96rem

96rem

100%

--reader-board

A board of lanes or cards: the roadmap index, a site's front door

64rem

72rem

72rem

100%

--reader-list

An index: forum, blog, status, the legal register and its documents, FAQ, careers, events, courses, a list-preset roadmap

42rem

56rem

56rem

100%

--reader-text

One piece, and the surfaces that were always set as one: a forum topic, a blog post, a roadmap item, the changelog, contact, a course lesson, the 404

36rem

42rem

42rem

100%

The index width was 48rem until October 2026. Under a header running the full shell, a 48rem status page or blog index at 1440px was a 720px strip with ~355px of nothing each side; 56rem (896px) is the band a Statuspage/Instatus status page or a Stripe-style index runs at. The events and courses catalogues moved from the one-piece width to the index width at the same time. A legal document keeps a 46rem measure inside the index width when you set no layout.measure, so terms never run past ~90 characters a line.

A space that sets no contentWidth emits none of the four variables, and every surface falls back to the value in the "unset" column. For these four, unset and wide give the same widths. (contentWidth used to set --reader-max alone, which only the shell and the docs article read, so full gave a full-width header over a 672px blog post. Every surface reads one of the four now.)

The docs article column: where unset and wide differ

The docs article column has its own rule, because a page can set its own width. The page's layout wins. When a page names no layout, the column follows contentWidth, and this is the one place where unset is not the same as wide:

contentWidth

Article column when the page names no layout

unset

default: 56rem

standard

default: 56rem

wide

wide: 72rem

full

full: no cap

A page's own layout gives narrow 42rem, default 56rem, wide 72rem or full with no cap, whatever contentWidth says. Two more take the page out of the docs frame: center (no tree, no rail, the article centred at the default width) and custom (no tree, no rail, no cap, the title kept for screen readers only and no page chrome). A documentation space can set its default article width once with theme.docs.articleWidth (narrow | default | wide | full, or a measure like "52rem") — between a page's own layout and contentWidth; see the docs theme guide. So on a docs space, setting wide explicitly makes every article that names no layout 16rem wider than it is with the key unset. The other surfaces don't change. The row around the article (nav, article and rail) reads --reader-max like any other shell. Running text inside the column still stops at a readable measure; images, tables and other wide blocks use the extra room.

To move one surface without moving the rest, set its token in customCss rather than guessing at a class name — main.max-w-3xl is a Tailwind build artefact and changes under you on any release:

.forumulate-reader {
  --reader-list: 60rem;
}

The long-form measure (layout.measure)

A width is how far a surface may grow; a measure is how long a line of text may get inside it. They used to be the same number, so widening the indexes also widened every document set in the index width — a privacy policy at contentWidth: "full" ran edge to edge, and the only way to hold it back was CSS aimed at our page structure (main:not(#main-content) > article). The measure is its own key:

{ "contentWidth": "full", "layout": { "measure": "52rem" } }

Field

Values

measure

a length with its unit: 20–160rem, 30–200ch or 320–2560px. Unset = no cap of its own

It is published as --reader-measure on .forumulate-reader, and read by every element carrying [data-reader-measure]:

Surface

The element that keeps to the measure

A legal document

The whole <article> — heading, facts and body

A blog post

The post body

The changelog

Each release's body, on the index and on its own page

A roadmap item

The item body

Docs page

#forumulate-article (the text track; media still spans the article)

The measure only ever narrows: on a surface already narrower than it, it changes nothing, and with no layout.measure the elements have no cap of their own and render exactly as before. The column stays where it was — the text keeps the left edge the page heading sits on. "ch" is the typographer's unit (65–75 characters is the classic range); rem matches the width tokens above.

To clear it, PATCH {"layout": {"measure": null}} — or {"layout": null} for the whole layer. A wrong spelling (maxWidth, measures, a top-level measure) is a 422 that names the key, not a 200 that renders nothing. Target [data-reader-measure] in customCss if you need more than a length; never the max-w-[…] class beside it, which is a build artefact.

The header and the footer are page-spanning bars, and their inner row — the logo, the links, the footer columns — runs to --reader-max whatever the page between them is. That is deliberate: the chrome is the site's, and it stays put while a reader moves from the docs to the blog to the status page. layout.frame decides the inner row:

{ "layout": { "frame": "column" } }

frame

The header's and footer's inner row

unset

--reader-max, the shell, on every page. Nothing is emitted

wide

The same as unset, stated: pins it, and overrides a site theme's column back for one space

column

This page's content column: list-wide over an index, text-wide over a post, board-wide over a board, the shell over a docs page. The chrome changes width as the reader moves between kinds of page

full

The whole window, less the side padding

content

Accepted for themes that stored it, and the same as wide. It used to mean what column means now

Why content changed. It matched each page's column, which on a real site put a 768px header — the nav wrapped onto two rows, the search box under the logo — over the blog, status and legal pages, and the full shell over the docs and forum one click away. Chrome that jumps width between pages of one site reads as broken, so content keeps one frame now, and the per-page match is there under its own name for a design that wants it.

The bars themselves (background, border) always span the window; only the row inside moves. It is published as --reader-frame on .forumulate-reader and read by every element carrying [data-reader-frame] — the header's <nav>, the footer's rows and the footer slot. Under column the reader also publishes --reader-column, the width of the current page's column, so customCss can line something else up with it.

Getting every edge on one line, on every page. The header row, the footer row and the page's column are all centred and pad their contents by the same gutter, so once their widths agree their contents start and end at the same x:

  1. Make the widths equal — layout.widths with list and text (and board, if you have a board) all one length, or --reader-list / --reader-text in customCss.

  2. Set "layout": { "frame": "column" }. Header and footer now take that one width on every page, and never move.

With unequal widths column still lines each page up, but the chrome moves between pages. A docs page keeps the shell because its column is the shell row; the "on this page" rail on a one-column page lives in the right-hand margin and never moves the column (it folds away when the margin is too narrow for it). To clear the key, PATCH {"layout": {"frame": null}}. A misspelling ("frame": "contents", "layout": {"frames": …}) is a 422 naming it.

Each surface's width (layout.widths)

contentWidth is three presets that move all four surface widths at once. A design that says "1080px shell, 980px index, 680px post" needs each one on its own, and layout.widths is that — one length per surface, the same vocabulary and bounds as layout.measure (px, rem or ch):

{ "layout": { "widths": { "max": "1080px", "list": "980px", "text": "680px", "media": "1200px" } } }

Key

Token

The surface

max

--reader-max

The shell: header, footer, a docs page

board

--reader-board

A board of lanes or cards — a roadmap, a front door

list

--reader-list

An index — posts, topics, releases

text

--reader-text

One piece — a post, a thread, a release

media

--reader-media

How far a wide image or table may break out of the text column

Each key wins over contentWidth for its surface alone, so {"contentWidth": "wide", "layout": {"widths": {"max": "1080px"}}} is a 1080px shell over the wide preset's index and post widths. Unset keeps the preset. The console offers the shells design files use (960–1280px) and your own length.

Background guides (layout.guides)

The hairline column rules a design-system site draws behind its content:

{
  "layout": {
    "guides": {
      "columns": 12,
      "width": "1080px",
      "color": "#eeeeee",
      "style": "dashed",
      "outer": "solid"
    }
  }
}

columns (1–24) rules sit between the two outer ones, across width (default the shell) centred on the page; style is the inner rules, outer the two at the edges (solid or dashed), color defaults to the theme's hairline. They are painted on [data-fx-main], so a section with its own background covers them, exactly as it covers the page.

The index title bar (layout.indexHeader)

Every index — blog, changelog, forum, roadmap, status, FAQ, jobs, events, courses — opens with a generated title bar: the space's name, and on a blog its Archive and RSS links. A composed front page usually has a hero of its own, and the bar above it could not be removed.

{ "layout": { "indexHeader": "hide-title" } }

show (default) is today's page; hide-title drops the heading and keeps the row's links; hide drops the whole row. The hooks are [data-fx-index-header] (the row) and [data-fx-index-title] (the heading).

The page gutter (layout.gutter)

The side padding between every reader page's content and the screen's edge — 16px on a phone, 24px from 768px and 32px from 1025px, unset (the desktop step was 24px until October 2026, which put a full-width header's logo 24px off the window's edge). Every page shell takes the same steps, the changelog, events, courses and contact pages included — they used to hold 16px at every width.

{ "layout": { "gutter": { "sm": 24, "lg": 32 } } }

px as a number (0–96), any length ("1.5rem", "clamp(1rem, 4vw, 2rem)"), or one per screen size. A bare value is every width; sm is below 768px, md 768–1024px, lg above, each falling back to the next larger step. Published as --reader-gutter-sm/-md/-lg on .forumulate-reader; a full-width section's side padding follows the same tokens (its unset value stays 1rem), so a band's words line up with the page around it.

What onThisPage actually does

The rail used to be a side effect of the width: wide and full removed it, standard kept it. Six of the eight built-in presets set wide or full, so picking one of those silently deleted it. Width and navigation are two decisions, so they are two keys.

Value

Result

auto

The default: each surface decides. A docs guide page and a careers role carry it; the reference and minimal docs presets do not; a blog post carries it under piecePreset: article-toc

always

Added wherever there is an article to index — which is how a long legal document, a changelog release, the FAQ index or a course lesson gets one

never

Removed everywhere, on every width

Unset is the same as auto. onThisPage is a key of the space theme, so you set it with PATCH /orgs/{orgId}/spaces/{spaceId}/theme and the body {"onThisPage": "always"}.

The rail is hidden below 1280px on every surface — at narrower widths it would take the room the words need — and it renders nothing for a document with fewer than two headings. contentWidth has no effect on it either way.

Which surfaces it reaches. These are all of them. A surface that is not in this list (the forum, roadmap, status, events, a site's front door, the indexes) has no rail at any setting:

Surface

With auto (or unset)

always

never

Docs pages (/p/…), including careers roles

On under the guide preset; off under reference/minimal

On

Off

Blog posts (/b/…/{slug})

On under piecePreset: article-toc, off otherwise

On

Off

Changelog releases (/c/…/{slug})

Off

On

Off

Course lessons (/co/…/{slug})

Off

On

Off

The FAQ (/faq/…)

Off

On

Off

Legal documents (/legal/{orgId}/{kind})

On (since October 2026; it was off)

On

Off

Legal documents, inline and page-backed alike. A legal document written as a page reads the theme of the space that page is in. One posted straight at POST /orgs/{orgId}/legal with an inline body belongs to no space, so it reads the theme of the org's legal space instead. That is the space whose header and footer already appear around it. Both kinds answer auto, always and never the same way. An org with no legal space has no theme to ask, so only the document's own setting (below) applies. The rail never appears inside an embed (?embed=1), because the embed is one frame of somebody else's page.

Per-piece override

A single page, post, release, lesson or legal document can set its own onThisPage, using the same three values. The piece wins over the space, and the space wins over the surface default:

  1. the piece's own onThisPage, when it has one;

  2. otherwise the space theme's onThisPage;

  3. otherwise the surface default in the auto column above.

A piece's auto is not the same as leaving it unset. auto asks for the surface's own default even when the space says always or never. Send null to clear a piece's value so the space decides again. The FAQ is one page per space with no pieces, so only the space key reaches it.

Piece

Where the override lives

How to set it

Docs page, course lesson, page-backed legal document

The page's metadata, beside layout

PATCH /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/metadata {"onThisPage": "always"}

Blog post

The post

POST or PATCH /orgs/{orgId}/spaces/{spaceId}/blog/{topicId} {"onThisPage": "never"}

Changelog release

The release

POST or PATCH /orgs/{orgId}/spaces/{spaceId}/changelog/{releaseId} {"onThisPage": "always"}

Inline legal document

The document

POST /orgs/{orgId}/legal {"kind": "terms", "title": "…", "body": "…", "onThisPage": "always"}

The value you set is returned where you would expect to find it: in a page's metadata.onThisPage, in onThisPage on a post or release, and in onThisPage on each document from GET /public/orgs/{orgId}/legal. Changing it never bumps a legal document's version, so nobody is asked to accept it again over a layout change.

# One long release gets the rail; the rest of the changelog stays as it is.
curl -X PATCH "$API/orgs/$ORG/spaces/$SPACE/changelog/$RELEASE" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"onThisPage": "always"}'

# One short docs page drops the rail the guide preset gives every page.
curl -X PATCH "$API/orgs/$ORG/spaces/$SPACE/pages/$PAGE/metadata" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"onThisPage": "never"}'

Both in the console

The studio's Look panel has a picker for each, next to the colours and the font: Content width (Default, Standard, Wide, Full) and On this page (Default, Auto, Always, Never). Each starts at Default, which writes nothing — an unset theme renders exactly as it did before these keys existed. Choosing Default again removes a value a preset or an API call pinned. Pinning Auto renders the same as Default; the only difference is that a later change to the default would not reach you. Pinning Wide does not render the same as Default on a docs space: it widens every docs article that names no layout from 56rem to 72rem, as the table above shows. On every other surface, Wide and Default give the same widths.

Brand assets

Key

Type

brandName

string ≤ 120, or false — the words next to the mark. See below

logoIncludesName

true — the logoUrl image is a full wordmark (mark and name), so neither logoMark nor the brandName words are drawn beside it, in the header or the footer. brandName still names it for screen readers. Unset keeps the mark-then-wordmark lockup. A footer with its own chrome.footer.logo never inherits the header's mark or words (set footer.logoMark / footer.showBrandName to add them)

logoUrl

https URL, library path (/api/assets/<id>) or data image, ≤ 2048 — the wordmark or mark

logoMark

the same shapes — a small square mark shown before the name (a lockup)

logoUrlLight, logoUrlDark

the same shapes — the wordmark for a light page and for a dark one. Picked by the scheme the header is rendered in (the reader's mode, a forced colorScheme, or one post's own colorScheme); each falls back to logoUrl

logoDarkMode

none | invert | plate — how a LONE logoUrl (no light/dark pair) is drawn on a dark page: invert flips its lightness (hue kept), plate puts it on a light rounded plate; unset/none draws it as is. Documentation spaces default to invert when there is a lone logo and no dark pin

faviconUrl

the same shapes — the browser-tab icon, on every reader surface (published absolute)

whiteLabel

boolean — removes the "Published with Forumulate" line from readers and emails

What the brand lockup shows

The header, the footer and the docs sidebar all render the same lockup, in this order: the square logoMark, then the logoUrl image, then the name.

brandName decides the name, and only brandName:

brandName

The name slot shows

a non-empty string

those words — including beside a logoUrl image, because you asked for text

false

nothing

unset or ""

the site's name, else the organization's — and only when no logoUrl is carrying it

"" is unset. It used to mean "no brand at all": clearing the field removed the name and suppressed the mark, which left an empty header.

In the header, chrome.brandText decides whether those words are SHOWN (2026-10-12). auto (default), show or hide:

chrome.brandText

The header's name slot

auto (unset)

Hidden beside a wordmark (it is kept for screen readers) and shown beside a mark. A wordmark is a logoUrl with a logoMark beside it, or a logoUrl alone whose image is at least 1.8:1 wide — or whose shape cannot be measured (an image hosted elsewhere). A mark is a logoMark alone, or a logoUrl narrower than 1.8:1

show

Always shown — the old "explicit brandName prints beside the wordmark" behaviour

hide

Never shown; the home link keeps the name as its accessible name

{ "chrome": { "brandText": "show" }, "brandName": "Acme Docs" }

This replaces the rule above for the header only: a set brandName used to print beside the wordmark too, so a wordmark spelling "Acme" was followed by "Acme". The footer and the docs sidebar keep the table above. The shape is read from the image itself, once, when a page first renders it.

The docs sidebar uses the same lockup and the same rule, with one difference. When brandName is unset, the fallback name there is the space's name, not the site's or the organization's. So a space with only a logoMark shows the mark and then the space name at the top of its rail. A space with a logoUrl shows the mark (if set) and the wordmark image, and no name unless brandName is set. A phone shows mobile.logoUrl in place of the wordmark when you set one. The sidebar lockup is not a link. To remove it, set chrome.sidebarBrand: "hide" (see below). whiteLabel is read off the theme of whatever renders the footer it appears in, and inside a site that is the site. A space's own whiteLabel is therefore ignored while the space belongs to a site — set it in the site's config.theme — and is honoured on a space that belongs to no site. Same rule as chrome in the table above, for the same reason: a site has one footer, and one line at the bottom of it.

Only for a space that belongs to no site. A site's own header/footer is configured on the site.

{
  "chrome": {
    "cta": { "label": "Start free", "href": "https://example.com/signup" },
    "headerLinks": [
      { "label": "Pricing", "href": "/pricing", "highlight": true },
      { "label": "Docs", "href": "/docs", "children": [{ "label": "Guides", "href": "/guides" }] }
    ],
    "navMenu": { "trigger": "hover", "animation": "fade" },
    "footerDescription": "Everything we ship, as we ship it.",
    "footerColumns": [{ "heading": "Product", "links": [{ "label": "Docs", "href": "/docs" }] }],
    "socialLinks": [{ "platform": "github", "href": "https://github.com/example" }],
    "footerLinks": [{ "label": "Privacy", "href": "/legal/privacy" }],
    "copyright": "© 2026 Example Ltd",
    "footer": { "logo": "https://cdn.example.com/footer.svg", "cookieLinkPlacement": "columns" },
    "mobileNav": "menu",
    "sticky": true,
    "height": "compact",
    "align": "split",
    "ctaStyle": "pill",
    "showSignIn": false,
    "themeToggle": { "show": true, "style": "icon", "position": "end" },
    "sidebarBrand": "show"
  }
}

Key

Shape / limit

cta

{ label (≤60), href (≤2048) }

secondaryCta

{ label, href, style: outline | link | solid } — a second button beside cta. Below

header

The bar's look: { overlay, overlayInk, height, border, borderColor, shadow, blur, opacity, logoHeight, navGap, navWeight, navSize, navCase, navTracking }. Below

headerLinks

≤ 12 items: a plain { label, href }, or one that opens a dropdown, a mega menu or your own HTML — see Navigation menus

navMenu

How those menus open and look: { trigger, openDelay, closeDelay, animation, radius, shadow, background, ink, border, width }

footerLinks

≤ 20 links — the thin bottom row

footerDescription

≤ 400 chars

footerColumns

≤ 12 columns, each { heading (1–60), links (≤20), stack }

socialLinks

≤ 10, { platform, href } — the platform list is below

copyright

≤ 200 chars

translations

The chrome in other languages: { "es": { labels: { "Pricing": "Precios" }, footerDescription, copyright } }. labels maps the original words of any header item, menu entry, footer heading or link, button, search pill or sign-in control to the translation. Shown when the page's language or the reader's interface language matches — see Translated labels

footer

{ logo, logoMark, cookieLinkPlacement, html, css, htmlPlacement, newsletter } — the footer's own lockup, where "Cookie settings" sits, your own HTML and a newsletter row. Below

mobileNav

menu (hamburger, default) or bar (links stay inline)

sticky

boolean — the header follows the reader down the page. Default false

height

compact | standard (default) | tall — the header row's vertical size

align

split (default: brand left, links and CTA right) | left | center

ctaStyle

solid (default) | pill | outline | link. Every style takes its ink from the brand, so a pale brand gets readable text

showSignIn

boolean, default true — our reader sign-in control in the bar. false leaves the in-page controls that need an account alone

themeToggle

{ show, style: mono | icon | switch | cycle, position: end | start | menu, radius, activeColor, hideOn, phone } — see below

sidebarBrand

show (default) | hide — the brand lockup at the top of the docs reader's left rail. See below

chrome.sidebarBrand controls the top of the docs reader's left rail ([data-fx-sidebar-logo]):

  • show (or unset) renders the lockup described under What the brand lockup shows: logoMark, then logoUrl, then the name. The name is brandName, or, when that is unset, the space's name, shown only when no logoUrl is set. With no logo keys at all, show is just the space name as text.

  • hide removes the lockup. Use it when the header above already carries the brand. Search, the version and language switchers and the page tree stay.

Inside a site, a site's config.sidebarBrand is the default for every space in it, and a space's own theme.chrome.sidebarBrand still beats it, because the docs rail belongs to the space. So: set it once on the site, and override it on the one space that differs.

Docs header keys (October 2026)

Every key here is optional, and unset renders the header exactly as before.

Key

Shape / limit

searchPlacement

start (right after the brand, before the links) | center (centred on the HEADER: brand and links | search | controls, three columns — since 2026-10-12, before that between brand and links) | before-links (at the far end: search, then the links, then the controls) | end (default). Wide screens only; the phone row is unchanged

searchStyle.width

120–560 — the pill's width in px from 768px up. Ignored for search: "icon"

searchStyle.kbd

mod-k (⌘ K on Apple, Ctrl K elsewhere) | slash (/) — a key hint drawn as keycaps on the pill. Unset draws none. The keys are bound only with open: "modal"

searchStyle.open

box (default — the dropdown that searches on submit) | modal — a full search dialog: searches as you type, ↑ ↓ ↵ esc, recent picks, results grouped by docs section with links to the matching headings, full screen on a phone. ⌘K and / open it

searchStyle.label

The pill's words — and the placeholder of the box, the search modal, the docs sidebar's search box and the docs landing page's search label (inside a site, the site's searchStyle.label)

searchStyle.backgroundDark / inkDark

Hex: the pill's fill and ink in dark mode. Unset: background / ink hold in both schemes

header.maxLinks

How many headerLinks the wide row draws (1–12, default 6). The plain links past it fold, in order, into one "More" dropdown at the end, so the header never wraps. Dropdowns, panels and button-styled links never fold; the phone menu lists every link

headerLinks[].icon

A glyph name, an image, or a brand mark — any socialLinks platform (github, discord, x, …), drawn with the footer's marks

headerLinks[].iconDark

The dark-mode twin of an image icon; both swap with the scheme. Needs icon

headerLinks[].githubRepo

owner/repo — draws the repository's star count after the label (48.3k), refreshed hourly; nothing when GitHub does not answer

headerLinks[].match

{ collection: [ids], pathPrefix: ["/docs/api"] } — the link is the CURRENT one (aria-current="page") when the docs page is in one of those collections or the address starts with a prefix

header.activeMatch

auto (default) | explicit — which link is current when it names no match. auto: a plain link to a page of this site is current when the address is that page or below it; the deepest such link wins, and a link to the home page never does. explicit: only match marks a link (the behaviour before October 2026)

header.activeStyle

text (brand ink, heavier) | underline | pill — how the current link is drawn. Unset: none drawn, aria-current still set

header.sectionCrumb

boolean — the docs page's section after the brand ("Acme / Guides"). Nothing on other surfaces or for a top-level page. header.sectionCrumbSeparator (1–3 chars, default /). Hook [data-fx-header-crumb]

header.logoHref

Where the header's brand links — a path or an http(s)/mailto URL. Default the reader's home

themeToggle.style: "select"

A small dropdown naming each mode

themeToggle.position: "sidebar"

At the foot of the docs sidebar; on a surface with no docs sidebar it falls back to end

footer.columnRule

boolean — a hairline between link columns, from 768px up

footer.hideOn

["docs"] — no footer on docs article pages. A consent control the footer carried stays reachable there on a line of its own

footer.headingStyle: "plain"

Already the sentence-case heading with no rule under it — the no-caps, no-rule column heading

A header link with style: "outline" or "button" now takes components.button.radius like the header's buttons. For a pale outline on a header button use ctas[i].border: "#ebebeb".

Ask AI's launcher, the search scope and the docs frame are theme.docs keys — see Theme a documentation space.

The footer's own lockup (chrome.footer)

The footer rendered the header's lockup with no way to say otherwise, and "Cookie settings" was injected into its bottom row wherever that row happened to be. Both are settings:

{
  "chrome": {
    "footer": {
      "logo": "https://cdn.example.com/footer.svg",
      "logoMark": false,
      "cookieLinkPlacement": "columns"
    }
  }
}

Field

Values

logo

https URL, library path or data image — the footer wordmark. false = none. Unset or "" = the header's logoUrl

logoMark

the same three answers for the square mark, against the header's logoMark

cookieLinkPlacement

bottom (default) · brand · columns

The three-way rule is brandName's, deliberately: a string is used verbatim, false shows nothing in that slot, and unset or "" keeps the header's. To go back to the header's lockup after setting one, send null — {"chrome":{"footer":{"logo":null}}} removes the key and {"chrome":{"footer":null}} takes the whole layer off.

The footer carries no phone-only logo whatever mobile.logoUrl says: a footer is one column on a phone, so there is room for the wordmark.

Inside a site this is the site's config.footer, exactly like whiteLabel and the rest of the chrome — see Sites.

cookieLinkPlacement moves the consent control; it cannot remove it. Withdrawing consent has to stay as easy as giving it (GDPR Art. 7(3)), and consent here is per-organization bring-your-own-CMP, so your reader may carry the only trigger a visitor can reach. There is no value that hides it: "none", "hide" and false earn a 422 that says so, rather than a 200 that changes nothing. The control renders nothing at all when there is nothing to consent to: an organization with no consent provider configured gets it only on pages that load an analytics tag (analytics.*) or a chat widget in an opt-in category, where a visitor must be able to withdraw. Configure a provider to have it on every page.

Value

Where it goes

bottom

the bottom row, after your footerLinks — where it has always been

brand

the brand block, under footerDescription and the social marks

columns

the last link of your last footerColumns entry — the "Legal" column pattern

brand and columns need the part of the footer they name to be rendered: a footer with no columns has nowhere to put a column link. Each falls back to bottom rather than dropping the control. The reader 404 renders the same footer, so it lands in the same place there.

The control is [data-fx-cookie-settings] wherever it lands, so you can style it without reaching for the utility class it shares with your footer links.

Markup we have no setting for goes in chrome.footer.html (with its own scoped chrome.footer.css) — see Your own footer HTML, and a newsletter row. It is sanitised by the same function as a header nav panel's HTML: no inline script or on* handler ever runs from it and form controls are stripped. An external <script src="https://…"> in it is loaded only on your own custom domain, the same rule as customHead — see Scripts in an HTML panel or the footer HTML. On our platform origin a script in your footer would be same-origin with /api/* and with every other tenant's storage, so there it is dropped.

x · github · linkedin · youtube · discord · mastodon · instagram · facebook · bluesky · website · behance · devto · dribbble · gitlab · hackernews · matrix · medium · patreon · pinterest · producthunt · reddit · rss · slack · spotify · stackoverflow · substack · telegram · threads · tiktok · twitch · vimeo · whatsapp

href is an https URL. A platform outside the list is a 422 naming the key — the list is the gate, so widening it is how a missing network gets in. Tell us and we will add it.

Marks: a faithful brand mark where we have one, the standard feed glyph for rss, a globe for website, and otherwise a neutral monogram badge carrying the platform's own name as its accessible label. A logo traced approximately is wrong on your brand's page and nobody can tell us so, which is why the icon set is allowed to lag the list above rather than guess.

chrome.themeToggle

{ "chrome": { "themeToggle": { "show": true, "style": "icon", "position": "end" } } }

Field

Values

show

true (default) or false. false is what a space pinned to one colorScheme usually wants

style

mono (default — SYS / LGT / DRK) · icon (screen, sun, moon) · switch (one two-state control) · cycle (one icon button: system → light → dark; its label names the next)

position

end (default — beside the CTA) · start (before the links) · menu (inside the phone hamburger panel)

radius

corner radius in px, 0–999. 0 is square, 999 a pill. Unset = square segments, a round switch

activeColor

the active choice's fill: ink (default) · brand · accent · a 6-digit hex like #4F46E5

hideOn

["desktop"] (768px and up) or ["phone"] — hide it at one width. Both is show: false, refused saying so

phone

menu (default — in the phone menu panel) · row (in the header row beside search and the menu button)

A rounded control with a brand-filled active state, with no CSS:

{ "chrome": { "themeToggle": { "style": "mono", "radius": 999, "activeColor": "brand" } } }

radius shapes the outline and the active segment (one pixel less, so it sits inside the border) on mono and icon, and the track and thumb on switch. The text on the active fill stays readable on its own: brand uses the brand's own --signal-fg, accent the page colour, and a hex near-black or near-white by luminance. The same settings reach every copy of the control — the header, the phone menu and the reader 404 — and a site takes the same object at config.themeToggle.

Both are published as custom properties on [data-fx-theme-toggle], so a theme that wants a value the settings do not offer can still set one in customCss:

Variable

What

Unset

--reader-toggle-radius

The outline / track radius (radius)

0px (segments), round switch

--reader-toggle-active

The active fill (activeColor)

var(--ink)

--reader-toggle-active-ink

The text or thumb on that fill

var(--bg)

Clear either with null: PATCH {"chrome": {"themeToggle": {"activeColor": null}}}. A near-miss spelling (activeColour, borderRadius, shape) is a 422 that names it.

The control is [data-fx-theme-toggle] in every shape. Do not target [role=group][aria-label=Theme]: the label is translated, so that selector stops matching the moment a reader switches interface language.

The switch shape flips between light and dark and never returns to system — a viewer who has touched a two-state switch has expressed a preference. system is still the default, and mono and icon can go back to it.

A second button (chrome.secondaryCta)

The pair every marketing header carries — "Sign in" beside "Get started" — is cta plus secondaryCta:

{
  "chrome": {
    "cta": { "label": "Get started", "href": "/signup" },
    "secondaryCta": { "label": "Sign in", "href": "/login", "style": "outline" }
  }
}

Same {label, href} rules as cta (a path or a safe absolute URL), plus style: outline (default — two solid buttons side by side are two primaries), link or solid. It renders immediately before the primary in every copy of the header — the desktop row, the phone menu and the 404 — and its hook is [data-fx-cta-secondary], carrying data-cta-style like the primary. In a header painted with palette.header_background an unfilled secondary gets the same readable-ink correction as an unfilled primary. A site takes it in its config like every other chrome key.

The header's appearance (chrome.header)

{
  "chrome": {
    "header": {
      "overlay": true,
      "overlayInk": "#ffffff",
      "height": 72,
      "border": "none",
      "shadow": "sm",
      "blur": 12,
      "opacity": 0.85,
      "logoHeight": 32,
      "navGap": 28,
      "navWeight": 500,
      "navSize": 14,
      "navCase": "uppercase",
      "navTracking": 0.06
    }
  }
}

Key

Shape

overlay

boolean — the header sits ON the first section (a hero runs underneath it), transparent. See below

overlayInk

hex — the header's text and icon colour while it is overlaid (white over a dark hero)

height

px, 32–200 — the bar's minimum height. Wins over chrome.height. Or {sm, md, lg} (see below)

border

line (today's) | none | strong

borderColor

hex — the bottom rule's colour

shadow

none | sm | md | lg

blur

px, 0–40 — background blur behind a translucent header

opacity

0–1 — the header background's opacity (a blur alone is 0.8)

logoHeight

px, 12–120 — the lockup's images in the header only; the footer keeps its own. Or {sm, md, lg}

navGap

px, 0–80 — space between header items

navWeight

100–1000

navSize

px, 10–24

navCase

none | uppercase | lowercase | capitalize

navTracking

em, −0.1–0.4

The nav keys style every top-level header item, [data-fx-nav-item] — your headerLinks and, on a site, the section links in the bar.

overlay and motion. theme.motion.header.behavior: "transparent-until-scroll" is the moving half of a transparent header: sticky, transparent until the reader scrolls. overlay is the static half: the header leaves the flow so the first section runs under it. On its own the header is absolutely positioned and scrolls away with the hero. With chrome.sticky or any moving motion.header.behavior it is fixed instead, and is painted back in (and loses overlayInk) once the reader has scrolled — so white text never ends up on a light bar over body text. Set both keys for the classic landing-page header; neither duplicates the other. The dropdowns and the phone menu keep the page's ink, since they sit on the page's surface.

The nav's colour and face, a floating header, a gradient

Three more chrome.header keys:

{
  "chrome": {
    "header": {
      "navInk": "#6e6e73",
      "navInkDark": "#a1a1a6",
      "navFont": "mono",
      "floating": { "inset": 12, "radius": 999, "maxWidth": "1080px" },
      "background": {
        "gradient": {
          "type": "linear",
          "angle": 90,
          "stops": [{ "color": "#0b0b10" }, { "color": "brand", "at": 100 }]
        }
      }
    }
  }
}
  • navInk / navInkDark colour the nav items at rest — a muted nav beside a full-ink brand — and leave the hover to the brand. navFont sets them in the heading, body or mono face.

  • floating lifts the bar off the edges into a rounded pill: inset px from the viewport edge (default 12), radius px (default 16; 999 is a pill), and maxWidth as a length (default the shell). A sticky floating header stays inset below the top. floating.phone decides the pill on a phone (below 768px): full spans the screen inset to inset, the brand at one end and the menu at the other; pill (and unset) keeps today's pill, as wide as what it holds. From 768px up the pill fills up to maxWidth (it used to shrink to its contents, so a 1204px cap drew a 975px pill).

On a phone, a header with a menu button keeps the brand, the search and the menu on ONE row: the brand gives way — its name ends in an ellipsis and a wordmark scales down — rather than the menu dropping to a second line. A header without a phone menu (mobileNav: "bar") wraps as it always did.

  • background.gradient (and gradientDark for dark mode) is the same shape a section background's gradient takes: linear at an angle or radial, two to four stops, each a hex or one of the reader's roles — bg, surface, panel, ink, muted, brand, accent — with an optional at percentage. It paints over palette.header_background.

Buttons: colours, size, a third one

chrome.cta takes background, ink, size (sm, md, lg) and showOnPhone (keep it in the phone header row instead of the menu); chrome.secondaryCta takes ink, background and underline: false; and ctaStyle / secondaryCta.style gain subtle — a soft wash of the brand behind brand ink.

{
  "chrome": {
    "cta": {
      "label": "Get started",
      "href": "/start",
      "background": "#000000",
      "ink": "#ffffff",
      "size": "lg",
      "showOnPhone": true
    },
    "secondaryCta": {
      "label": "Sign in",
      "href": "/login",
      "style": "link",
      "ink": "#6e6e73",
      "underline": false
    }
  }
}

A header with THREE buttons uses chrome.ctas — up to three, in order, each {label, href, style, background, ink, underline}. When it is set it replaces cta and secondaryCta; the first is [data-fx-cta], the others [data-fx-cta-secondary], and each carries [data-cta-index].

{
  "chrome": {
    "ctas": [
      { "label": "Contact sales", "href": "/contact", "style": "link", "underline": false },
      { "label": "Sign in", "href": "/login", "style": "outline" },
      { "label": "Start free", "href": "/start" }
    ]
  }
}

The buttons' own inks are written with no weight at all, so an elementStyles colour on [data-fx-cta] now wins over them — and over the readable-ink correction a painted header applies, which used to leave white on white.

Rows above and below the header

{
  "chrome": {
    "topBar": {
      "links": [
        { "label": "Status", "href": "/status" },
        { "label": "Contact", "href": "/contact" }
      ],
      "background": "#f5f5f7",
      "ink": "#1d1d1f",
      "height": 32,
      "align": "end"
    },
    "subnav": {
      "links": [
        { "label": "Overview", "href": "/" },
        { "label": "Guides", "href": "/guides", "divider": true }
      ],
      "navSize": 13,
      "navWeight": 500
    }
  }
}

topBar is a utility row ABOVE the header row — plain links (up to eight), its own fill, ink and minimum height, links at the end (default) or start. subnav is a section row BELOW it, and its items are header items: a dropdown or a mega menu works there exactly as in the main row. Both line up with the header's frame. Hooks: [data-fx-top-bar], [data-fx-subnav].

Header search, the desktop menu, dividers

  • chrome.search: none (default), icon (a magnifier) or pill (a field-shaped button). Either opens a small box that searches this space's posts — or its pages, on a space that has no posts. Hook: [data-fx-header-search].

  • chrome.desktopNav: "menu" puts the header links behind the menu button on a wide screen too; the buttons and controls stay in the bar.

  • headerLinks[].divider: true draws a hairline before that item — grouping "Product · Pricing | Docs · Blog". On the phone menu it is a rule. Hook: [data-fx-nav-divider].

  • chrome.themeToggle.position: "footer" moves the light/dark control out of the header into the footer's bottom row.

{
  "chrome": {
    "align": "left",
    "header": {
      "linksAlign": "start",
      "controlsAlign": "end",
      "brandSize": 18,
      "navSeparator": "·"
    },
    "topBar": {
      "links": [{ "label": "Status", "href": "/status" }],
      "style": "tab",
      "phone": "hide"
    },
    "subnav": {
      "links": [
        { "label": "Overview", "href": "/" },
        { "label": "Guides", "href": "/guides" }
      ],
      "phone": "menu",
      "justify": "between"
    }
  }
}

Key

What it does

header.linksAlign

end (default — the links beside the buttons), start (right after the brand, the buttons alone at the far end) or center (centred between the brand and the controls on a wide screen — exact when those two are about the same width)

header.controlsAlign

end keeps the buttons and controls at the far end whatever align packs the rest to, so align: "left" no longer drags them in after the links. Unset = inline

header.brandSize

The brand NAME's size in px, in the header (10–64), or {sm, md, lg}. The name gets a line box to match, so a big name's descenders are never clipped

header.navSeparator

One or two characters drawn between top-level nav items — ·, /, |. Decoration: a screen reader skips it, and the phone menu does not draw it

header.scrollTitle

true shows a post's title in the header once the title has scrolled out under it — links give way in place (no layout shift), fading only without reduced motion. Off by default; pages that are not posts are unchanged. "site" also shows the brand's name on every other page once it has scrolled. Hook: [data-fx-header-title]

header.scrollTitleFont

heading (unset) | body | mono: the face of the scrolled-in post title. It takes the theme's heading face by default.

topBar.style

bar (default) or tab — the links in a small filled tab hanging from the top edge at the align end. background fills the tab. Hook: [data-fx-top-bar-tab]

topBar.phone

On a phone: wrap (default), scroll (one row that scrolls sideways), hide, or icons (a link with an icon shows the icon alone, its words kept for screen readers)

subnav.phone

On a phone: wrap (default), scroll, menu (the items move into the header's menu panel) or hide. With mobileNav: "bar" there is no panel, so menu wraps

subnav.justify

start (default) packs the items; between spreads them across the row

Round-4 keys (2026-10-11)

Key

What it does

header.height

px, or {sm, md, lg}: {"sm": 56, "lg": 76} is 76 on a desktop and 56 at 640px and below. md is 1024px and below, and the header folds at 768px, so {sm: 56, lg: 76} is still 76 on a 700px tablet. An overlay clears each step's own height

header.logoHeight

px, or {sm, md, lg} — a tall phone wordmark is {"sm": 56, "lg": 28}. There is no separate phone key

header.brandPrefix

{text, ink, inkDark} — the first part of the brand name in its own ink ("/ " before "Blog"). Drawn inside the name, so the lockup keeps one accessible name. Hook: [data-fx-brand-prefix]

header.menuButton

{background, ink, radius, size} — the phone menu button (size is its square, 32–64px). Unset is the bare glyph

header.ctaDivider

true draws a thin vertical rule before the buttons, on the wide row only. Hook: [data-fx-cta-divider]

header.group

{align: start | center | end, width} — the row as a group narrower than the bar (width is a length like "810px"), lined up with the frame's start, centre (default) or end. Wide screens only

header.borderSpan

bar (default) or group — the bottom rule under the group only. border and borderColor still say how it looks

header.groupOnScroll

full — once the reader scrolls, the group releases to the full frame

header.phoneLayout

row (default) or stacked — on a phone, the controls on the first line and the brand on its own line below

topBar.align

end (default), start, center or between

topBar.links[].icon

A glyph name (the header menus' list) or an image, before the label

topBar.links[].style

link (default) or button — a small filled pill

topBar.font / case / tracking

The bar's face (heading, body, mono), letter case (as navCase) and letter-spacing in em

subnav.background / ink

The section row's own fill and text

subnav.border

line (default) or none — the rule between the header row and the section row. (The rule under the section row is the header's own: header.border: "none")

cta.border, secondaryCta.border, ctas[].border

A 1px border in this hex

secondaryCta.size, ctas[].size

sm, md (default) or lg, as cta.size

secondaryCta.showOnPhone, ctas[].showOnPhone

Keep that button in the phone header row, beside the menu button, in order

chrome.searchStyle

{label, background, ink, backgroundDark, inkDark, size: sm | md, glyph} — the search pill's words (also the box's placeholder; default "Search"), fill, ink (and their dark-mode twins) and type size; glyph: false drops the magnifier from the pill

With chrome.align: "left" or "center", the phone menu button still ends the phone row — it no longer sits mid-row after the brand.

scroll suits a row of plain links; an item that opens a dropdown is better served by menu on a phone, where the dropdown becomes an accordion.

The top bar's row runs the full width of the frame, so align: "end" (the default) puts its links — or its tab — at the far end. It used to shrink to its links and sit in the middle of the page.

Round-5 keys (2026-10-11)

Header, top bar, theme control and footer. Every key is optional and unset renders as before, except the cycle toggle's outline (see themeToggle.border).

Key

What it does

header.border

Also per screen size: {"lg": "none", "sm": "line"} — a rule on a phone only. Steps as header.height (md 1024px and below, sm 640px and below). borderSpan: "group" follows the lg step

header.menuButton.glyph

three-bar (default) or two-bar — two hairlines

header.menuButton.desktop

true: the menu button on a wide screen too, after the links, opening the same panel. The links stay in the row (desktopNav: "menu" is the one that folds them)

headerLinks[].icon / iconOnly

A glyph or image before a header link's label; iconOnly: true draws the glyph alone on the wide row, the label kept for screen readers and as the tooltip (needs icon). The phone menu shows the label

cta.height / fontSize

Also on secondaryCta and ctas[]: the button's exact height in px (24–64) and its label size in px (10–20) — "a 32px button with 14px type", which size cannot spell. Wins over size's padding

topBar.links (max 16)

Up to 16 links (was 8)

topBar.links[].iconOnly

true: the glyph alone at every width; the label stays for screen readers and as the tooltip. Needs icon

topBar.links[].hideOn

["desktop"] (768px and up) or ["phone"] — that link at one width only. Under phone: "icons", hideOn: ["phone"] on the label-only links leaves the icons alone in the phone row

topBar.phone: "row"

The bar hides on a phone and its links move into the header row, before the menu button

topBar.button

{background, ink, radius} — the look of a style: "button" link (radius in px; 999 is a pill). Unset is the ink pill

topBar.sticky

false: the bar scrolls away and only the header row below it sticks. Ignored by an overlay header (header.overlay), which is taken out of the page's flow

chrome.searchStyle.labelPhone

The search pill's words on a phone. "" is the glyph alone on a phone

chrome.searchHideOn

["desktop"] — a phone-only header search — or ["phone"]. Both is refused: set search: "none"

themeToggle.modes

Which modes it offers, in render order: ["dark", "light"] is a two-glyph moon-then-sun pill (with style: "icon", radius: 999). cycle steps through these only. Without system, the OS's mode shows lit until the reader picks

themeToggle.border

true draws the outlined square around a cycle button. Default none — the bare glyph

footer.headingGap

px between a column's heading and its first link (default 8)

footer.headingWeight

The column headings' weight, 100–900 (default 600)

footer.bottomAlign

frame (default) or columns — the bottom row (copyright, legal links) starts under the first link column. A side brand only; on a phone it is the full row

footer.width

The footer's own frame, a length with its unit ("72rem", "1200px"), apart from the header's layout.frame

footer.art.placement: "edge"

The band straddles the footer's top rule, half above it. art.offsetY (px, −300…300) nudges it down (+) or up (−)

footer.art.direction

down-right (default), down-left, up-right or up-left — which way the streaks run

footer.art.opacity / blur

0–1 (for the glow preset, its intensity — default 0.3, an ambient wash), and a blur in px (0–40), on the band

footer.newsletter.subheading

A muted line under the newsletter heading (≤ 200 chars). In split it sits under the heading, in its column

footer.newsletter.consentPlacement

full (default) or form — in a split row, the consent note sits under the form's column rather than across both

Wave-6 keys (2026-10-11)

Key

What it does

header.iconSize

The header's control glyphs — search, the theme toggle's icons, the menu button — in px (10–40), or {sm, md, lg}: {"lg": 16, "sm": 20}. Unset: each its own

themeToggle.footerAlign

With position: "footer": end (default — the bottom row's end) or last-column — at the start of the last link column above it, from 768px up. No link columns: end

footer.columnWidth

Each link column's width in px (40–600), wide screens: fixed tracks from the columns' start, columns of them (else one per column), 24px apart — the pitch is this + 24

footer.contentWidth

Like width, measured inside the gutter: "1016px" is the words' width. Wins over width

footer.padding

{top, bottom} px — the brand-and-columns block's padding: the room from the top rule to the headings, and under the columns. Unset: 40 / 32

footer.bottomSize

The bottom row's text in px (10–24) — the copyright and its words. Its links follow linkSize when set; the credit keeps its small size

footer.htmlSize

Your footer HTML's text size in px (10–24). Unset: 14

footer.htmlLinkInk / htmlLinkInkDark

Your footer HTML's link colour, and its dark twin. Hover keeps the brand colour

footer.htmlLinkUnderline

true underlines your footer HTML's links, false takes the underline off

footer.newsletter.padding

{top, bottom} px inside the newsletter row: top is the room from its rule to the heading. Unset: 24 under a rule, else 0

footer.newsletter.splitRatio

A split row's heading column as a share of the row, 0.2–0.8. Unset: 0.4 (heading 1 : form 1.5)

footer.art.clip

rule — an edge band ends sharply at the footer's top rule; nothing spills into the columns. Default none

footer.art.glowBelow

With clip: "rule": a soft wash of the band's colour under the rule, this many px tall (0–300). Hook: [data-fx-footer-art-glow]

The bottom row's rule now runs the width of its words, inside the gutter, as the columns above it do — it used to overhang by the gutter at each end. The row's frame and gutter sit on [data-footer-bottom-frame]; [data-footer-bottom] is the row inside it.

With themeToggle.phone: "row" (or topBar.phone: "row") on a packed header (align: "left" or "center"), the phone row's items group at the end beside the menu button; the toggle no longer sits straight after the brand. A footer toggle (position: "footer") now ends the bottom row, after the credit.

linkSeparator now hangs off the link before the gap, so a wrapped line ends on a separator rather than starting with one. The bottom row's "Cookie settings" is one of the row's links: it takes linkSize, the link ink and the separator, in the columns as well.

Round-3 showcase keys (2026-10-12)

Key

What it does

brandText

auto (default) | show | hide — see What the brand lockup shows

header.linksAlign

gains after-search: the owner's links straight after the header search, wherever it sits. No header search: end

searchPlacement

gains before-links; center now centres on the header — see the docs header table above

cta.phone, secondaryCta.phone, ctas[].phone

menu | bar — keep the button in the phone row (bar) or fold it into the menu (menu). Wins over showOnPhone when both are set

ctas[].inkDark / borderDark (and on cta, secondaryCta)

The button's label and hairline in dark mode. Unset: ink / border in both schemes

headerLinks[].phone

menu (default) | bar — a plain link that stays in the phone row beside search; its menu copy hides on a phone

headerLinks[].kbd

mod-k | slash | mod-i — keycaps after the words, the search pill's; pressing the keys follows the link (never while typing)

headerLinks[].ink / inkDark / border / borderDark

The link's own colour and a 1px outline in it — a neutral pill instead of the brand-outlined style: "outline". Hooks: [data-nl-ink], [data-nl-line]

signInStyle.order

before-ctas (default) | after-ctas

subnav.links

now optional, so subnav: { "phone": "scroll" } can style a section row whose items the reader generates (the docs sections.placement: "header" tabs)

sidebarToggle

true | { "placement": "start" | "end" }

{
  "chrome": {
    "brandText": "auto",
    "ctas": [{ "label": "Sign up", "href": "/signup", "phone": "bar" }],
    "headerLinks": [
      { "label": "Ask AI", "href": "/ask", "kbd": "mod-i", "ink": "#52525b", "border": "#d4d4d8", "borderDark": "#3f3f46" }
    ],
    "signInStyle": { "look": "outline", "order": "after-ctas" },
    "sidebarToggle": { "placement": "end" }
  }
}

Unflagged with these: below 420px at most one filled button (solid, pill, subtle) stays in the phone row — the first one pinned there; the rest are drawn in the menu panel at that width — and the sign-in label never wraps. A pill with searchStyle.width gives way (down to 160px) before the buttons and the sign-in are crowded off the row. A desktop menu (desktopNav: "menu") or a sidebarToggle alone now asks for a header, like links do. The menu panel carries an empty [data-forum-menu-slot] the forum sidebar folds into.

A logo for each scheme

theme.logoUrlLight and theme.logoUrlDark are the wordmark for a light and a dark rendering; logoUrl fills in for whichever you leave unset. Both images are in the page and the RENDERED scheme picks one — the reader's own choice, a pinned colorScheme (a post's own included, so a dark story under a light blog switches the logo with no logo key of its own), the OS, and inside a footer with its own chrome.footer.scheme, that scheme. The footer shows the pair too, unless it has a chrome.footer.logo of its own.

{
  "chrome": {
    "footer": {
      "html": "<div class=\"badges\"><img src=\"https://cdn.example.com/soc2.svg\" alt=\"SOC 2\"> <a href=\"/security\">Security</a></div>",
      "css": ".badges { display: flex; gap: 16px; align-items: center; }",
      "htmlPlacement": "above",
      "newsletter": {
        "formId": "0b5a6f43-27c4-4d5b-9a51-3c1d2f8e9a10",
        "heading": "The monthly digest"
      }
    }
  }
}

Key

Shape

html

≤ 20,000 chars of your markup — badges, a note, a newsletter provider's static embed

css

≤ 10,000 chars, scoped to your html so it cannot reach the rest of the page

htmlPlacement

above the columns (default) | below them | replace them

newsletter

{ formId, heading } — a form from the Forms area, rendered inline in the footer as one row

html goes through the same sanitiser as a header HTML panel: no <script>, <style>, <iframe>, form control or on* handler survives in the markup, on any host — custom domain included — and your CSS is scoped to a class of its own. Images are routed through the reader's image rule. Your raw source is never sent to the browser: only the sanitised markup is. A provider whose embed needs an external script works on your own custom domain — the <script src> is taken out of the markup and loaded by the page (see Scripts in an HTML panel or the footer HTML); on our hosts use newsletter with a form of your own, or the provider's hosted link.

newsletter is the form block, not a second form: it posts to the same public door on this address's own API mount, works with no JavaScript, shows the form's own success message, and its responses land in the Forms area with everything else. A form that is closed or deleted renders nothing in the footer rather than an error on every page. replace with no rich footer tier (no description, columns or social links) sits where above would.

Hooks: [data-fx-footer-html] (your HTML) and [data-fx-footer-newsletter] (the row). Setting html or newsletter alone gives a lone space a footer.

The footer's look

The footer was one fixed design — the page's paper, uppercase labels over the columns, a two-then-three grid. Its colours are palette.footer_background / footer_primary (and their paletteDark twins); the rest is chrome.footer:

{
  "chrome": {
    "footer": {
      "scheme": "dark",
      "headingStyle": "plain",
      "columns": 4,
      "background": { "color": "#0b0b10", "colorDark": "#000000", "edge": "wave" },
      "newsletter": {
        "formId": "8f4c2b1e-3a5d-4e6f-9a7b-1c2d3e4f5a6b",
        "layout": "split",
        "hideLabel": true
      }
    }
  }
}

Key

What it does

scheme

light or dark: the footer in that scheme whatever the page is in, with your palette for it

headingStyle

label (today's small uppercase) or plain — sentence case, in the heading colour, no rule

columns

1–6 link columns side by side on a wide screen, or auto for one per column you wrote. Unset is today's grid

background.color

The band's fill (colorDark for dark mode). Wins over palette.footer_background for the fill only

background.image

A picture behind the footer, from your asset library or a host in imgOrigins. Wins over a gradient

background.gradient

The same gradient shape the header takes

background.edge

A shape along the footer's top, in the footer's own fill: none, wave, clouds, or an SVG URL. Hook: [data-fx-footer-edge]

newsletter.layout

stacked (default) or split — the heading beside the form

newsletter.hideLabel

Keep the field's label for screen readers only

newsletter.placement

above or below the columns, on its own row. Unset: with your footer HTML, where htmlPlacement puts it

newsletter.consent

{ text, checkbox } — a consent line under the form. text alone is a note; checkbox: true makes it a box to tick

newsletter.button

{ background, ink, backgroundDark, inkDark, border } (hex) — the submit's colours in the footer only; components.button paints the rest of the site

newsletter.rule

true draws a hairline (--line) above the newsletter row

newsletter.consent uses the Forms area's consent wording. A form that has its own consent line (consentText, set in the Forms area) keeps that one instead — it is the line the API requires and stores beside each answer, and the footer never draws a second box. The footer's own checkbox is enforced by the browser (required); for a consent you need on record, set it on the form. To use the footer's note instead, clear the form's consent line in the Forms area (consentText: null).

Your footer HTML can no longer widen the page: the slot contains its own paint, so a band written with margin: 0 -50vw stays inside it.

The footer's type, its brand block and a status pill

{
  "chrome": {
    "footer": {
      "headingInk": "#6e6e73",
      "headingInkDark": "#a1a1a6",
      "headingFont": "mono",
      "headingSize": 12,
      "font": "body",
      "showBrandName": false,
      "brandPlacement": "top",
      "status": { "monitorSpaceId": "0b5a6f43-27c4-4d5b-9a51-3c1d2f8e9a10" }
    }
  }
}

Key

What it does

headingInk / headingInkDark

The column headings' colour, and its dark-mode twin

headingFont

The headings' face: heading, body or mono

headingSize

The headings' size in px (10–32)

font

The whole footer's face: heading, body or mono

showBrandName

false keeps the footer's marks and drops the words; true shows the words even beside a wordmark. Unset follows the header

brandPlacement

side (default — a column beside the links), top (a full-width row above them), bottom (a row after them) or none — no brand block: the columns take the full width, and the description, social marks and a cookieLinkPlacement: "brand" control move to the bottom row

status.monitorSpaceId

One of your STATUS spaces. The bottom row gets a live pill — "All systems operational", a dot in the state's colour — linking to it

status.style

pill (default, bordered) or text — no border, the dot kept

status.color / case / font / dot

The words' colour (hex); upper for capitals; mono or body; dot: false drops the dot

brandWidth

The brand column's width, side placement only: a % of the row (10–60, decimals allowed — 16.67) or a length ("304px", "19rem"). Unset is today's 1.4 : 2

border

false drops the rule along the footer's top. The bottom row keeps its own

bottomRule

false drops the rule above the bottom row

linkSize

The footer's links (columns, bottom row, the rows link row) in px (10–24)

linkGap

Space between two links in a column, px (0–40). Default 6

linkInk / linkInkDark

The links' colour, and its dark-mode twin (also used on a scheme: "dark" footer). Hover keeps the brand colour

linkWeight

The links' weight, 100–900

layout

stack (default) or rows — see Footer layouts

linkSeparator

none (default), pipe, dot or slash between the links of a flat row (the bottom row, the rows link row)

socialStyle / socialSize

The social marks plain (default) or framed in a circle / square; their size in px (16–48)

breadcrumb

true adds a row: your mark › your home link › the page's title. breadcrumbPlacement: top (default, above the columns) or bottom

phone

stack (default — open lists) or accordion: on a phone each column is a heading that opens; from 640px up they are always open

The pill reads the status page's own board, so it cannot disagree with the page; a page with too little history says "Status unknown", never "operational". The id must name a status space of yours — anything else is a 422 naming the key. Hooks: [data-fx-footer-heading], [data-fx-footer-brand], [data-fx-footer-status] (with [data-status]), [data-fx-footer-crumbs] (the breadcrumb row).

The breadcrumb's last crumb is the page's own title ([data-fx-page-title]), read once the page has rendered; a page without one (a front page, a list) shows just your mark and home link. For an extra line such as "More ways to shop: Find a store", use chrome.footer.html with htmlPlacement: "above" — a plain <p> with a link needs no css.

A column with "stack": true continues the column before it: its heading and links sit under that column's, in the same grid track, instead of wrapping into the grid's next row. columns: "auto" counts tracks, not groups. In the console's footer-columns box, start a heading with + to stack that block. footerColumns takes up to 12 columns of up to 20 links each.

Two worked recipes, both with no CSS:

{ "chrome": {
  // …twelve of these: each column is a heading and its links.
  "footerColumns": [{ "heading": "Product", "links": [{ "label": "Docs", "href": "/docs" }] }],
  "themeToggle": { "position": "footer" },
  "footer": { "brandPlacement": "bottom", "columns": 6,
    // The id of your STATUS space.
    "status": { "monitorSpaceId": "4f9c2d1e-8b7a-4c3d-9e2f-1a0b5c6d7e8f", "style": "text" } } } }

Columns first (Kestrel): brandPlacement: "bottom" draws the brand row after the column grid, columns: 6 lays twelve columns out as two rows of six, and the status reads as a line of text beside the theme toggle in the bottom row.

{ "chrome": {
  "footerLinks": [{ "label": "Privacy", "href": "/legal/privacy" }],
  "socialLinks": [{ "platform": "github", "href": "https://github.com/example" }],
  "footer": { "layout": "rows", "linkSeparator": "pipe", "socialStyle": "circle" } } }

Rows (Kilter): the brand lockup on the left with the circled social marks on the right, then footerLinks as one flat row with | between them, then the legal row (copyright, consent, credit). Link columns, if you set any, sit between the first two rows.

chrome.footer.art is a fixed-height band of artwork above or below the columns — no html, no css:

{ "chrome": { "footer": { "art": {
  "preset": "streaks", "color": "#e5132f", "height": 150, "fade": "both", "placement": "above" } } } }

Key

What it does

preset

streaks (five skewed bars), grid (a hairline grid, tiled) or glow — drawn in color

image

An SVG/PNG from your asset library instead, drawn as it is. One of image or preset — both is a 422

color

The preset's colour (hex). Default your brand colour

height

px, 24–600, or { "lg": 150, "sm": 90 }. Default 150

fit

cover (default), contain or repeat-x

position

center (default), left or right

fade

none (default), both, top or bottom — the band fades out at those edges

placement

above the columns (default), below them, or top — above everything, the newsletter included

Hook: [data-fx-footer-art] (with [data-art], [data-preset], [data-fade]). It is decorative: hidden from screen readers, and it takes no clicks.

Every headerLinks item is still the { label, href } it always was, and one stored before this existed renders exactly as it did. An item can also open something when the reader hovers over it, clicks it or tabs to it. It opens one of three things:

  • A dropdown list (children): links, each with an optional one-line description, icon and badge.

  • A mega menu (panel with columns): up to four columns of links with optional headings, an optional image card (featured), and an optional footer row of links plus a call to action (footer).

  • A panel of your own HTML (panel with html, plus optional css).

All of this works the same in a site's config (at its top level, beside its other chrome keys) and in a lone space's theme.chrome. It shows on every page of the site or space and on its 404.

An item

Key

Shape / limit

label

1–60 chars — the word in the bar

href

where the word goes. Optional for an item that opens something; when set it stays a real link, even with JS disabled

children

1–12 links — a dropdown list. Not allowed together with panel

panel

a mega menu or an HTML panel (below). Not allowed together with children

style

link (default) | button (filled with the brand, like the CTA) | outline

highlight

boolean — the brand's ink and a heavier weight, e.g. for "Pricing"

An item with neither href, children nor panel is refused, because it would go nowhere.

A link inside a dropdown or a column — { label, href, description?, icon?, badge? }: description ≤ 160 chars, badge ≤ 24 chars ("New", "Beta"). icon is either one of our glyph names or an image URL (https://…, a library path /api/assets/…, or a data: PNG, JPEG, WebP or GIF image — not an SVG, which is a script container; upload the SVG and use its /api/assets/… URL). The glyph names are: book, book-arrow, code, message, calendar, chart, pie-chart, puzzle, lock, shield, star, bell, search, sparkle, link, cog, clock, heart, image, trophy, medal, crown, headset, webhook, ticket, board, tracker, template, check-circle, warning, translate, share, eye, bookmark, hash, chat-app, team-chat, upload, pencil, quote and user. A glyph takes the link's own colour. Anything else that is not an image URL is refused with a 422 that names it, so a misspelled glyph does not silently become a broken image.

Menus go one level deep. A dropdown's links have no children, and sending children inside one is a 422 that names …children.0.children. A mega menu's columns are the only extra level.

A panel

Key

Shape / limit

columns

≤ 4, each { heading? (≤60), links (1–10) }

featured

{ title (1–80), href, image?, text? (≤240) } — an image card after the columns

footer

{ links? (≤4 {label, href}), cta? {label, href} } — a row along the bottom

html

≤ 20,000 chars of your own markup. Not allowed together with columns/featured/footer

css

≤ 10,000 chars, only with html. Scoped to the panel

width

auto (default: at least navMenu.width) | content (as wide as its content) | full (the header's width)

align

start (default) | center | end — which edge of the item the panel lines up with

navMenu: how menus open and look. Every key is optional. Leaving all of them out gives a panel in your own surface colour, with a hairline border, a 10px radius, a medium shadow and a quick fade.

Key

Default

Meaning

trigger

hover

hover opens on hover and on click and keyboard; click opens only on click and keyboard

openDelay

120

ms of hover before it opens (0–1000). A pointer that only passes over the bar opens nothing

closeDelay

200

ms of grace after the pointer leaves (0–2000), so a diagonal move to the panel keeps it open

animation

fade

none | fade | slide | scale. Readers who ask for reduced motion always get none

radius

10

panel corner radius in px (0–48)

shadow

md

none | sm | md | lg

background

your --surface

6-digit hex

ink

your --ink

6-digit hex — the panel's text

border

your --line

6-digit hex

width

240

minimum panel width in px (160–960)

How readers use it. Desktop: hovering over the word opens the menu after openDelay. Clicking the word, or the caret beside a word that has its own href, opens it and keeps it open. A click on an open menu that hovering opened pins it. A second click closes it. From the keyboard, Enter or Space toggles it, ↓ opens it and moves into it, ↑/↓/Home/End move between its links, and Escape closes it and returns focus to the button. Only one menu is open at a time. A menu closes when the reader clicks outside it, tabs out of it, follows a link in it or moves to another page. The button carries aria-expanded and aria-controls: this is the disclosure pattern, not role="menu", so Tab keeps its ordinary meaning. Phones: in the hamburger menu (mobileNav: "menu"), each item that opens something becomes an accordion. A dropdown is a list, a mega menu's columns stack, and an HTML panel renders inline. With JavaScript off, every href still navigates, desktop menus open on hover and on keyboard focus, and the phone accordions still open.

A simple dropdown (each example is a whole theme body. On a site, put the headerLinks list at the top level of config instead):

{
  "chrome": {
    "headerLinks": [
      {
        "label": "Product",
        "href": "/product",
        "children": [
          {
            "label": "Docs",
            "href": "/docs",
            "description": "Guides and reference",
            "icon": "book"
          },
          { "label": "API", "href": "/reference", "icon": "code" },
          { "label": "Changelog", "href": "/changelog", "badge": "New" }
        ]
      }
    ]
  }
}

A mega menu with a featured card:

{
  "chrome": {
    "headerLinks": [
      {
        "label": "Solutions",
        "panel": {
          "width": "full",
          "columns": [
            {
              "heading": "By team",
              "links": [{ "label": "Support", "href": "/support", "icon": "headset" }]
            },
            { "heading": "By size", "links": [{ "label": "Startups", "href": "/startups" }] }
          ],
          "featured": {
            "image": "/api/assets/0f0e0d0c-0b0a-4908-8706-050403020100",
            "title": "How Acme cut tickets by 40%",
            "text": "A two-minute read.",
            "href": "/customers/acme"
          },
          "footer": {
            "links": [{ "label": "All solutions", "href": "/solutions" }],
            "cta": { "label": "Talk to sales", "href": "/contact" }
          }
        }
      }
    ],
    "navMenu": { "animation": "slide", "shadow": "lg", "radius": 14 }
  }
}

A panel of your own HTML:

{
  "chrome": {
    "headerLinks": [
      {
        "label": "Resources",
        "panel": {
          "html": "<div class=\"grid\"><a href=\"/blog\"><img src=\"/api/assets/0f0e0d0c-0b0a-4908-8706-050403020100\" alt=\"\">Blog</a><a href=\"/webinars\">Webinars</a></div>",
          "css": ".grid{display:grid;grid-template-columns:repeat(2,12rem);gap:1rem} img{width:100%;border-radius:8px}",
          "width": "content",
          "align": "center"
        }
      }
    ]
  }
}

The script rule for HTML panels. Your panel's markup is cleaned the same way a custom-code block's is, on the way out, on every host. Every <script>, <style>, <link>, <meta>, <base>, <object>, <embed>, <form>, <input>, <button>, <select>, <textarea> and <template> is removed, along with every on… attribute (onclick, onerror, …) and every javascript: URL. That applies on your own domain too: inline code in a panel never runs. An external <script src="https://…"> in the panel is the one exception, and only on your own custom domain — see below. For anything inline, put the code in customJs and target [data-fx-nav-panel]. customJs runs only on your own custom domain (see Where owner and vendor scripts run), never on ours. Each panel that opens also fires a forumulate:nav-open event on document, with { detail: { id } } naming the panel's element id, so a script can react when a panel opens. Your css is scoped to the panel, so h2 { … } styles only the panel's own h2. :root, html and body mean the panel itself, and @import is removed. Images in your HTML and in url(…) in your CSS follow the same address rule as every other image on the page, so a library path (/api/assets/…) also loads on a domain that hosts the space under a path.

A widget that ships as <div id="status-widget"></div><script src="https://status.example.com/embed.js"></script> can go straight into a nav item's panel.html or into chrome.footer.html:

  • Only on your verified custom domain. There the <script src> is taken out of the markup and loaded by the page once (not once per copy of the panel — the desktop row and the phone menu share it), through exactly the mechanism a customHead <script src> uses: the page's CSP nonce, the functional consent tier, after the page is interactive. On forumulate.com, a *.forumulate.com subdomain or /p/… it is dropped, the same rule as every owner script (see Where owner and vendor scripts run).

  • External https only. A src must be an absolute https:// URL. Inline <script> bodies, on… handlers, javascript: URLs and relative or http: srcs are always removed, on every host.

  • Allowed automatically. Each script's origin is added to the address's script origins — the list the reader's CSP script-src and Trusted Types allowlist are built from — so you do not have to repeat it in scriptOrigins. It shares that list's cap of 20 origins with everything else the space loads, and at most 20 scripts are taken from one chrome.

  • Find your markup by a selector. The script is not placed inside the panel, so document.currentScript.parentNode is not your panel; give your markup an id or class and look it up, or listen for forumulate:nav-open.

  • The chrome a page wears decides. A space inside a site loads the site's panel and footer scripts, not those in its own (unrendered) theme.chrome.

Hooks. [data-fx-nav-item] is every top-level item, plain or not. [data-fx-nav-panel] is what an item opens (on a phone, the accordion's body). [data-fx-nav-link] is a link inside a panel, and [data-fx-nav-featured] is a mega menu's image card. An open desktop item carries [data-open="true"]. See the fx-1 hook set.

Clearing. PATCH {"chrome": {"navMenu": null}} removes every menu setting, {"chrome": {"navMenu": {"shadow": null}}} removes one, and on a site it is PATCH /orgs/{orgId}/sites/{siteId} with {"config": {"navMenu": null}}. headerLinks is a list and is replaced as a whole. A misspelled key at any depth, such as navMenu.delay, children.0.desc or panel.columns.0.title, is a 422 that names it.

In the console, Header navigation (in a space's theme and in a site's navigation editor) lists the items. For each one you choose what it opens. You type a dropdown's links one per line as Label | URL | description | icon | badge, edit a mega menu as JSON, and write an HTML panel as HTML plus CSS. The menu look settings sit under the list.

sticky, height and align are settings rather than CSS: a theme that positions our chrome by hand breaks the first time the markup changes, which is exactly what the data-fx-* hooks exist to avoid. elementStyles does take position (relative, sticky, or absolute inside a named block — never fixed), offsets and a z-index of 0–10, for laying out YOUR content (an overlapping card, a sticky rail); the header is layer 30, above any of it. See "Size, pictures, underlines and layering" in the advanced theming guide.

A site's header lists its member spaces by name. headerSpaceIds curates that list: unset means every space, a list of ids means those in that order, and [] means none — for an owner whose headerLinks are the whole bar. [] used to mean "every space", so there was no way to say "none".

A footer column's label is heading. Every link carries label; a column carries heading. Sending title or label for a column is a 422 naming the key — it used to be a silent 200 that rendered a column with no heading.

The header bar renders only once there is something to put on it (headerLinks, a cta or a brand mark); the footer only once something fills it. A themed space that configures no chrome still gets its colours and font — the theme belongs to the space, not to the furniture.

Both of the things this guide used to list as "not configurable here" are settings now: the footer's lockup is chrome.footer.logo / logoMark, and chrome.footer.cookieLinkPlacement says where "Cookie settings" sits. The one part that is still not a choice is whether that control exists at all — see chrome.footer above for why, and note that it renders nothing when your organization has no preference centre to open.

The reader's account control, and the forum header keys

Signed in, the reader's control is an avatar that opens their menu: Profile, Bookmarks, Notifications and Preferences on a forum, and Sign out everywhere. On a forum a bell sits beside it. The control wears the header nav's ink (header.navInk / navInkDark, font and case), so it reads on a dark header. Hooks: [data-fx-signin], [data-reader-account], [data-reader-bell].

Key

What it does

signInStyle

{look, icon, label}. look is link (default, today's text link), solid (a primary button, [data-fx-cta]) or outline ([data-fx-cta-secondary]), so components.button and the CTA colours reach it. icon is a glyph name or image before the label. label is 1–30 chars (default "Sign in"). order: before-ctas (default) or after-ctas — "Create topic · Log in". The control is in the server-rendered HTML for a request with no session cookie on a forum (or wherever signInStyle is set), so it no longer pops in after the page loads

cta.icon, ctas[].icon

A glyph name (the header menus' list) or an image, drawn before the button's label

ctas[].action

signIn or signUp: the button opens this space's reader sign-in instead of following href, and renders nothing once the reader is signed in. href stays required: it is the no-JS fallback, and what a space with no reader provider links to

subnav.backgroundDark / inkDark

The section row's fill and text in dark mode. Unset keeps background / ink in both

subnav.active

{underline, width}: an underline under the active section item (underline is a hex or brand, width 1–4px, default 2). The active item is the one a link's match marks aria-current

header.accentTop

{color, width}: a stripe across the top edge of the header. color is a hex or brand; width 1–8px, default 3

sidebarToggle

true draws a menu button at the START of the header, before the brand, that opens the page's sidebar drawer (on a forum: categories, tags, about). It shows only on a page that has a drawer. header.menuButton.desktop is the other menu button: at the end, header links only. { "placement": "end" } draws it at the far END of the row instead (a phone's thumb side)

{
  "chrome": {
    "signInStyle": { "look": "outline", "icon": "user" },
    "ctas": [{ "label": "Join", "href": "/join", "action": "signUp", "icon": "sparkle" }],
    "sidebarToggle": true,
    "header": { "accentTop": { "color": "brand", "width": 3 } }
  }
}

The 404 page

notFound (in the theme, not in chrome) says what a reader sees at an address under this space that does not exist. There are two ways to fill it: words (a heading, a paragraph, some links), or a page of your own, built from the same blocks as any other page and shown as the 404's body.

Key

Shape / limit

pageId

a page whose blocks are the 404's body (below)

hidePage

boolean, default true — take that page out of the navigation, the sitemap and search

title

≤ 120 chars — replaces "This page isn't here". With pageId, it is the tab title and the fallback's heading

body

≤ 600 chars — the paragraph under the heading. Not shown when a page renders

links

≤ 8 { label, href } — offered under "You may have wanted"

showSearch

boolean, default true — false leaves the search box off

showSections

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

hideSearch

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

pageSpaceId

written by us when pageId is saved: the space the page lives in. Anything you send here is replaced

redirect

none (default) | home — on a documentation space, a missing page answers a 307 to the space's front door instead of the 404 page

A page as the 404

{ "notFound": { "pageId": "3292a77d-91b9-4aec-9ea2-7207a85e1bbc", "showSections": false } }

Write the page in the editor like any other — headings, images, cards, buttons, tabs, a form, an embed — publish it, and choose it under Theme → Page not found (404) → Page to show as the 404 (or set pageId through the API). On every address that is not a page, its blocks replace the stock heading and paragraph:

  • It still answers 404. The status, and the noindex every reader 404 carries, do not change. That is the difference from a "soft 404" — a missing page answered 200 — which search engines penalise: a crawler is told the truth, and a reader gets the page you designed.

  • Your header and footer stay, exactly as on every other page (below). Under the page come the search box and the section list, unless showSearch: false / showSections: false, then any links.

  • It is rendered the way a docs page is: the same renderer, your library images through this address, external images through our image proxy, embeds only from the hosts you trust, and the interactive blocks working — code highlighting, diagrams, spoilers, extension blocks and forms.

  • Where the page can live. In a space's theme, pageId must be a page of that space. In a site's config.theme.notFound, it may be a page of any space in the site, and that 404 is what readers see at a site address that names no section (/nope); a space inside the site that sets no notFound of its own uses the site's too. A pageId that is not there is refused with a 422 naming the key (notFound.pageId, or config.theme.notFound.pageId on a site) — it is checked when the theme or theme draft is saved and again when a draft is published.

  • The page itself is hidden (hidePage, on unless you send false). We set the page's own Hide from navigation and noindex flags, so it leaves the sidebar, llms.txt, the sitemap and search, and its own address stops being indexed. Both flags show in the page's details panel; choosing a different 404 page later does not clear them on the old one — untick them there if you want it back.

  • If it cannot be shown, the words are. A page that has been deleted or unpublished, a space that turned private, an API that does not answer in time: the 404 falls back to the words-and-links screen (title, body, links, plus a link to the page in case it opens when followed). The 404 never turns into an error screen because of the page.

This works on every reader address: the platform address (/p/…, /b/…, /f/… and the rest), a site's own domain, a space mounted on a path of your domain, and a site address whose first segment names nothing.

Around the body

Everything around the body is your chrome, not ours — and it is the same chrome, rendered by the same components, as every other page of the space. The 404's header is your header: the brand lockup, your header links, your cta, "Sign in" (unless chrome.showSignIn: false), the light/dark toggle at the themeToggle.position you chose (start, end or menu), and the phone menu. Its footer is your full footer: footerDescription, footerColumns, footerLinks, social marks, copyright and "Cookie settings" where chrome.footer.cookieLinkPlacement puts it. A pinned colorScheme sets data-theme on <html>, as on any page. The same precedence applies: a space inside a site wears the site's header and footer on its 404 too, and a space that configures no header or footer gets what its pages get — the toggle row, the sign-in bar and the consent control. Your faviconUrl is in the tab. With the words (no page), the body is followed by a link home and the failed address; then, in both cases, the search box and the sections this address publishes, each unless switched off.

Announcement strip

{
  "announcement": {
    "text": "We are moving to v2 on Friday",
    "level": "warning",
    "href": "/blog/v2"
  }
}

text 1–280 chars; level is info (default) | warning | danger; href optional. Rendered by the space's chrome, above the header and spanning the full page width, on every reader surface of the space — index and piece alike. It is in the server-rendered HTML, so curl | grep data-announcement-banner finds it. (It used to be fetched by each page after first paint, and only five of the twelve surfaces mounted it.)

info and warning carry a dismiss button and are remembered per visitor, keyed on the text — editing the notice brings it back. A danger notice cannot be dismissed. Not rendered inside an embed (?embed=1): an embed is somebody else's page.

href can be a path on this site (/blog/v2), a fragment (#install), or an absolute https:, mailto:, tel: or sms: URL, like every other link in the reader's chrome.

Since October 2026 the strip also takes, all optional:

Key

What it does

background, ink

Hex colours for the strip and its words, in place of the level's tint

backgroundDark, inkDark

Their dark-mode twins

gradient

A gradient over background — the shape a section background's gradient takes

dismissible

true/false overrides the rule above (dismissible unless danger)

lead

≤ 60 chars, bold words before text — "New:"

button

{ label (≤40) } — a small filled button after the text, linking to href (required with it)

pages

≤ 50 page ids, and "front" for the front page — show it on those pages only. Pieces cannot carry their own announcement; this is how a landing-only banner is built

style is bar (default, the tinted full-width strip) or inline: no fill, the text in the page's ink, the link as a small pill, and a hairline rule under it. placement is above-header (default) or below-header, between the header and the page; a space with no header keeps it on top. Hooks: [data-announcement-banner][data-style], [data-placement].

span (2026-10-12) is page (default — edge to edge) or content: with placement: "below-header", the strip is as wide as the page's content, and on a documentation page with a sidebar its left edge is the article's (the sidebar beside it stays clear). Read only with below-header. Hooks: [data-announcement-banner][data-span="content"] inside [data-ann-frame].

{ "announcement": { "text": "Astro 5 is out", "placement": "below-header", "span": "content" } }

Component shape (components)

Colour is only half of a look. components sets the SHAPE of the reader's built-in components — radius, elevation, the focus ring, the code, input and button surfaces, the vertical rhythm, selected text, the scrollbars and the syntax palette — so a brand with square corners, a thicker focus ring, roomier prose or a dark code block does not need customCss aimed at our class names.

{
  "components": {
    "radius": 2,
    "shadow": "sm",
    "focus": { "color": "#2d6cdf", "width": 3, "offset": 2 },
    "code": { "background": "#0d1117", "ink": "#e6edf3", "border": "#30363d", "radius": 4 },
    "input": { "border": "#d4d4d8", "radius": 2 },
    "button": { "radius": 999, "weight": "semibold", "shadow": "none" },
    "spacing": { "scale": 1.15, "pad": 1 },
    "selection": { "background": "#2d6cdf", "ink": "#ffffff" },
    "scrollbar": { "width": "thin", "thumb": "#30363d", "track": "#0d1117" },
    "syntax": { "comment": "#8b949e", "keyword": "#ff7b72", "string": "#a5d6ff" }
  }
}

Dark twins, the codeDark idiom — a sibling *Dark object whose slots fall through to the light one: components.inputDark and components.buttonDark, each { background, ink, border }, emitted only in dark mode. (A request for button.backgroundDark is spelled buttonDark.background: one shape for every component's dark twin.)

{
  "components": {
    "button": { "background": "#111111", "ink": "#ffffff" },
    "buttonDark": { "background": "#ffffff", "ink": "#111111" },
    "inputDark": { "background": "#18181b", "border": "#3f3f46" }
  }
}

Every panel that opens over the page wears one surface: the header's menus and mega menus, the account menu, the notification bell, the docs version / variant / SDK pickers, "On this page" and page-action menus, share and post-action menus, the forum's filter pickers and the header search box. Unset, the panel is your palette's surface colour in each mode, with a 1px line border, a 10px corner and a soft shadow — always opaque.

Key

Type

What it sets

background

#rrggbb

Panel fill (and dark, unless backgroundDark)

backgroundDark

#rrggbb

Panel fill in dark mode

ink

#rrggbb

Text in the panel

inkDark

#rrggbb

…in dark mode

border

#rrggbb

The 1px border

borderDark

#rrggbb

…in dark mode

radius

number, 0–32 (px)

Corner radius

shadow

none | sm | md | lg

Elevation

blur

number, 0–40 (px)

Frosted glass: blurs what is behind the panel

blur is the only way to make a panel see-through: with it set, the fill is mixed to 82% so the blur has something to show. Leave it unset under a header that already blurs (chrome.header.blur, motion.header.blur) — a blur inside a blur only sees the header, and the panel reads as transparent.

The header's dropdowns take their radius, shadow and width from chrome.navMenu; a colour set there wins over this one for those panels.

{
  "components": {
    "popover": {
      "background": "#ffffff",
      "backgroundDark": "#17181c",
      "border": "#e4e4e7",
      "borderDark": "#2a2b31",
      "radius": 12,
      "shadow": "lg"
    }
  }
}

Published as --popover-bg, --popover-ink, --popover-border, --popover-radius, --popover-shadow and --popover-blur on the reader, for customCss that draws a panel of its own. Panels sit on one stacking scale — --z-header 30, --z-popover 40, --z-drawer 60, --z-modal 70 — and the region holding an open panel is lifted above its neighbours, so a menu is never painted under the row or the content beside it.

Selects and the language switcher (components.select)

Every dropdown on a reader page — a filter or sort, a form field, the theme switcher, the docs version and variant pickers, the language switchers — is drawn by the reader, not by the operating system, so it wears your tokens instead of a grey OS box. It keeps a native select's behaviour: ↑/↓, Home/End, typing a label to jump, Enter/Space to choose, Esc to close; it works before the page has finished loading and without JavaScript (the native control is the fallback), and its list floats above headers, sidebars and panels, so it is never clipped.

Unset, the box is components.input (then your line and ink colours), the open list is components.popover, the focus ring is components.focus.

Key

Type

What it sets

style

custom | native

native: the operating system's own control. Unset: custom

background, backgroundDark

#rrggbb

The box's fill (unset: components.input.background)

ink, inkDark

#rrggbb

Text in the box

border, borderDark

#rrggbb

The box's 1px border

radius

number (px)

The box's corner (unset: components.input.radius, then radius)

optionHover, optionHoverDark

#rrggbb

The highlighted option. Unset: a light wash of the text colour

optionSelected, optionSelectedDark

#rrggbb

The chosen option's row. Unset: no fill (bold, with a check)

check

#rrggbb

The chosen option's check mark. Unset: your brand ink

{
  "components": {
    "input": { "radius": 999, "border": "#2a2b31" },
    "select": { "optionHover": "#1f2a24", "check": "#20e3a2" },
    "popover": { "backgroundDark": "#0e0e14", "radius": 12 }
  }
}

Published as --reader-select-* on the reader. Per-element overrides go through elementStyles on the stable hooks: [data-fx-select] (the box), [data-fx-select-menu] (the open list), [data-fx-select-option] (one option; [aria-selected="true"] is the chosen one, [data-active] the highlighted one) and [data-fx-language-switcher] (any language switcher's box, wherever it is placed).

Where the language switcher goes (i18n.switcher)

A translated docs space shows a switcher between its languages; so do a translated changelog, blog, forum, FAQ, status page and legal document. Where it sits, and whether it is the dropdown above or a row of pills, is one key:

Key

Values

Unset

placement

header | sidebar | footer | content-top | none

Where each surface always put it: the docs sidebar; above the content everywhere else

style

select | pills

select — the reader dropdown, from the control tokens above

  • header puts it in the header beside the other header controls (and in the phone menu); sidebar at the head of the docs sidebar; footer just above the site footer; content-top above the article; none hides it (the languages' addresses still work, and hreflang still lists them for search engines). Every placement is in the page as served — nothing moves into place after it loads, and it works without JavaScript. In the header and the footer the switcher lists the space's declared languages; a language this page has no translation in leads to what the reader serves there (see docs.localeFallback).

  • A surface that has no sidebar (a changelog, a status page) reads sidebar as content-top, so a site-wide setting never makes the control vanish.

  • Set it on the site theme and every space in the site follows; set it on one space to override the site for that space only.

  • The older docs-only docs.localeSwitcher.placement / .style still work and apply wherever i18n.switcher leaves a value unset. docs.localeSwitcher.label (the control's caption) is still read.

  • This is the switcher between the content's translations. The control that changes the reader's interface language ("English" in the header) is a different one, shown by showLanguageSwitcher.

{ "i18n": { "switcher": { "placement": "header" } } }

In the studio this is Design → Languages.

The value types

Every key is optional, and an unset key changes nothing.

  • Sizes are JSON numbers, in pixels. Send "radius": 12, not "radius": "12px" or "radius": "0.75rem". A string is a 422 at that key's path, for example "components.radius": ["Expected number, received string"]. Decimals are accepted (1.5). A number outside the range listed for its key is also a 422. It is not clamped.

  • Colours are #rrggbb, the same rule as every other theme colour. A bare 0b835b or a three-digit #0b8 is normalised; anything else is a 422.

  • Elevations (shadow, button.shadow) are none, sm, md or lg, not a raw box-shadow string. That leaves nothing to sanitise and keeps the four steps the same across surfaces.

  • Spacing values are unitless multipliers from 0.5 to 2, also JSON numbers.

Shared shape

Key

Type

Unit

What reads it

radius

number, 0–999

px

The corner of cards (.card), panels (.surface), callouts, .input fields, buttons, the header call-to-action, code blocks, inline code, the code copy button and the focus ring. code.radius, input.radius and button.radius each win over it for their own component

shadow

none | sm | md | lg

—

Cards and panels only. Unset, they have no shadow

focus: the keyboard focus ring

Key

Type

Unit

What reads it

focus.color

#rrggbb

—

The :focus-visible outline on links, buttons, inputs, selects, textareas, summary and anything with a tabindex, plus the edge of a focused .input. Unset: the brand colour

focus.width

number, 1–6

px

The outline's thickness. Unset: 2

focus.offset

number, 0–8

px

The gap between the control and its outline. Unset: 2

Unset, the colour is the brand as the reader's other brand-coloured TEXT uses it — moved, per light and dark mode, until it reads on the page — so a pale brand still gets a visible ring. On the reader, a focused link, button or summary also gets a thin band of the page colour around its ring, so the ring reads on a dark card or a photo as well as on the page.

The 1px minimum on focus.width is deliberate. A visible focus ring is an accessibility commitment, so a theme can restyle it but not remove it.

code: code blocks and inline code

Key

Type

Unit

What reads it

code.background

#rrggbb

—

Code blocks (pre), code blocks with a filename bar, and inline code

code.ink

#rrggbb

—

The base text colour of all three. Highlighted tokens take their colours from syntax instead

code.border

#rrggbb

—

The edge of all three, the line under a filename bar, and the edge of the copy button

code.radius

number, 0–999

px

The corner of code blocks, inline code and the copy button. Wins over radius

input: text fields

Key

Type

Unit

What reads it

input.background

#rrggbb

—

Reader fields drawn with the shared .input class (see below)

input.ink

#rrggbb

—

The text typed into those fields

input.border

#rrggbb

—

Their edge. While a field has focus, the edge takes focus.color instead

input.radius

number, 0–999

px

Their corner. Wins over radius

Every input on a reader page reads these: the docs sidebar's search box, the forum's search box, the search box on a 404 page, and the forum's member search.

button: buttons and the header call-to-action

Key

Type

Unit

What reads it

button.background

#rrggbb

—

The fill of primary buttons (.btn-primary), of the header call-to-action when chrome.ctaStyle is solid or pill, and of a course landing's solid CTA

button.ink

#rrggbb

—

The text on that fill, in the same places

button.shadow

none | sm | md | lg

—

The elevation of the same filled buttons

button.border

#rrggbb

—

The edge of unfilled buttons: the plain .btn and the outlined .btn-ghost

button.radius

number, 0–999

px

The corner of every button — the header buttons in every slot, and the button block — in every ctaStyle. Wins over radius

button.weight

regular | medium | semibold | bold

—

The label weight of every button, and of the header call-to-action in every ctaStyle

button.edge

{ height: 1–8, color?: #rrggbb }

px

A raised bottom edge (the "3D" button): a solid band height px under every FILLED button — .btn-primary, the button block's primary, a solid header call-to-action, a course landing's solid CTA — which the button sinks into while pressed. color unset: the fill darkened (it follows buttonDark). Stacks under button.shadow. Unset: flat

An outline or link call-to-action has no fill by design, so button.background, button.ink, button.shadow and button.edge do not apply to it. Its corner and weight still follow button.radius and button.weight.

Its label is the brand colour as it reads on the page. When palette.header_background (or paletteDark.header_background) repaints the header, the label is checked against that colour instead: it stays the brand when the brand reaches 4.5:1 on the header, and otherwise takes the header's foreground — header_primary if you set one that is itself readable there, else near-black or near-white. Each mode is decided on its own. With no header palette nothing changes.

apiRef: the API reference block

How an OpenAPI operation block looks. Every key unset keeps today's look.

{
  "components": {
    "apiRef": {
      "methodColors": { "get": "#0f766e", "post": "#4338ca", "delete": "#b91c1c" },
      "methodColorsDark": { "get": "#5eead4" },
      "methodStyle": "soft",
      "surface": { "background": "#fafafa", "border": "#e4e4e7", "radius": 12 },
      "size": "sm",
      "card": false
    }
  }
}

Key

Values

What it does

methodColors, methodColorsDark

hex per get post put patch delete head options webhook

The method pills, published as --api-method-<m>. The sidebar's method badges read the same variables, so one setting paints both.

methodStyle

solid, soft

soft: a tinted pill with coloured text instead of a filled one.

surface

background, backgroundDark, border, borderDark, radius (0–32)

The block's card.

size

sm, md

sm: the block's buttons, tabs and SDK links at 0.8125rem, matching a 14px UI.

card

boolean

false: no border or padding, so the method and URL row sits directly under the page title.

spacing, selection, scrollbar and syntax

Key

Type

Unit

What reads it

spacing.scale

number, 0.5–2

multiplier

All three of the axes below at once

spacing.block

number, 0.5–2

multiplier

The gap between blocks of prose. Wins over scale

spacing.section

number, 0.5–2

multiplier

The space a heading leaves above itself. Wins over scale

spacing.pad

number, 0.5–2

multiplier

Padding inside code blocks, inline code, callouts and caption bars. Wins over scale

selection.background, selection.ink

#rrggbb

—

Selected text inside the reader. Unset, the browser's own highlight

scrollbar.width

auto | thin

—

The reader's scroll areas and the page's own scrollbar

scrollbar.thumb, scrollbar.track

#rrggbb

—

The thumb and the track of those scrollbars

syntax.<group>, syntaxDark.<group>

#rrggbb

—

Code highlighting, one colour per highlight.js group (table below). syntaxDark applies only in dark mode

The sections below explain spacing, scrollbar and syntax in more detail.

spacing is a multiplier, not a length

Every gap in the reader is a literal chosen against its neighbours — a paragraph is 0.85rem from the next one, an h2 claims 2.4rem above itself, a code block pads 0.95rem/1.1rem. So spacing scales those rather than replacing them, and 1 is exactly today's layout:

  • block — the gap between blocks: paragraphs, lists, quotes, code, callouts, rules, and the space under a heading.

  • section — the space a heading claims above itself, which is what makes sections separate without a divider.

  • pad — padding inside the surfaces our stylesheet owns: a code block, an inline code chip, a callout, a caption bar.

  • scale sets all three at once; naming one of the others overrides it for that axis.

An absolute scale could only have meant anything by naming one length per element, and the first element nobody named would be the one that stopped lining up.

scrollbar: what is honoured where

width, thumb and track compile to the standard scrollbar-width and scrollbar-color — Firefox 64+, Chrome/Edge 121+, Safari 18.2+ — and, for older WebKit/Blink, to a matching ::-webkit-scrollbar block. A browser that honours the standard properties ignores the ::-webkit-scrollbar rules once they are set, so the two cannot disagree; an older one falls back to the webkit block. There is deliberately no thumb radius, hover colour or track border: scrollbar-color cannot express any of them, so they would work in one engine and silently do nothing in the others.

scrollbar-color takes a pair, so naming one colour pins the other to our neutral (a grey thumb, a transparent track) — "leave the other one alone" is not something the property can say.

syntax: the highlight.js groups

The keys are highlight.js's own class names, so a palette lifted from any hljs stylesheet maps across by name. Each one paints its whole alias group:

Key

The .hljs-* classes it paints

comment

comment, quote

keyword

keyword, selector-tag, literal, doctag, formula, deletion

string

string, regexp, addition

number

number, symbol, bullet, link

title

title, title.function_, section

built_in

built_in, type, class .title, params

tag

name, selector-id, selector-class, tag

attr

attr, attribute, variable, template-variable, template-tag

meta

meta, comment.meta

emphasis

emphasis, strong

built_in keeps highlight.js's underscore as a key; the variable hyphenates it (--reader-syntax-built-in). The base ink of a code block is not a syntax slot — it is code.ink, the same decision as the surface it sits on.

syntax applies in every mode and syntaxDark overrides it in dark mode, exactly like palette and paletteDark. A slot you leave out of either falls through to our own palette, so an unset theme highlights code exactly as it always did.

Code themes (syntaxTheme) and the dark code surface (codeDark)

syntaxTheme names a whole code look — the ten token colours AND the surface they were drawn for, per mode:

{ "components": { "syntaxTheme": "one-dark" } }

Name

Light mode

Dark mode

github

GitHub

GitHub Dark

github-dark

GitHub Dark

GitHub Dark

one-light

One Light

One Dark

one-dark

One Dark

One Dark

solarized-light

Solarized Light

Solarized Dark

solarized-dark

Solarized Dark

Solarized Dark

nord

Nord

Nord

dracula

Dracula

Dracula

monokai

Monokai

Monokai

night-owl

Night Owl

Night Owl

A preset paints code BLOCKS — inline code in a sentence keeps components.code — and anything you set yourself wins slot by slot: syntax, syntaxDark, code and codeDark adjust a preset rather than fight it.

{
  "components": {
    "syntaxTheme": "github",
    "syntax": { "keyword": "#d73a49" },
    "codeDark": { "background": "#010409" }
  }
}

codeDark is code's surface in dark mode (background, ink, border), for a code block that is pale on a light page and should not stay pale on a dark one.

An ink you set for light is lifted for dark unless you set codeDark.ink: code.ink: "#24272f" with a dark codeDark.background used to leave inline code at 1:1 in dark mode. The lift is skipped when you set only the light background, because then you chose that background for both modes.

inputDark is the same for input (background, ink, border): the search field and text inputs in dark mode. An unset slot falls through to input, then to ours.

Code blocks and inline code separately, and everything else about code (2026-10-12)

components.code keeps its four keys (background, ink, border, radius, which paint blocks AND inline code) and gains:

Key

Values

Does

block

{background, ink, border, radius}

Code blocks only. Beats the four keys and a syntaxTheme's surface

inline

{background, ink, border, radius, weight, backticks}

Inline code only. background: "none" is bare text; weight 400–700; backticks: true draws them

look

bordered (ours) · flat · inset

flat drops the outline; inset sets the code in a 4px frame (outer colours it)

copy

text (ours) · icon · cell · none

The copy button: words, an icon (in the caption bar when there is one), a header cell, or none

actions

["copy"] (ours) · ["copy","ask"] · …

Adds an "Ask AI about this code" button; leaving copy out hides the copy button

showLanguage

true

A language label in the caption bar or the block's corner

wrap

true

Wraps long lines everywhere; a block's own wrap: false opts out

terminal

auto · off (ours)

Every shell block (bash, sh, zsh, console, powershell …) gets a terminal bar

highlightBar, highlightBackground

hex

A highlighted line's left bar and wash

insert, delete

hex

Inserted / deleted lines (codeBlock.ins / del)

codeDark takes the same block and inline (without radius), outer, highlightBar, highlightBackground, insert and delete for dark mode.

{
  "components": {
    "code": {
      "block": { "background": "#0b1020", "ink": "#e6e8eb", "border": "#1c2333", "radius": 12 },
      "inline": { "background": "#f1f3f5", "ink": "#1f2328", "weight": 500 },
      "copy": "icon",
      "showLanguage": true,
      "look": "inset"
    },
    "codeDark": { "inline": { "background": "#262a31", "ink": "#e6e8eb" } }
  }
}

Every key is optional and unset draws today's code block.

{
  "components": {
    "link": {
      "underline": "always",
      "offset": 3,
      "thickness": 1,
      "weight": 400,
      "color": "#0066cc",
      "colorDark": "#2997ff"
    }
  }
}

underline is hover (today's), always or none; offset and thickness are px; weight 100–1000; color/colorDark publish --reader-link. underlineColor / underlineColorDark colour the underline itself (unset: the link's colour), and external: "arrow" puts ↗ after every absolute (http(s)://) link — including one to your own host spelled absolute.

{ "components": { "link": { "underline": "always", "underlineColor": "#c7d2fe", "external": "arrow" } } }
``` Only a
link in running text is styled — a card, a button or a mention chip that
happens to be a link keeps its own look.

#### Metadata rows and image corners

```json
{
  "components": {
    "meta": { "font": "mono", "case": "upper", "tracking": 0.06 },
    "image": { "radius": 0 }
  }
}

meta styles every [data-fx-meta] row (dates, bylines, reading time) and publishes --reader-meta-font, --reader-meta-case, --reader-meta-tracking. meta.scope: "date" keeps the face and case on the row's date only (its <time>), so a mono, upper-case date can sit beside author names set in the body face; all (the default) is the whole row — a post head's byline row as well as a feed card's. cards reaches the meta rows on feed cards only, and cards-date only the dates on feed cards, so a mono card meta leaves the post head alone. meta.size (px 10–24, a length, or one per screen size) and meta.color / meta.colorDark publish --reader-meta-size / --reader-meta-color on the same target. A feed's own card.meta sets the same tokens on the card, so it wins over these.

The button block follows components.button. Its fill, ink, border, shadow, weight and radius — and the form submit and modal buttons, which are the same element — read the --reader-button-* tokens, falling back to the brand fill and 8px corner they always had.

Lists and callouts (components.list, components.hint)

{
  "components": {
    "list": { "marker": "emdash", "markerColor": "#ff6363" },
    "hint": { "icon": "badge", "edge": "none", "background": "#f4f1fb", "radius": 12 }
  }
}

Key

Values

What it does

list.marker

disc (default) | dash | emdash | arrow | square | none

The marker of every bullet list that did not choose its own marker; a list's own wins

list.markerColor

#rrggbb

Publishes --reader-marker; colours drawn markers and the browser's disc and numbers (::marker)

list.markerColorDark

#rrggbb

The same in dark mode

list.itemGap

0–64 px

The room between the items of every list that set no itemGap of its own (round 5)

list.hang

true | false (default)

Hangs every top-level list's marker in the gutter, so the items' words sit on the column edge with the paragraph above; a checklist, marker: none and nested lists keep their indent (round 6)

hint.icon

none (default) | badge

A round per-style glyph at the start of each callout

hint.edge

bar (default) | hook | none

The 3px leading bar, a bar that curls into the top edge, or a plain hairline like the other sides

hint.background

#rrggbb (and backgroundDark)

One wash for every callout style

hint.border

#rrggbb

The hairline round the panel

hint.radius

0–999 px

The panel's corner; unset is components.radius, then 10px

hint.hang

true | false (default)

Hangs the leading bar into the gutter by its width and moves an icon: "badge" out of the panel, above its top edge, so the words keep the plain indent (round 5)

Round-5 keys (2026-10-11, Package D)

  • list.marker/markerColor/itemGap reach only the lists a document wrote (a bare <ul>/<ol>). Before, they also drew on the front page's own lists — every tag of the tags band carried the theme's em dash.

  • list.itemGap and hint.hang are new (rows above). A list block's own itemGap and weight, a paragraph's weight, and a row of columns' spaceBefore/spaceAfter/spaceAround are block look keys — see Advanced theming.

image.radius is --reader-img-radius: content images here, and the covers and feed cards the blog and changelog draw.

{
  "components": {
    "image": { "margin": { "lg": "2.5rem", "sm": "1.5rem" } },
    "byline": { "link": { "color": "#0a0a0a", "colorDark": "#fafafa", "weight": 600 } },
    "caption": { "align": "center" }
  }
}

image.margin is the space above and below a picture in running text (--reader-img-margin, a length or one per screen size). byline.link styles author links in a byline ([data-fx-byline] a, on posts and feed cards; a card's own card.byline.color/weight beat it). caption.align (start or center) sets every caption under a picture, a video and a code block (--reader-caption-align).

image.backdrop (CL-6) puts a panel behind every block picture in running text — the soft gradient or flat colour Cursor's and Linear's release notes frame screenshots on, without baking it into the image file. A picture inline in a sentence, a list, a table cell or a link stays bare.

{ "components": { "image": { "radius": 14, "backdrop": { "style": "gradient", "padding": 28 } } } }

Key

Values

Unset

image.backdrop.style

none | solid | gradient

none (today's bare picture)

image.backdrop.color / colorDark

#rrggbb — the panel (solid) or the gradient's first stop

a 14% brand wash over the paper

image.backdrop.to / toDark

#rrggbb — the gradient's second stop

a 4% brand wash

image.backdrop.angle

0–360 (degrees)

135

image.backdrop.padding

0–96 px between the panel's edge and the picture

24

The panel's corner is image.radius; the picture's reserved box still holds (no layout shift).

quote: blockquotes

Key

Values

What it does

quote.style

bar | pull

bar is today's ruled, italic quote. pull sets every plain quote as a large, unruled pull quote.

quote.size

integer, 14–96 (px)

The pull quote's size (--quote-size).

quote.align

start | center

Centres every plain quote (a centred bar quote loses its bar).

These reach a quote that did not say for itself. A quote block with its own variant or align (> text {: .pull .center} in Markdown, or blockquote.attrs — see Authoring) keeps what it asked for, including an explicit default under a theme-wide pull.

A blog post's layout (post)

post arranges ONE blog post: the layout (single, rail-left, rail-right, offset, split-cover), the rail beside the words, the order of the head (kicker, date, title, dek, byline…), the cover, the share control, more to read, the breadcrumb, the authors and their card, a masthead band, the contents rail's type, a drop cap, the series navigation, and per-tag templates (byTag). Every key is optional; a theme without post renders each post as it always did. The blog's piece layouts (article-rail, article-meta-rail, article-margin, article-split) are patches over it, and your post lands on top of the one chosen.

{
  "post": {
    "layout": "rail-left",
    "rail": { "items": ["bylines", "date", "share"], "width": "16rem" },
    "header": {
      "order": ["kicker", "date", "title", "dek"],
      "kicker": "firstTag",
      "dateStyle": "mono-caps"
    },
    "cover": { "show": false },
    "share": { "position": "rail", "style": "icons" },
    "byTag": {
      "press": {
        "align": "center",
        "masthead": { "layers": [{ "color": "#0b2545" }], "scheme": "dark" }
      }
    }
  },
  "footnotes": { "placement": "margin" }
}

The whole vocabulary, key by key, is in Blog and changelog. Round 3 added the head's type and the share row's choice: header.kickerStyle (font, case, size, color, tracking, weight, rule: start|bar), header.titleLineHeight, header.gap (responsive), header.dateRule, header.filedUnder ("Filed under {tags} on {date}", strict tokens), share.networks / share.frame (circle|square|none), rail.phoneLayout: row, breadcrumbStyle.placement: grid and byline.initials. post.masthead speaks the title band's vocabulary (layers, layersDark, height, above, blend, titleColor…), plus scheme and overlapsHeader. footnotes.placement is popover (today), margin (in the right margin at 1280px and wider, inline below) or end (numbered, listed after the body).

Round-5 keys (2026-10-11): post page

byline.weight / font / roleSize / roleColor / separator, header.spacing.afterDek, header.measure ({title, dek, meta}: head|text), header.dateLocale and rail.dateLocale, breadcrumbStyle.blog: false (needs root or levels: 2), split.verticalAlign: spread, pageActions.look: text and pageActions.placement: body-end, rail.phoneLayout: spread, share.size, toc.subItemSize. The full rows are in Blog and changelog.

Each part a template adds carries a hook — [data-fx-masthead], [data-fx-breadcrumb], [data-fx-kicker], [data-fx-lede], [data-fx-byline], [data-fx-post-rail], [data-fx-author-card], [data-fx-series-nav], [data-fx-suggested] — and the page root says which layout it wears ([data-post-layout]), so the one touch no key reaches is a rule on a name rather than on our class names.

Motion (motion)

Page transitions, scroll reveals, hover treatments, a header that shrinks or hides, a reading-progress bar, and your own keyframes on the documented hooks — the animations you would otherwise write in your own site's code, as theme keys. Every reader surface honours them (docs, blog, forum, roadmap, changelog, events, courses, status, FAQ, jobs, legal, contact, the site index and the 404), on our domain and yours.

Unset is the reader you have today, byte for byte. Nothing is emitted until you set a key, so motion absent, {}, {"preset": "none"} and {"enabled": false} all produce exactly the stylesheet you had before.

Nothing is hidden from crawlers or from visitors without JavaScript. A reveal's "before" state only applies once the reader's motion script has run and has already marked every block that is on screen as shown. The served HTML is the whole, visible page; a search engine, a reader with scripts blocked, or a page whose script failed to load sees everything. A printout shows everything too. Content at the very end of a page always reveals, even on a screen as tall as the page, and a window resized to show more reveals what it now shows.

Reduced motion. When a reader's device asks for reduced motion and respectReducedMotion is on (the default), every effect becomes an instant change: durations collapse to zero, reveals become opacity-only, hover lifts and tilts stay put, loops stop, the page transition is skipped, smooth scrolling is off and the pointer glow never starts. Turning respectReducedMotion off keeps your opacity fades at their own speed for those readers (a fade instead of a snap) and nothing more: movement — lifts, tilts, slides, loops, page transitions, smooth scrolling — stays off whatever the key says, because some readers get motion sick and WCAG 2.3.3 asks you to respect them.

PATCH {"motion": null} takes the whole layer off; null on any group (reveal, hover, header, ambient, keyframes) or on one key inside a group clears just that part. A misspelt key is refused by name, like the rest of the theme.

Presets

preset sets everything at once. Every other key overrides the preset one key at a time, so {"preset": "lively", "reveal": {"stagger": 0}} keeps lively's reveal effect and only drops the stagger.

Preset

What it sets

none

Nothing. Same as no motion at all; useful as a base for setting just one or two keys

subtle

Fade page transition; everything fades in (12px, 40ms stagger); link underline fades in; buttons lift; cards get a brand border on hover; smooth scrolling; animated focus ring

lively

emphasized easing; slide-up page transition; everything fades up (24px, 70ms stagger); link underline slides; buttons and cards lift; images zoom; header shrinks with a blurred backdrop; progress bar

playful

spring easing; scale page transition; everything zooms in (90ms stagger); link underline grows; buttons shine; cards tilt; images zoom; header hides on scroll down; aurora background; pointer glow

What a reveal applies to

reveal.targets picks what scrolls in. everything is the recommended choice and what every preset uses: every top-level block of every body (an article, a post, a page) and every entry of an index page, in reading order, staggered as they come into view together.

Or name kinds. Every block type belongs to exactly one:

Target

What it reveals

headings

Headings

paragraphs

Paragraphs, at any depth (not a table cell's)

lists

Bullet, numbered and checklists, each as a whole

images

Images, figures, galleries, before/after comparisons, logo walls

cards

Card grids, any .card, people, prompts, stats, pricing, events, downloads

code

Code blocks with their title bar, code groups, file trees, examples, playgrounds, OpenAPI

callouts

Hints and callouts

tables

Tables, each as a whole

quotes

Blockquotes and testimonials

media

Video, audio, carousels, marquees, drawings, diagrams, math, swatches

embeds

Embeds and iframes, custom code, extension blocks, office maps, system status

tabs

Tab sets

steps

Steppers, walkthroughs, scrollytelling stories

accordions

Expandables, expandable groups, modals

buttons

Buttons, button rows, forms, contact forms, RSVPs, quizzes

sections

Sections, columns, grids, heroes, front-page bands and feeds, leaderboards, updates, dividers

items

One entry of an index page: a post card, topic row, forum post, release, roadmap card, event, role, lesson, FAQ question, status group, legal or contact row

blocks

Every top-level block of a body (the earlier default; everything adds the index entries)

What keeps it seamless, whatever you pick:

  • Nothing on screen at load fades in. What is visible when the page opens is marked shown before reveals are armed; only what is below the fold starts hidden, and it is hidden at once, never faded out.

  • One entrance per thing. A block inside another that is also revealing (the paragraphs of a callout, the code in its titled frame, the blocks of a forum post) moves with its container, never twice.

  • Tall blocks reveal in pieces. A block taller than the screen (a long table, list, feed or section) reveals by its child blocks as each reaches the viewport, instead of once while most of it is still off screen.

  • Late content reveals too: posts a forum appends live, lazy blocks, and the new page after a client-side navigation. Code highlighting and images loading replay nothing, and nothing moves the layout — a reveal changes only opacity, transform and filter.

lively and playful make the header sticky, overriding chrome.sticky. Set "header": {"behavior": "static"} (or null to fall back to chrome.sticky) if you want their other effects without it.

The keys

Key

Values

Default

preset

none, subtle, lively, playful

unset

enabled

false switches every effect off and keeps your settings

true

respectReducedMotion

See above

true

speed

A multiplier on every duration, 0.25–4. 2 is twice as fast. The base duration is 400ms

1

easing

standard, emphasized, spring (a slight overshoot), linear, or cubic-bezier(x1, y1, x2, y2) with x1/x2 in 0–1 and y1/y2 in ±3

standard

pageTransition

none, fade, slide-up, slide-left, scale

none

pageCrossfade

true overlaps the old and new page (a cross-fade). Off, the old page has faded out before the new one starts, so two pages are never on screen together

false

reveal.effect

none, fade, fade-up, fade-down, zoom, blur, slide-left, slide-right

none

reveal.stagger

ms between blocks that come into view together, 0–1000

60

reveal.distance

px a block travels on the way in (fade-up/-down, slide-*), 0–200

24

reveal.once

false replays the reveal each time a block scrolls back in

true

reveal.targets

everything, or any of the kinds under What a reveal applies to below

["everything"]

hover.link

none, slide (underline draws left to right), grow (from the centre), fade (a faint underline becomes solid)

none

hover.button

none, lift, glow, scale, shine (a light sweep). Buttons and the header call-to-action

none

hover.card

none, lift, tilt, glow, border

none

hover.image

none, zoom, brighten. Images in an article

none

header.behavior

static, sticky, shrink, hide-on-scroll-down, transparent-until-scroll

unset (chrome.sticky)

header.blur

A translucent, blurred backdrop on a header that floats over content

false

ambient.background

none, shift (a slow brand-tinted gradient drift), aurora (soft moving colour fields)

none

ambient.cursorGlow

A soft brand glow that follows the pointer. Mouse and trackpad only, never on touch

false

ambient.scrollProgress

A 3px brand-coloured reading-progress bar across the top

false

ambient.smoothScroll

Anchor links glide instead of jumping

false

ambient.focusRing

The keyboard focus ring settles in instead of appearing

false

keyframes

Your own animations; see below. Up to 12, each 2–12 steps

none

targets

Documented hook → animation; see below

none

scroll

Scroll-linked effects (parallax, scrubbed reveals, a progress bar) on a hook or a named block; see below. Up to 24

none

ambient.scrollProgress is the fixed bar at the very top; the docs header's 2px rule is docs.topRule. An explicit scrollProgress: false also drops that rule unless docs.topRule says otherwise.

Hover effects only apply on devices with a real pointer, so a tap on a phone never leaves a card stuck mid-lift. The header behaviours never hide a header that has keyboard focus inside it.

Page transitions use the browser's View Transitions API for full page loads, where the browser supports it (Chromium-based browsers and Safari 18.2 or later). For moves between pages inside the reader, and in browsers without cross-document View Transitions, the page's main region plays the same entrance instead. Nothing is held back while it plays.

Moves between pages INSIDE the reader (a click on a reader link that does not reload the page) use the same API where the browser has document.startViewTransition (Chromium-based browsers, Safari 18 or later): the old page is snapshotted, the new one renders, and the browser animates between them. While it runs, the main region is named reader-main and the header reader-header, so the header stays exactly where it is and only the page moves. Target them from customCss with ::view-transition-old(reader-main) / ::view-transition-new(reader-main) if you want a different effect. The names exist only during a transition (a named element is its own stacking context, which would otherwise trap a popover under the header).

The main region has no background of its own, so its two snapshots are transparent: overlapping them shows one page's text through the other's. By default the old page fades out first (35% of the duration) and the new one fades in after it; pageCrossfade: true overlaps them instead. Under reduced motion the old snapshot is hidden, so the swap is instant.

It does not happen when pageTransition is unset or none; for a link opened in a new tab, with a modifier key, to another site or to a #section of the same page; for a link inside an element carrying data-no-view-transition; or when the visitor asks for reduced motion and respectReducedMotion is on (the default). A browser without the API, or a navigation that does not commit within 2.5 seconds, falls back to the CSS entrance above — the page is never frozen on a snapshot. Back/forward keeps the CSS entrance.

Your own keyframes, on the documented hooks

keyframes defines named animations. Each step is a percentage (at, 0–100) and up to six properties from a short, safe list:

Step property

Accepts

opacity

0–1

transform

translate*, scale*, rotate*, skew*, perspective functions with plain numbers and units, e.g. translateY(12px) scale(.98)

filter

blur, brightness, contrast, grayscale, hue-rotate, invert, opacity, saturate, sepia

color

A hex colour, transparent, currentColor or var(--token)

backgroundColor

Same as color

clipPath

inset(), circle(), ellipse() or polygon() with plain numbers and units

No url(), no nested functions, no quotes: anything outside that list is refused with the step's path in the error.

targets maps a documented hook to an animation. The keys are every hook in the fx-1 hook set, named without the data-fx- prefix. Today that is:

  • the chrome and the page: header, logo, brand-name, nav, nav-item, nav-panel, nav-link, nav-featured, cta, cta-secondary, theme-toggle, sidebar, sidebar-logo, main, aside, footer, footer-col, footer-html, footer-newsletter, cookie-settings, search, article, page-title;

  • theme components: outlet (a filled outlet) and component (one component's markup in it);

  • the interactive blocks: carousel, carousel-slide, gallery, lightbox, pricing, pricing-plan, testimonials, testimonial, logo-wall, stats, stat, marquee, modal, compare, and the motion blocks layer, story, story-step, playground, the landing blocks hero and person, and the office map's office-map, office and map;

  • the three class hooks button, input and card.

Any other key is refused. If you need an arbitrary selector, use customCss.

Target field

Values

Default

animation

One of your keyframes names, or a built-in (below)

required

duration

ms, 50–20000. Not scaled by speed, because you named it

600 ÷ speed

delay

ms, 0–10000

0

iteration

1–100, or "infinite"

1

trigger

load (plays on page load), reveal (plays when it scrolls into view), hover

load

The built-in animations, usable without defining anything: fade-in, fade-up, fade-down, zoom-in, slide-left, slide-right, blur-in, pulse, float, wiggle, spin, bounce. A keyframe of your own with the same name wins. Keyframes are merged by name between a site theme and a space theme, so a space can add one without restating the site's — and a space target may name a keyframe only its site defines.

A target naming an animation that exists nowhere is refused when you save it: PUT/PATCH …/theme (and the theme draft) answer 422 validation.failed, naming the key and listing every name that would work:

{
  "error": {
    "code": "validation.failed",
    "message": "motion.targets.header.animation: \"fade-upp\" — no animation by that name. Use a built-in or a keyframe defined in motion.keyframes (this theme's or its site's): blur-in, bounce, fade-down, fade-in, fade-up, float, pulse, site-glow, slide-left, slide-right, spin, wiggle, zoom-in.",
    "details": { "motion.targets.header.animation": ["Unknown animation \"fade-upp\"."] }
  }
}

The site is looked up at the moment you save. Removing one of your own keyframes while a target still names it is refused the same way. A site theme's own targets may name built-ins and the site's keyframes (a member space's keyframe is not in reach of every space the site renders).

What the save cannot see coming — the site later removing a keyframe a space points at — is not a 422 on either side. The reader stays tolerant (that target simply does not animate) and every live-theme answer (GET/PUT/PATCH …/theme) carries a warnings array that names it:

{
  "theme": { "…": "…" },
  "warnings": [
    {
      "code": "motion.unknown_animation",
      "key": "motion.targets.header.animation",
      "message": "\"site-glow\" is not a built-in animation or a keyframe in this theme or its site, so header does not animate. …"
    }
  ]
}

The same array carries contrast warnings ("code": "contrast.low"): a colour pair your palette or paletteDark sets — ink, muted or heading on background/surface, header_primary on header_background, footer_primary on footer_background — that measures under WCAG AA (4.5:1 for text, 3:1 for headings) in a scheme the theme can be read in. It is the same check the visual editor runs, so a theme written through the API is told what one picked in the editor is. key names the slot to change; the save is never refused for it.

It also measures the colours the theme sets outside the palette: primaryColor against the page (3:1 — the brand paints buttons, borders and the focus ring; text in it is corrected for you), components.link.color / colorDark against background and surface (4.5:1, painted as given), and any elementStyles rule that sets both color and a background (key: "elementStyles.<i>"). A site theme save (POST/PATCH …/sites) answers the same warnings as contrastWarnings (its warnings lists rewritten values), and a front-page save (PUT …/front-page, space or site) answers contrastWarnings for each feed's card.titleColor, subtitleColor, excerptColor, dateColor, tagColor, surface.ink (on the card's surface when it sets one, else the page) and a non-solid moreButton.color. Hex values only — a token follows the scheme. A solid "more" button's label is picked from its fill, so it is never reported.

warnings is [] when there is nothing to report. A stale reference like this does not block saving anything else; re-pointing or removing that target clears it. A single piece's own theme.motion (per-page customization) is not checked at write, because a piece layers over whichever space theme is live when it is read; a name that resolves nowhere there does nothing.

Scroll-linked effects (motion.scroll)

A targets animation is triggered: the block crosses a line and plays to the end. A scroll effect is tied to the scroll position: halfway through its stretch of scrolling, it is halfway through. That is how a giant word drifts slower than the grid under it, a hero picture sinks as the page rises, a caption fades as you read past it, or a bar fills as you read the page.

Each entry names what moves, how, and over which stretch of scrolling:

Field

Values

Default

hook

A documented hook (the targets list above), e.g. page-title, feed-item

one of hook or block is required

block

A block you named with its hook attribute — the block carries data-fx-block="<name>"

part

self; background (a section's painted background, overscanned so no edge shows); media (the picture inside, or the block if it is one)

self

effect

A preset, below. Any channel you set overrides the preset's

none

rate

Parallax, −1 to 1. 0.5 moves at half the scroll speed (it lags, reading as far away); -0.5 at one and a half (it rushes ahead, reading as near). Cannot be combined with translateY

none

translateX, translateY

[from, to] in px, ±2000

none

opacity

[from, to], 0–1

none

scale, scaleX

[from, to], 0–4. scaleX grows from the element's start edge — a bar that fills

none

rotate

[from, to] in degrees, ±720

none

blur

[from, to] in px, 0–40

none

range

cover (top edge entering → bottom edge leaving), contain (while wholly in view), entry (while coming in), exit (while going out), page (the whole document)

from effect, else cover

start, end

The slice of the range the effect runs over, 0–100 (%)

0, 100

phone

false keeps a phone (≤640px) still

true

The presets:

effect

Means

reveal

entry: opacity 0 → 1, translateY 48 → 0

zoom

entry: opacity 0 → 1, scale 0.85 → 1

unblur

entry: opacity 0.2 → 1, blur 12 → 0

fade-out

exit: opacity 1 → 0, translateY 0 → −40

progress

page: scaleX 0 → 1

parallax

cover: rate 0.3

An entry names exactly one of hook or block, and must move something. A misspelt field is refused by name.

How it runs. Browsers with CSS scroll-driven animations (Chromium-based browsers, Safari 26 or later) run it in CSS — animation-timeline: view() for an element's own passage, scroll(root) for page — with no script at all. Elsewhere the reader fetches a small script, only on a page that has scroll-linked content, that drives the same values. The effects use the individual translate, scale and rotate properties, so they combine with a block's own transform and with hover effects rather than replacing them. An element with both a targets animation and a scroll effect plays only the one emitted last; give each its own element.

Reduced motion always wins here. Unlike the rest of motion, respectReducedMotion: false does not reach scroll-linked effects: movement tied to the scroll wheel is exactly what the setting protects people from. They are also off with enabled: false, on the console's editing canvas, and on paper. The page as served is the natural page: with no scroll timeline and no script, every element sits where the layout put it, fully visible.

{
  "motion": {
    "scroll": [
      { "block": "hero-words", "rate": 0.45 },
      { "block": "hero", "part": "background", "rate": 0.25 },
      { "hook": "feed-item", "effect": "reveal", "end": 60 },
      { "hook": "page-title", "effect": "fade-out", "phone": false },
      { "block": "read-bar", "effect": "progress" }
    ]
  }
}

For floating pictures and words placed inside a section, see the layer block, and for a picture that sticks while steps scroll past, the story block (both in the authoring reference).

CSS variables and the state the script publishes

Name

What

--reader-motion-duration

The base duration after speed (e.g. 400ms). Hover uses half of it, the page transition 0.8×

--reader-motion-easing

The resolved timing function

--reader-motion-distance, --reader-motion-stagger

reveal.distance / reveal.stagger

--reader-motion-i

Per block: its place in a group that entered together. Set and cleared by the script

--reader-motion-progress

0–1 scroll position, on <html>, published when the progress bar or a scroll-driven header is on

--reader-motion-x, --reader-motion-y

The pointer position, on <html>, while the pointer glow is on

--reader-motion-observe/-once/-scroll/-cursor/-page

Instructions to the reader's motion script. Read them if you like; do not set them yourself

html[data-reader-motion]

Present once the script has armed reveals

[data-motion-in]

On a revealed block or hook

html[data-reader-scrolled], html[data-reader-scroll-dir]

Past the top of the page; up / down

[data-fx-main][data-motion-page]

The main region while its entrance plays

--reader-motion-sfx

What the scroll-linked fallback drives. An instruction to the script; do not set it

--rm-p, html[data-reader-sfx]

Per element, its 0–1 progress — set only by the fallback script, in a browser without scroll timelines

So your own customCss can join in. For example, make a hook you styled yourself react to the header shrinking:

:root[data-reader-scrolled] .forumulate-reader [data-fx-cta] {
  padding-block: 0.25rem;
}

Three worked examples

A quiet documentation site. Fades, no movement, and the headings alone reveal so long reference pages stay calm:

{
  "motion": {
    "preset": "subtle",
    "reveal": { "effect": "fade", "targets": ["headings", "images"] },
    "hover": { "button": "none" }
  }
}

A product launch blog. The lively preset, faster, with a springier curve and the header turning solid only once the reader scrolls past the hero:

{
  "motion": {
    "preset": "lively",
    "speed": 1.25,
    "easing": "cubic-bezier(.34, 1.4, .64, 1)",
    "header": { "behavior": "transparent-until-scroll", "blur": true },
    "ambient": { "background": "shift" }
  }
}

Your own entrance on the page title, and a call-to-action that breathes. A custom keyframe on the page-title hook plays on load; the built-in pulse plays on the header's call-to-action while it is hovered; cards rise into view:

{
  "motion": {
    "keyframes": [
      {
        "name": "title-in",
        "steps": [
          {
            "at": 0,
            "opacity": 0,
            "transform": "translateY(18px)",
            "clipPath": "inset(0 0 100% 0)"
          },
          { "at": 60, "opacity": 1, "clipPath": "inset(0 0 0 0)" },
          { "at": 100, "transform": "none" }
        ]
      }
    ],
    "targets": {
      "page-title": { "animation": "title-in", "duration": 700, "trigger": "load" },
      "cta": { "animation": "pulse", "duration": 900, "iteration": "infinite", "trigger": "hover" },
      "card": { "animation": "fade-up", "trigger": "reveal" }
    }
  }
}

In the console

Design → Motion has the preset and the main switches: page transition, reveal, header behaviour, link/button/card hover, background, progress bar, pointer glow, smooth scrolling, on/off and reduced motion. Each picker's first option, "From the preset", writes nothing. Keyframes and targets are set through the API.

The title band (titleBand)

An illustration behind the post title — a sky of layered clouds, a gradient wash, a brand-coloured plate — running edge to edge behind the title, the byline and the tags, and reaching up under the header if you want. It needs no customCss, and it adds no markup: the band is painted behind the title by the theme's own stylesheet, so it holds no words and hides none.

Key

Values

Default

layers

1–4 layers, painted top first (as CSS paints backgrounds). Each is exactly one of image, gradient or color

required

layersDark

The same, for a visitor in dark mode

layers in both

height

px, 80–1200

320

heightPhone

px on a phone (≤640px), 60–900

70% of height

above

px the band starts above the title, 0–400 — far enough and it runs under the header

48

blend

How the whole band mixes with the page behind it (a blend mode, below)

normal

titleColor, titleColorDark

The title's colour over the band, light and dark

the title's own

pages

posts (blog posts) or all (every page title the reader prints, docs included)

posts

A layer:

Field

Values

Default

image

An absolute https URL (an uploaded asset's public URL) or an inline data: image; an inline SVG is sanitised on the way in

gradient

{"type": "linear" or "radial", "angle": 0–360, "stops": [{"color", "at": 0–100}]}, 2–6 stops

linear, 180

color

A flat colour

size

cover, contain, auto (a picture's own size)

cover

repeat

none, x, y, both

none

x, y

Where a picture sits, as percentages of the band

50, 50

blend

How this layer mixes with the layers below it: normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, luminosity

normal

rate

Parallax as the page scrolls, −1 to 1: 0.4 sinks at 40% of the scroll speed (far away), -0.3 lifts faster (near). 0 is still

0

Colours are a hex value, transparent, or one of the reader's tokens by name: bg, surface, panel, ink, muted, brand, accent — so a band built from tokens follows your palette into dark mode on its own.

A path on this site (/api/assets/…) is refused: a stylesheet resolves it against the page, and on a custom domain that mounts a space under a path that is your own server, not ours. Use the asset's absolute URL.

The band is a picture first and motion second: it paints with motion.enabled: false, and only its layers' rate waits on motion being on. The drift never runs for a visitor who asked for reduced motion, and it is a scroll-driven animation where the browser has one (the same small fallback as motion.scroll elsewhere).

{
  "titleBand": {
    "layers": [
      {
        "image": "https://cdn.example.com/clouds-near.svg",
        "size": "contain",
        "repeat": "x",
        "y": 100,
        "rate": -0.15
      },
      {
        "image": "https://cdn.example.com/clouds-far.svg",
        "size": "contain",
        "repeat": "x",
        "y": 70,
        "rate": 0.35,
        "blend": "screen"
      },
      {
        "gradient": {
          "angle": 180,
          "stops": [{ "color": "#bfe3ff" }, { "color": "bg", "at": 100 }]
        }
      }
    ],
    "layersDark": [
      {
        "gradient": {
          "angle": 180,
          "stops": [{ "color": "#0b1430" }, { "color": "bg", "at": 100 }]
        }
      }
    ],
    "height": 340,
    "above": 120,
    "titleColor": "ink"
  }
}

PATCH {"titleBand": null} takes it off.

Custom code

Key

Limit

Notes

customCss

20 000 chars

Injected into the reader, scoped nowhere — write your own selectors. Sanitised: <, javascript:, expression( and @import are stripped. The child combinator > is kept

cssSnippets

≤ 50 × 10 000 chars

{ name, css, enabled } — named fragments you can switch off one at a time without deleting them

elementStyles

≤ 400 rules

{ selector (≤300), declarations, media?, state?, scheme?, page? } — what the studio's inspector and visual editor write. Selectors are scoped under .forumulate-reader. The declaration property is allowlisted (type, colour, spacing, border, box, size, picture, underline, layering, grid placement) and some values are narrowed (position never fixed, z-index 0–10, offsets ±100%/±50vh, heights ≤200vh); a declaration outside either is a 422 naming it and saying why, e.g. elementStyles.0.declarations.float. It used to be stored, echoed back on a 200, and then dropped on the way to the stylesheet

customJs

20 000 chars

Runs on every reader surface once the page has loaded and hydrated (browser idle after load), on your own domain only (not on forumulate.com or its subdomains). It is in the functional consent category, which a visitor cannot refuse, so no consent banner holds it back. Gets window.__theme_settings (below)

scriptOrigins

≤ 20 origins

Third-party script origins. Honoured only on your own domain, never on forumulate.com or a *.forumulate.com subdomain

customCss lands after the palette, the settings and the font rules, so it wins over all of them — no !important needed.

Where owner and vendor scripts run

The rule is about the host: code you or a vendor supply runs only on a domain of your own, because on forumulate.com and every *.forumulate.com subdomain a script shares a site with our /api and could call it as the signed-in visitor.

What

On your own domain

On forumulate.com / *.forumulate.com

Consent

customHead <meta> and <link> tags (PUT …/analytics-tags)

emitted

emitted

none needed: they run no code

customHead scripts and intercom

run

not emitted

functional: cannot be refused

scriptOrigins (an allowlist for scripts, not a script)

honoured

ignored

—

Chat-widget connectors

run

not emitted, unless the provider is one we mark safe for our origin

the provider's category

ads

run in the page

only inside a sandboxed iframe

marketing: nothing loads before a grant

ga4, gtm, segment

run

run

analytics: nothing loads before a grant

customJs

runs

not emitted (window.__theme_settings still is)

functional: cannot be refused

<script src="https://…"> in a nav panel.html / footer.html

loaded once

not emitted (inline code and on… never, anywhere)

functional: cannot be refused

One rule, every host: no owner script executes on our origin. customJs used to be the exception; it is now held to the same rule, because on our origin it shares /api with every signed-in reader. "Not emitted" includes the page's serialised data: on our hosts the script source is filtered out on the server and never reaches the browser at all. The details of customHead, and of what it strips, are in Theming: the rest of it.

Theme settings (typed inputs)

settingsSchema declares the inputs; settings holds the chosen values.

{
  "settingsSchema": [
    { "key": "hero_height", "type": "int", "label": "Hero height", "default": 320 },
    { "key": "show_authors", "type": "bool", "default": true }
  ],
  "settings": { "hero_height": 420 }
}

type is bool | int | string | enum | color (enum takes options). key is letters, digits and underscore, starting with a letter. Each resolved value (your value, else the declared default) is emitted as --setting-<key> for customCss to read, and the whole resolved object is handed to customJs as window.__theme_settings. A key with no value and no default emits nothing.

Interface strings

stringOverrides reword our UI strings for this theme: catalog key → locale → text.

{ "stringOverrides": { "siteChrome.more": { "en": "Everything else", "fr": "Le reste" } } }

Resolution is org override → theme override → shipped string, so an org still has the last word. Reader surfaces only.

The keys come from the catalog, and nothing else is accepted.

GET /public/orgs/{orgId}/i18n/catalog   →  { "catalog": { "siteChrome.more": "More", … } }

That response is every string a reader page can show and the English we ship for it; it needs no auth, it is the same for every org, and it is cacheable for an hour. A stringOverrides key that is not in it is a 422 naming the key — previously a guess (or a nonsense key) was accepted with a 200 and then honoured nowhere, on the one surface that otherwise refuses an unknown key by name.

An override replaces the source wording, which is why the catalog is English only. Keys under a console namespace are not in it: a space theme is never resolved by the console, so we would be accepting a key we could never honour.

Forum spaces (theme.forum)

A Community (forum) space reads theme.forum on top of every key above; any other space type refuses it with a 422 (a site theme may carry it for its forums). Every key is listed, with examples, in Theme a forum; each one is optional.

Key

Values

What it does

forum.look

clean, board, cards, qa, paper

A set of forum defaults laid under your keys: neutral paper and UI sans (all but paper), avatars, relative dates, tag chips, wider columns … paper = the forum before 2026-10

forum.list, forum.dates

see the guide

The topic list: avatars, columns, excerpt, thumbnails, density, paging, stats, header; date style

forum.front

see the guide

The front page's filter row and panel

forum.sidebar, forum.categoriesPage, forum.categoryPage, forum.tagsPage, forum.tagStyle

see the guide

The native sidebar, the /categories, category and tags pages, and how tags are drawn

forum.post, forum.topic

see the guide

The topic page: avatars, byline, body size, votes, quotes, menu, map, reply order, answer placement, share, rail, timeline

forum.chrome

see the guide

The composer dock and the search page's filter panel

A hidden forum index header (layout.indexHeader: "hide") stays in the page as its only <h1>, visually hidden, so screen readers and search engines still get the forum's name.

2026-10-12: forums read with the clean look by default

A Community (forum) space whose owner left the keys unset now reads with the clean look: neutral paper and ink, a UI sans, avatars beside posts and on the topic list, relative dates, coloured tag chips, a dot category badge once there are two categories, no vote count on discussions, wider columns, the topic map at three replies and the rest of the defaults listed in Theme a forum. Resolved at read — nothing is written to any space — and every key you set still wins. {"forum":{"look":"paper"}} restores the previous forum (except the bug fixes that shipped with it). Inside a site it applies too; set forum.look: "paper" on the site theme to keep a site's forums warm beside its blog. Other space types are unchanged.


2026-10-12: documentation spaces read with the clean look by default

A Documentation space whose owner left the keys unset now reads with the clean look: neutral paper and ink, a UI sans, the header always on, a quiet collapsible sidebar, one "Copy page" action, hairline tables and the rest of the defaults listed in Theme a documentation space. Resolved at read — nothing is written to any space — and every key you set still wins. {"docs":{"look":"paper"}} restores the previous reader (except the space PDF, now settings.readerTools.pdf: true on request). Other space types are unchanged.

An FAQ question, a careers role, a custom page, a legal document, the contact home and a course lesson are docs pages, and they read the page part of theme.docs: articleAlign, articleWidth, topRule, pageHead, frame, pageActions, toc, panel, aside, feedback, pager, issueLink, editLink, suggestEdits and localeFallback. The rest of theme.docs (sidebar, sections, mobileNav, header, ask, search, the switchers, api, look, …) describes a documentation site; those spaces refuse it with a 422 that names the keys. A site theme's docs keys reach these member spaces for the page keys only.

Each type starts from its own defaults (resolved at read, never stored; any key you set wins):

Type

Defaults

Careers

no "Was this page helpful?", no byline, the date it was posted (first publish), no page tree, one "← All roles" link over the title, contents only on a long role

Custom (landing, pages presets)

no tree, no rail, no breadcrumb, no byline, no date, no feedback, no previous/next, full-width article (unless you set contentWidth)

Custom (article preset)

the page tree, contents on a page with four or more H2s (a phone list below 1280px), a breadcrumb, the date it was updated, previous/next; no byline, no feedback; the docs article width (unless you set contentWidth)

FAQ

breadcrumb "FAQ › Section", search box "Search questions…", contents on a long answer and on an index of four or more sections

Legal

contents for a document with four or more H2s, no byline, no feedback

Contact

no feedback, no byline, no tree

Courses

the phone contents list (the lesson layout is the course's own)

A custom space is a page, not a docs site. Its layout preset (settings.preset) picks the arrangement, and the shape's space.index picks what its address shows:

  • landing (recommended): the space's address renders its home page (the one you named, else the first page) in place, full width, canonical at that address. The page's own slug still answers and names the space's address as canonical.

  • pages: the same, plus a header nav built from the top-level pages when the space has two or more and you have set no chrome.headerLinks of your own (your links win; a space inside a site wears the site's header instead).

  • article: the docs frame above, for a security or trust page that wants contents.

space.index: "landing" or "single" makes the address the page; "list" makes it the list of pages; "board" keeps the redirect to the first page. A front page you compose in the studio replaces the home page at the address. A custom page's structured data is a WebPage with no breadcrumb on the page the address renders (or wherever the visible breadcrumb is off); seo.schemaType (PUT …/spaces/{id}/seo, the "What this page is, to search engines" select in the SEO panel) declares AboutPage, ContactPage or ProfilePage in its place, or makes a SoftwareApplication or Product the page's mainEntity.

Every one of them comes back by key. Keys added for this:

  • docs.frame.tree: show | hide — the page tree beside the article.

  • docs.frame.rail: show | hide | auto — the on-this-page rail's default; auto draws it for a page with four or more H2s. theme.onThisPage and a page's own metadata.onThisPage still decide outright.

  • docs.pageHead.date: updated (unset) | posted — which date the head prints: the last publish ("Updated") or the first ("Posted").

  • docs.pageHead.breadcrumb.style: "back" — one "← {label}" link to the space's index instead of a trail (label: root.label, else "All roles" on a careers space, else the space's name).

  • docs.pageHead.breadcrumb.root: "space" — the trail leads with the space's own name, linked to its index.

  • docs.toc.phone now also drives the legal, FAQ-index and lesson pages: disclosure shows the contents as a collapsible list above the body below 1280px, where the rail is hidden.

{ "docs": { "feedback": { "show": true }, "frame": { "tree": "show" } } }

A careers space with that theme gets the feedback widget and the role tree back; everything else keeps its careers default.

A labelled section a page leaves empty (a role with no "How we hire") is no longer published as a heading over nothing, on any type.

Pages at site paths, block entrances, snippet variables (CU-3)

Page mounts — config.pageMounts on a site (PATCH /orgs/{o}/sites/{id}; console: Sites → "Pages at site paths"). One page of a member space answers at one segment of the site, or at its root:

# A site's config, not a theme key.
curl -X PATCH "$API/orgs/$ORG/sites/$SITE" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"config": {"pageMounts": [
        {"path": "/", "spaceId": "…", "page": "home"},
        {"path": "/pricing", "spaceId": "…", "page": "pricing"}
      ]}}'

/ replaces the generated site index. The page's address inside its space (/marketing/pricing) 308s to the mount, and the mount is its canonical — on the site's domain and on the platform copy. The site's sitemap index gains a sitemap-pages.xml child listing the mounts, and the space's own sitemap leaves those pages out. Refused with a 422 naming the row: a space that is not a member, a page that does not exist, a type without page addresses (LEGAL, the topic-backed types), a path used twice, /api, and a path that is a member space's own segment. Mounts live on site addresses only.

Header page picker. The site's header editor offers "Link to a page…" on every row (it writes the address the site serves — the mount, else /{space}/{page}) and "Fill from a space's top pages…", which replaces the rows with that space's top-level pages as ordinary, editable links.

A block's own entrance — motion / motionDelay on ANY block (the block menu's "Entrance" and "Entrance delay"; markdown {motion=rise motionDelay=200} or ::cards{motion="scale"}): fade | rise | scale | none, delay 0–1000 ms in steps of 100. It wins over the theme's motion.reveal for that block; none keeps one block still while the theme reveals the rest. Nothing is hidden in the served HTML, and reduced motion keeps only the fade.

Snippet variables — vars on a reusableRef (the snippet's box in the editor; markdown :::reusable{id="…" name="pricing-band" var-heading="Plans"}:::): up to 20 one-line values, each filling the snippet's own {{heading}} at that use only. A name the use does not set falls back to the space's variable.

Typing on the canvas. In the studio, double-click a heading, paragraph or button label inside a section to type over it in place (Enter saves, Escape cancels). Plain text only: a heading with a link, bold or a variable opens the full editor instead.

Goals, A/B tests, phone order, scheduled publish (CU-4)

Five more hooks every block takes (the block menu's last rows; in markdown {goal=signup}, {experiment=hero abVariant=b abWeight=30}, {orderSm=first}, or the same names as directive attributes, ::cards{orderSm="last"}).

goal — a conversion goal. A slug. A press on a button or link inside the block, or a form sent from inside it, is counted as that goal on that page: once per reader per goal per day, machines left out, through the same pipeline as page views. The analytics page shows each page's goals against its readers ("Conversions and tests"; GET /orgs/{o}/analytics/conversions).

experiment + abVariant (+ abWeight) — an A/B test. Blocks that share an experiment name are its arms; abVariant is a (the control), b, c or d; abWeight (1–100, unset = 50) is the arm's relative share. Each reader sees one arm, chosen before first paint by the page's own nonce'd script and kept in the first-party fx_ab cookie — so the HTML is identical for every reader and no cache varies on the cookie. A crawler and a reader without script see the control. The reader reports which arms it saw, and a goal reached on the page is attributed to them.

Settle a test from the analytics page (PUT /orgs/{o}/spaces/{s}/pages/{p}/experiments/{key}):

Body

Readers then see

{ "status": "decided", "winner": "b" }

arm b, everyone, at once

{ "status": "stopped" }

the control, everyone

{ "status": "running" }

the test again

{ "autoPick": true, "minVisitors": 500 }

the test, until an arm wins (below)

{ "goal": "signup" } / null

(judges the test by one goal / by any goal)

Auto-pick is off by default (a decision changes what every reader sees). Switched on, an hourly sweep decides when every arm the live page declares has minVisitors readers (default 200) and the leader beats the runner-up at 95% confidence (one-sided two-proportion test — the same figure the panel shows). A decision does not edit the page: its arms stay in the document, so resuming is one call; delete the losing blocks when you are done.

Platform kill switch: READER_EXPERIMENTS=off on the web tier serves every reader the control (docs/OPERATIONS.md).

orderSm — a block's place on a phone. first, 1–6 or last; at ≤ 640px it reorders the block among its siblings in a row of columns, a grid or a card grid ("picture above the text on a phone"). Visual order only — a screen reader and the keyboard follow the document. The editor keeps the written order.

Scheduled publish. The page writer's details rail and the studio's page settings show "Scheduled publish": pick a date and time and the page's open draft goes live then (POST …/pages/{p}/schedule), see the pending time, or cancel it. A schedule with nothing left to publish (the draft was already published by hand) is cleared rather than retried.

Per-type namespaces (theme.status, theme.legal, theme.faq, …)

These space types have a namespace of their own: status, legal, faq, roadmap, events, courses, careers, contact, custom and blog (docs, forum, changelog and the blog post keep their older docs, forum, changelog and post keys). Every key of every namespace, with its type and values, is in the generated theme namespaces reference. Each is a strict object holding the keys that only make sense for that type, layered over the shared keys above. Keys added here reuse the vocabulary of the blog, forum and changelog keys that mean the same thing.

<type>.look — default or paper. Unset is default.

Value

What it does

default

The type's default layer, laid under your keys: every key you set still wins, per field. Until a type ships defaults of its own, this is the reader as it is.

paper

No default layer at all: the reader exactly as it was before the type grew a look. The way back, in one key.

{ "events": { "look": "paper" } }

How the namespace is read:

  • Resolved at read, never stored. The reader gets the type's defaults under your keys; GET …/theme still returns only what you wrote.

  • Only on its own type. A space of another type ignores the namespace, so a site theme can carry status for its status page and events for its events space side by side.

  • Inside a site the order is type defaults < site theme < space theme.

  • Every key is a console control. The studio's design panel has one "every setting" section per type ("Status page: every setting", …), built from the schema, so a new key gets a control without UI work.

  • Every key is a CSS variable. Each leaf is published on the reader root as --<type>-<path> (events.list.layout → --events-list-layout; numbers are unitless px, true/false are 1/0), for the type's own stylesheet and for your customCss. look itself is not published.

The shared parts a type composes

A type does not invent its own words for a list, a card or a date: it uses one of these, under a key that says which part of its page it shapes (events.list, careers.card, status.tones). Every key in each is optional and independently settable.

Part

Keys

Values

List

layout, density, divider, hover, columns

rows cards grid compact table timeline; the forum list's compact cozy comfortable; none rule gap; none tint lift underline; 1–4

Card

surface, radius, pad, shadow

flat outline raised; 0–32 px; 0–48 px; none sm md lg (the components elevation steps)

Chip

style, case

the tag chip's chip outline outline-mono text eyebrow and the badge styles pill dot square bar; none upper

State colours

one key per state of the type, each { tone, color, colorDark }

tone: success warning danger info neutral brand — a palette slot, so repainting palette.success repaints it; color / colorDark: a hex override

Date

format, time, zone, relative

the blog's date formats (medium, day-month, iso …); none short; source viewer both; off recent always

Side rail

layout, items, sticky, align

the changelog release page's stacked side; the type's own items, in order; true/false; center start

Filter bar

style, sort, search

tabs pills segmented panel checklist; the type's own sort keys, in order; true/false

Subscribe

style, channels

link icon button email-popover menu; email rss atom json ics webhook slack, in order

Group headings

by, style, collapsible, sticky, counts

none day month year field; heading divider gutter-label eyebrow; true/false each

A state colour with tone is published as the palette slot itself (--status-tones-degraded: var(--reader-warning, …)), with that slot's dark value under dark mode; color replaces it and colorDark replaces it in dark mode.

Badges on every type

settings.frontDoor.badgeStyle (pill, outline, text; the forum adds dot, square, bar) is declared for every space type. Its rules now live in one stylesheet every reader loads, so any chip drawn as the shared badge — a status, version or state chip on a roadmap, changelog, events or status page — follows it exactly as a forum's category badge does.

Shared reader parts: chips, facts, people, dates

Every type draws the same four parts, so a type namespace composes the same choices for them (packages/shared/src/reader-parts.ts) rather than inventing its own:

Part

Choices

Unset

State chip

tone success warning danger info neutral brand (a palette slot, so palette.success repaints it), plus an optional color / colorDark hex; look pill outline text chip outline-mono eyebrow

the front door's badgeStyle; the type's default tone per state

Facts (facts.look)

list chips tiles kicker card

list, the strip under the title

People (people)

display: none names avatars avatars-names; role; size (px)

avatars-names, no role

Dates

format (the post date formats), time none/short, zone source/viewer/both, relative off/recent/always

long for a declared date; the source zone

A declared date field now reads as a date in the page's language ("1 March 2026") instead of its stored 2026-03-01; a yes/no field is a chip carrying its own label; an email (or a text field that holds one) is a mailto: link, and a field declaring schemaProperty: "telephone" is a tel: link.

zone: viewer re-spells only the time text after load, in the reader's own zone; the server renders the source zone, so nothing moves but the words.

The people line reads the space's author registry, which every type has at /orgs/{orgId}/spaces/{spaceId}/authors (the blog's …/blog/authors is the same registry).

Status pages: theme.status

Every part of a status page has its own keys, and each key is independent: the banner's style says nothing about the bars, the bars nothing about the groups. Unset keys take the bold defaults below (the default look); status.look: "paper" turns every one of them off and draws the page as it was before it had a theme.

Key

Values

Unset

status.colors.<state>

states operational degraded partial major maintenance noData, each { tone, color, colorDark } (the shared state colours)

the palette slot the state means (palette.success, warning, danger, info), then the page's own green / amber / red / muted

status.banner.style

rule solid tint outline centered

tint, a light wash of the state's colour

status.banner.icon / subline / showUpdated

true/false: a glyph before the headline; a line saying what the state means for the reader; "Last updated …" from the newest check, with the reader's time zone

icon on, subline off, "Last updated" on

status.banner.align / radius / pad

start center; 0–32 px; 0–64 px

start; square; 16 px

status.bars.shape

thin pill ribbon square (preset geometry)

thin

status.bars.height / width / gap / radius

8–64 px; 2–16 px (a fixed bar width; unset shares the row); 0–8 px; 0–8 px

the preset's

status.bars.days / daysPhone

30 60 90: the newest N days on a wide screen / under 640 px

every day the API sends; 30 on a phone

status.bars.fill

solid hatched (partial and outage days striped)

solid

status.bars.noData

full (a full-height neutral bar) tick (a short faint one)

full

status.bars.caption

true/false: "90 days ago — 99.98% uptime — Today" under each strip, and "Collecting data since …" said once above the components while the page is new

on

status.bars.percentDecimals

1 2 3: decimals in an uptime percentage (floored, so 99.996 is never "100")

2

status.bars.tooltip

native (the browser's title text) styled (a panel that opens on hover, tap and from the keyboard — ←/→ walk the days — listing the day's incidents and recorded outages with their length)

styled

status.bars.captionCase / captionTracking

none uppercase lowercase capitalize (the CSS text-transform words, applied to the caption under each strip); 0–0.3 em of letter spacing on it (Instatus' tracked "90 DAYS AGO ——— TODAY")

none; 0 (the caption as written)

status.components.layout

list grid-2 cards

list

status.components.container

none card (the whole board) card-per-group

none

status.components.card

the shared card part: surface, radius, pad, shadow

theme.components radius and shadow

status.components.dividers

true/false: a hairline between rows

off

status.components.statusStyle / statusCase

word icon chip icon-word; upper sentence

word, small capitals

status.components.rowMeta

full percent percent-latency none chart (the component's response time drawn under its bars, over metrics.range)

none (the caption says the percentage); full is uptime window, checks, latency

status.components.groupCollapse

none expanded collapsed: a group folds, showing its worst state

none

status.components.groupSummary

none count count-uptime beside a group's name

none

status.components.headline / headlineSize / headlineAlign

none first every: a large uptime figure ("99.99%", "uptime over the last 90 days") over the components band — the mean of the band's shown components that have earned a number, floored; first only over the first components band on the page (the hero of a split page); 24–96 px (three quarters on a phone); start center

none; 48; start

status.components.groupBy

group region: what the headings split the rows by — the owner's groups, or each component's region ("EU", "US-East") in first-seen order. The band's groups then names regions

group

status.legend.position / states

top bottom hidden; 3 (operational, partial, outage — no "no data"; each bar still names its day) 4 5 (adds maintenance)

top, 5

status.incidents.card

plain tinted accent-left (in the severity's colour)

plain

status.incidents.chips

true/false: severity and state as chips

off

status.incidents.updates

all latest collapsed (latest shown, the rest behind a disclosure)

all

status.incidents.time

absolute relative both

absolute

status.incidents.emptyStates

true/false: "No maintenance scheduled." when nothing is

on

status.incidents.foldOutages

true/false: recorded outages are entries in the day-by-day history and the day panels instead of their own list (only while the history is shown — a recorded outage is never hidden)

on

status.page.frame

plain card band-card (the page as a card over a band of colour)

plain

status.page.band / bandHeight

a state colour (tone or hex) for the band; 80–480 px

the brand colour; 270 px

status.hero.layers / layersDark

the hero illustration — up to four title-band layers (an https image or inline data: image, a gradient, or a colour; the same vocabulary and painter as theme.titleBand), and a set for dark mode. Pictures load through our image proxy; the box is server-rendered at its height, so nothing shifts

no hero

status.hero.placement / height / overlap

above-title (meets the header) below-title (under the title row and sub-page links); 80–480 px (three fifths on a phone); 0–160 px the first band (usually the banner) rides up over the hero's foot — Statuspage's classic header

below-title; 200; 0

status.hero.bleed / radius

true: edge to edge of the window; 0–32 px corners (ignored when it bleeds)

off; the reader's radius

status.page.emptyDays

0 7 15: the history as the last N days, each saying what happened or "No incidents reported."

7; 0 is the flat list of resolved incidents

status.page.nav

none links tabs: the page and its sub-pages (current status, history, uptime, maintenance) under the title, on every one of them

none (a sub-page shows a way back to the board)

status.incidents.links

true/false: each incident title on the board links to the incident's own page

on

status.history.view

list calendar (each month as a grid of days in its worst colour, above the month's list)

list

status.history.group

the shared group keys (style, counts, collapsible, sticky) for the month headings; by is always month

a plain heading

status.history.monthsPerPage

1 3 6 12

3

status.history.perMonth

0–50: incidents listed per month before "Show all N" (0 lists every one)

5

status.history.filter / archiveLink

true/false: the component picker above the archive; the "Incident history" link under the history band

on, on

status.incident.timeline / order

rail (a dot per update) list; desc (newest first, Statuspage) asc (as a story)

rail, desc

status.incident.components

true/false: the affected components as chips on the incident page, each linking to its own page

on

status.incident.actions

the shared item actions (items, style): copy link, share

every action the space allows

status.uptime.months / cell / monthPercent

1 2 3 months of calendar (the board keeps 90 days); square round; the month's uptime beside its name

3, square, on

status.maintenance.view / showPast

list calendar (the next three months, each window's days in blue); completed maintenance under the upcoming

list, on

status.page.timezone

local utc: the zone the page's times are spelled in until a reader picks one with the switch (utc is what the server renders; local re-spells them after load)

utc

status.page.refresh

0 30 60 120 300: seconds between quiet re-reads of the page while it is open and visible (no reload, no scroll jump; a hidden tab catches up when shown). The banner's "Last updated" moves with it

60; 0 never

status.page.favicon

brand (the site's icon) dot (a dot in the overall state's colour) alert (the brand while all is operational, the dot otherwise)

alert; brand

status.header.reportUrl / reportLabel

"Report a problem" beside the title: an https:// or mailto: address or a path on your site; its words

no link; "Report a problem"

status.header.zoneSwitch

true/false: a Local / UTC switch beside the title (and on every sub-page). It moves every time on the page at once and is remembered in the reader's browser

on

status.header.siblings

true/false: "Other status pages" beside the title — the organization's public status pages, each with a dot in its state's colour, linking to where it is read. Drawn only when there are two or more

on

status.partners.card / links / component

the partners band: plain tinted accent-left (in the maintenance colour); each window's name links to the vendor's page for it; the vendor component's name before each window

plain, on, on

status.header.languageSwitch

true/false: the original and every language an incident on the page has been translated into, as ?locale= links beside the title (and on every sub-page). Draws nothing on a page with no translations

on

status.page.language

reader (incidents open in the reader's own interface language where they have been translated into it) source (the original until the reader picks). A ?locale= address always wins; a missing translation, title or update reads in the original

reader

status.incidents.translatedNote

true/false: "Translated from the original · Show original" above an incident read in a translation ("Machine-translated…" when your DeepL key wrote it)

on

status.bars.drillDown

true/false: the styled day panel (bars.tooltip: styled) shows the day hour by hour, from the checks; a click on a bar (or Enter on the strip) opens the component's page at that day (components/{slug}?day=YYYY-MM-DD), which works without script. Probed components only

on

status.metrics.range / rangeSwitch

24h 7d 30d: the window every chart opens on; Day / Week / Month links above the charts (?range=)

24h, on

status.metrics.percentile

p50 p95 both: the response-time line(s); both dashes the slow tail

both

status.metrics.style / height / grid / tone

line area; px 80–320 (a row's chart is a third); gridlines at the axis ticks; the line's colour (a palette slot or hex)

area, 160, on, the operational colour

status.metrics.rows

what a row's chart (components.rowMeta: chart) draws: auto the measured response time, else the custom series the component owns (a metric's component, set in the console or by PUT …/status/metrics/{key} with component: <id or slug>) — so a manual component gets a chart; latency or metrics only that one

auto

status.widget.position / show

bottom-right bottom-left top-right top-left; incidents incidents-maintenance always

bottom-right, incidents-maintenance

status.widget.tone / radius / scheme

the card's accent (unset: the incident's impact colour); px 0–24; light dark auto

impact colour, 10, auto

The bold defaults are the default look's, and each is what the reference pages (Statuspage, incident.io, Instatus, Better Stack) do: no reference page hatches its days, a short tick made a new page's strip read as a dashed line, ninety bars on a phone are 1 px slivers, the banner is the loudest thing on the page, the strip's two ends are labelled with the percentage between them, a day opens a panel saying what happened, and a healthy page says "No incidents reported" rather than showing no history at all. The legend, every bar's title (or panel) and the strip's accessible name still say what each day was. While a component's window is too short to publish a percentage, a day just under 100% draws as operational, so the strip agrees with the state beside it; the day's percentage is still in its title or panel.

Charts, embeds and the Statuspage API (ST-4). Response time is charted from our own checks — p50 and p95 per hour, six hours or day — in the metrics front-page band (latency: [slugs]), under each row with components.rowMeta: chart, and on every component's page. An owner's own numbers are custom metric series pushed with a PAT (POST /orgs/{orgId}/spaces/{spaceId}/status/metrics/{key}/points, { "value": 0.4 }) and drawn by the same band (metrics: [keys]). Every chart is SVG drawn on the server: no chart library, no script, no third-party request. Elsewhere: <page>/widget.js is a floating card for a customer's own site that appears only while something is happening (theme.status.widget, overridable per tag with data-position, data-show, data-accent, data-scheme); <page>/badge.svg?style=status|uptime&theme=light|dark&size=s|m|l&component=<slug> draws the state or uptime; the systemStatus block places that badge in any other space; and <page>/api/v2/summary.json (with status, components, incidents, incidents/unresolved, scheduled-maintenances, …/upcoming and …/active) answers in Statuspage's own shape. All of them send Access-Control-Allow-Origin: *.

Hub pages, regions and vendors (ST-6). A component of kind space is another status page of the organization (targetSpaceId): its row wears that page's state, uptime and day bars, read live from the page itself, and links to it — so a company with one page per product or region can publish a hub that rolls them up (a private page is left off a public hub). Every component can carry a region, and components.groupBy: region heads the rows by it. A component of kind statuspage mirrors one component of a vendor's public Statuspage (url is the vendor page or its summary.json; vendorComponent its id or exact name, blank for the whole page): we read it every intervalSeconds (default five minutes), the vendor's word is the state, and its bars and uptime are counted from those reads. The vendors' announced maintenance is the optional partners front-page band (limit = the soonest N), styled by theme.status.partners.

Components say more than their name. A component is watched by an http check, a heartbeat, or nothing at all (manual — its state is what you declare, or what an open incident says). Each takes a description (a "?" beside its name), a linkUrl (its name links to its docs or product), a display (bars; status, the state without a strip; or hidden-unless-degraded, shown only while it is not operational) and a start date (days before it draw as no data, never as up). Groups are arranged in the studio: their order, a description under the heading, one level of nesting (a group inside another), and "start folded", which folds that group whatever components.groupCollapse says. An incident names the components it affects and its impact on each (degraded, partial outage, major outage, maintenance): while it is open each one reads at least that state, the days it spans take that colour on the bars (maintenance in blue), and the card lists them.

The status front page is made of bands. The board used to be one band (board) nobody could reorder or restyle. A generated status page now places intro, banner, active, components, outages, history and subscribe in that order; kpis (component counts as tiles) is offered and board (all of them at once) still works on a page that stored it. Each band takes a few placed options:

Band

Options

banner

align (start center)

active

only: incidents or maintenance

components

groups: these groups only, in this order

outages

limit

history

limit, days (only incidents that started in the last N days)

A status page has sub-pages (ST-3). Every incident has its own address, {status page}/incidents/{id} — impact and state chips, when it started and ended and for how long, the affected components, the write-up with its post-mortem sections, and the updates as a timeline; an Article (or, for maintenance, an Event) in its structured data. The feeds, the maintenance calendar and subscriber emails link there. {page}/history is the archive by month (a quiet month says so), with a component picker and ?month= for one month in full; {page}/uptime is one component's last months as calendars (GitHub's /uptime); {page}/components/{slug} is a component's own page with its calendars, its incidents and a subscribe menu scoped to it; {page}/maintenance lists what is under way, coming and done, with "Add to calendar" (maintenance.ics). All of them are in the space's sitemap. An incident written up after it was over (backfilled with its real start and end) is marked Retroactive.

Maintenance runs itself (ST-5). A maintenance window walks Statuspage's lifecycle — Scheduled → In progress → Verifying → Completed — and the page says which step it is on. At its announced start it moves to In progress, and at its end to Completed, on its own (turn either off per window in the studio, for work you verify by hand); each move refreshes the page, is announced to subscribers and channels, and reaches your webhooks. Subscribers are reminded before a window starts — 24 hours by default, set per page under "Telling people" (1, 6, 24, 48 or 72 hours, or never); a window posted closer to its start than that is its own notice. Incidents take a fifth severity, Notice: information rather than an outage, which moves no component and colours no day unless you link components with an impact. Incidents you post every quarter can be saved as templates (title, write-up, severity, components) and picked when declaring one. The light/dark control beside the page is the chrome's (chrome.themeToggle, shown unless you hide it).

Every band also takes spaceBefore / spaceAfter. The parts carry fx-1 hooks (data-fx-status-banner, -group, -row, -strip, -bar, -legend, -incident, -maintenance, -subscribe, -kpis, -actions) for elementStyles.

The register at /legal/{org} is a front page like every other type's: the space's intro (written in the studio) and the documents band, stored or generated. Its heading is the legal space's name (a register mounted at /policies and called "Policies" says so). Its arrangement is the space's layout preset:

Preset

What it draws

standard

A ruled list (the default)

split

A large heading in a left column, the documents ruled on the right, each with a Read button

cards

Each document as a card with its description, two to a row

no-front-door

No register page; each document keeps its own address

legal.list (the generic index: table, rows, grouped feeds, facets) still replaces the documents band when set (the shared type-index keys; its feed cards carry the same description, effective date and group as the register rows).

Key

Values

Unset → default layer

What it does

legal.register.group

none collection

none → collection

One group per named collection of the legal space, in the space's order, with the collection's description and icon. Pages in the default container come first.

legal.register.description

show hide

hide → show

The line under each title: the page's search description, else the first sentence of its "In plain English" summary.

legal.register.date

effective updated none

updated → effective

Which date each row prints. effective is the declared "Effective from", falling back to the last amendment or update.

legal.register.edition

true false

shown

The edition ("Edition 3") beside the date.

legal.register.readButton

true false

on under split, off otherwise

A "Read" pill on each row.

legal.register.search

auto always never

auto

A filter box over the documents; auto shows it from eleven documents up. Filters in place, nothing is fetched.

legal.head.lead

effective edition

edition → effective

The line under a document's title. effective: "Effective 15 September 2026 · Last updated 1 October 2026 · Edition 3" (edition muted). edition: "Edition 3 · Updated …".

legal.head.kicker

effective updated edition none

none

Moves one of those facts to a small upper-case line above the title (Linear's "EFFECTIVE DATE: …").

legal.head.kickerMono

true false

on

The kicker in the monospace face.

legal.head.rule

true false

on

The 2px ink rule above the register's heading and every document's title.

legal.date

the shared Date part

browser short date → long

How every date on the register, the head and the facts strip is spelled — one key for all three.

legal.facts.look

the shared Facts part

list

The declared-facts strip (governing law, what it replaces, …). Facts the head already states are not repeated there.

legal.numbering.style

none decimal alpha decimal-alpha

none

Automatic clause numbers: 1. / 1.1, A. / A.1, 1. / (a) / (i). Written into the heading text, so the contents rail, a copied heading and print carry them.

legal.numbering.levels

1–3

2

H2 only, H2–H3, or H2–H4.

legal.summary.style

plain callout

plain → callout

The "In plain English" slot in a tinted, ruled box.

legal.history

disclosure open none

none → disclosure

The edition history under a document's head: each edition's effective date, the author's "What changed" note and its editorial amendments, linking to the archived text (…/{kind}/v/{n}) and to "Compare with current" (…/{kind}?from={n}). disclosure is a closed "History" control.

legal.upcoming

banner none

banner

When a new edition is scheduled (published with a future "Effective from"), a notice over the title: "A new edition takes effect on … — read it" (…/{kind}/v/next, with a comparison).

legal.item.actions

the shared item-actions part (items, style)

every allowed action, row

A document's actions: copy link, copy / view as Markdown (…/{kind}.md), print or save as PDF, share. Kept to what readerTools (LEGAL: pdf and copyMarkdown on) and frontDoor.showShare (off) allow.

legal.docNav

none sidebar footer-list

none → sidebar

The register beside a document, grouped like the register, the current one marked: in the left column from 1280px (sidebar), or listed under the text (footer-list).

legal.gloss

inline-callout margin table-top

inline-callout

Clause glosses — a :::gloss callout under the clause it explains. margin stands it beside its clause on a wide screen; table-top gathers every gloss into a "Section / In short" table above the text, each row linking to its clause.

legal.definitions

list table inline-bold

list

The "Definitions" slot (one Term — what it means per list item) as a definition list; its terms join the space glossary in the page's DefinedTermSet and are underlined where the text uses them.

Numbering never touches a slot's own label ("In plain English", "The document") or anything inside the summary slot. A document whose headings are already numbered by hand is left as written — the studio shows a note saying so — rather than printed as "1. 1. Definitions".

A legal document's head also carries a visible breadcrumb: docs.pageHead.breadcrumb (the docs-family key), which the LEGAL layer sets to one "← {register name}" link (style: back). style: trail draws register › document; show: false removes it. There is none in an embed or when the register has no front door.

Editions (LG-2). Every edition a document has had is public: its text at /legal/{org}/{kind}/v/{n} (noindex, canonical to the current document, with a "This is not the current version" notice and the facts it was published with), and a line-by-line comparison at …/{kind}?from={n} (or …/v/{n}?from={m} between any two). Publishing a document whose "Effective from" is in the future schedules the edition: the current text stays in force (and stays the edition acceptance is measured against), the new one is readable at …/v/next, and on the day it takes over and readers are asked to accept again. The page's "What changed" field becomes the edition's note (staff can correct it later with PUT /orgs/{org}/legal/{kind}/editions/{n}). The notice email to people who accepted an earlier edition is the org setting legal.edition_notice (accepted | none), not a theme key.

Reading depth (LG-3). A printed document carries its address, edition and effective date in a print-only header, and every clause after the first starts a new page. An upcoming edition stays off every public surface — the document's docs address, search, llms.txt, the .md twin, feeds — until its day: the page's served revision is held on the edition in force. The register publishes its editions as a feed (/legal/{org}/atom.xml, rss.xml, feed.json, feed.xml; ?kind=privacy for one document) and takes email subscriptions (POST /public/orgs/{org}/spaces/{space}/legal/subscribe, scope.kinds for named documents); its "Get updates" control is drawn in frontDoor.subscribeStyle (link), and a placed subscribe band draws every channel. Each document has its own social card (…/{kind}/social-card.png). FX hooks: [data-fx-legal-row], [data-fx-legal-facts], [data-fx-legal-kicker]. The held page also serves the in-force edition's facts (LG-4): its docs address, nav and every reader of the published page print the edition in force's "Effective from", version and note until the day, while the editor keeps the upcoming ones.

Trust centre (LG-4).

Key

Values

Default

What it does

legal.variants.picker

links menu none

links

A document's other languages, on the document: a row of links, or one closed control that opens them. ONE control for both ways a policy is translated — its region / language versions (separate pages, variantOf) and its docs locale twins (POST …/docs/locales); a language offered both ways appears once. Unset, theme.docs.localeSwitcher decides (style: select = menu; its label names the control), then links.

legal.variants.register

picker chips none

picker

Versions on the register. picker: one region/language picker over the list (?variant=eu-de) that switches every row to that version where one exists. chips: each row lists its versions. A version is never a row of its own.

legal.variants.banner

notice none

notice

On a version marked "Translation for information only": "This translation is provided for information only. The {original} is the version that governs."

legal.subprocessors.sortable

true false

on

Click a heading of the sub-processor table to sort by it.

legal.subprocessors.changes

summary none

summary

"Since edition N: Added … Removed …" and "From {date}: …" (the upcoming edition) over the document.

legal.trust.compliance

{ heading?, items: [{ name, status?: certified|compliant|in-progress, logo?, href?, note? }], style?: tiles|list }

none

The compliance band: certification badges (SOC 2, ISO 27001, GDPR …). A logo outside our CDN goes through the reader's image proxy.

legal.trust.access

{ heading?, items: [{ title, description?, href?, access?: public|request }], requestHref? }

none

The access band: the document library. A request document is gated where it lives (a page in a private space, opened by its share link or the reader gate), so the band offers Request access — to requestHref, else the org's contact space with the document named in ?subject=.

legal.trust.status

pill card none

none

The status band: the org's status page's live state.

A version is its own page in the legal space, with its own address (terms-de, terms-eu), and three declared fields: Variant of (the original's address), Region, Language (a code: de, pt-BR), plus Translation for information only. Editions, history, the .md twin and the feed work on it unchanged; the document page emits hreflang for every version that declares a language (and x-default on the original). A version is never asked to be accepted on its own: members accept the original.

The Sub-processors section takes a table (Entity | Purpose | Location | Transfer mechanism | Added — columns are found by their headings). Its rows are published at …/{kind}/subprocessors.json (and GET /public/orgs/{org}/legal/{kind}/subprocessors) as the edition in force states them, with what the last and the upcoming edition add, drop or change; the register's update mail names the processors an edition adds and removes.

The register's generated front page places compliance above the documents and access and status below them; each draws nothing until its key is set, and all three move like any band.

{ "legal": { "look": "paper" } } turns every bold default above off at once.

A changelog's labels and its default look (theme.changelog, CL-1)

A release declares a change type (the type field: New, Improved, Fixed, Deprecated, Retired, Security — rename the options in Design → shape), a breaking flag and a channel. The reader draws them as badges, filters by them (?type=fixed, ?breaking=1, ?channel=beta), and the feeds narrow by the declared type (rss.xml?type=fixed).

Since 2026-10-14 a changelog reads with the default look: the date as an eyebrow over the title, a dot on the rail at each release, the index showing each release's summary and a "Read release" link instead of the whole body, tag chips in the tags' registry colours, the entry title a step above any heading in its body, labels on, and tag tabs generated over the list when the changelog has more than one tag. look: "paper" restores the reader exactly as it was before. Every key below also works on its own.

{
  "changelog": {
    "look": "default",
    "typeBadge": {
      "show": true,
      "look": "outline-mono",
      "case": "upper",
      "placement": "eyebrow",
      "glyph": true,
      "tones": { "fixed": { "tone": "info" }, "better": { "color": "#0e7c66" } }
    },
    "breakingBadge": "badge",
    "upgradeNotesFirst": true,
    "channelBadge": { "show": true, "skip": ["Stable"], "tone": { "tone": "warning" } },
    "tagStyle": "chip",
    "tagOverflow": 3,
    "tagInk": "tag",
    "versionBadge": { "fill": "#fde8e8", "ink": "#b42318", "mono": true, "prefix": "v", "nowrap": true },
    "entry": { "body": "summary", "readMore": "Read the notes", "titleAboveBody": true },
    "facts": { "look": "chips", "cta": "button" },
    "filters": { "types": true, "tabInk": "tag", "style": "segmented" }
  }
}

Key

Values

Default (paper)

look

default, paper

default

typeBadge.show / .look / .case

on/off; the shared chip looks; none/upper

on, outline-mono (off)

typeBadge.placement

before-title, after-title, eyebrow, gutter (eyebrow without a gutter)

eyebrow

typeBadge.glyph

glyph before the word (icons, never emoji)

on

typeBadge.tones.<type>

{tone} palette slot or {color, colorDark}; key = option lower-cased

New success, Improved info, Fixed brand, Deprecated warning, Retired neutral, Security danger

breakingBadge

badge, row-tint, none

badge (none)

upgradeNotesFirst

a breaking release's page opens on its "Upgrading" slot

on (off)

channelBadge.show / .look / .skip / .tone

the channel beside the version; channels with no badge

on, outline, ["Stable"], info (off)

tagStyle

chip, outline, outline-mono, text, eyebrow

chip (the old bordered list)

tagOverflow

1–12 chips, then "+N"

3 (all)

tagInk

tag (registry colours), neutral

tag

versionBadge.fill / .ink / .fillDark / .inkDark / .mono / .prefix / .nowrap

the version pill

brand soft fill; nowrap on (off)

entry.body

full, summary

summary (full)

entry.readMore

the summary's link words

"Read release"

entry.titleAboveBody

title sized by --reader-entry-title, body headings a step below

on (off)

facts.look / facts.cta

the shared facts looks; "Try it" as link or button

chips, button (list, link)

closes

the roadmap requests the release shipped: list, count ("Closes 3 requests"), none

list (none)

filters.types

change-type chips on the filter and tags bands

on when any release has a type (off)

filters.tabInk

tag tabs in neutral ink or each tag's colour

neutral

filters.style

tabs, pills, segmented

tabs

frontDoor.showShare (the share control on a release page) now defaults to on.

A changelog's entries, grouping and release pages (theme.changelog, CL-2)

How much of each release the index shows, how it is grouped and dated, and what a release page carries beyond its notes. A release now accepts coverUrl / coverAlt, bylines (written names or {author: key} from the space's author registry) and authorIds (org members) on POST/PATCH …/changelog, the same fields as a blog post. The generated front page places the tags band (tag and change-type tabs) over the list; it draws nothing until there are two tags or a type to choose between, and look: "paper" leaves it out. A new preset, rows, opens the index as one ruled line per release (the feed block's compact layout).

{
  "changelog": {
    "entry": {
      "body": "collapsed",
      "cover": "above-title",
      "coverAspect": "16:9",
      "authors": "avatars",
      "readTime": true
    },
    "group": { "by": "month", "style": "divider", "sticky": true, "counts": true },
    "datePlacement": "gutter",
    "gutter": { "items": ["version", "date", "type"] },
    "dateFormat": "day-month",
    "release": {
      "nav": "prev-next",
      "moreCount": 3,
      "breadcrumb": true,
      "authors": "avatars-names",
      "readTime": true,
      "actions": { "items": ["copyLink", "copyMarkdown", "viewMarkdown"], "style": "row" },
      "cover": true,
      "side": ["back", "date", "author", "readTime", "actions", "share"]
    }
  }
}

Key

Values

Default (paper)

entry.body

full, summary, title (the title and labels alone), collapsed (summary, then the notes folded under "Full notes")

summary (full)

entry.cover

none, above-title, below-title, gutter-thumb (under the title without a gutter)

above-title (none)

entry.coverAspect

16:9, 2:1, auto (the picture's own shape)

16:9

entry.authors

none, names, avatars, avatars-names

none

entry.readTime

"4 min read" in each entry's meta

off

group

the shared grouping: by (none, day, month, year, field + field), style (heading, divider, gutter-label, eyebrow), collapsible, sticky, counts. by: day prints each date once, in its heading. A front page's spaceFeed{group} beats it

none

gutter.items

what the date gutter holds, in order: date, version, type, title (each leaves the main column)

the date alone

dateFormat

the blog's date formats (short, medium, long, full, day-month, day-month-short, month-year, month-year-short, iso) on the index, gutter and release page

"October 14, 2026"

release.nav

none, prev-next (the releases shipped before and after), more (the newest others)

prev-next (none)

release.moreCount

1–10 releases for nav: more

3

release.breadcrumb

"Changelog / Release" in place of the back link

off

release.authors

the people it credits, as entry.authors

avatars-names (none)

release.readTime

"4 min read" under the title

on (off)

release.actions

the shared item actions (items: copyLink, copyMarkdown, viewMarkdown, print; style: row, icons, menu), kept to the space's reader tools; {"items": []} for none

every allowed action (none)

release.cover

the cover over the release's title; it is also the page's share image

on (off)

release.side

the side column's items now include author, readTime and actions

every item

In the console (CL-3). Every theme.changelog key has a control under Design → "Changelog: every setting", drawn from the schema. The one hand-built control left is "Changelog on a phone" (header.rightPhone), because "Nothing" is an empty list and the generic editor reads an empty list as unset. In the studio, double-click a part of the changelog to open the keys that draw it:

Double-click

Opens

a date

dateFormat, datePlacement, dateTone, stickyDate, gutter (next to the "show dates" switch)

the rail / date gutter, or the gap between entries

rail, datePlacement, gutterWidth, columnWidth, entryGap, gutter

a type, breaking or channel badge

typeBadge, breakingBadge, upgradeNotesFirst, channelBadge

the version pill

versionBadge (next to the "show version" switch)

a release's tags

showTags, tagStyle, tagOverflow, tagInk

FAQ (theme.faq)

An FAQ opens as a help centre by default: a search hero, questions as ruled rows with a chevron, feedback only under a question the reader opened, and "Still need help?" cards under the list. { "faq": { "look": "paper" } } brings back the plain page (heading, filter box, every answer open with a feedback row, one-line contact link). Every key below is optional; set one and the rest keep their defaults.

faq.item — one question. An FAQ item is a collapsible, so its look uses the words of components.expandable, and when you have given every collapsible a look there (components.expandable.style/icon), the FAQ follows it instead of the default rows.

Key

Values

Default

style

row card bordered spoiler plain

row (unless components.expandable.style is set)

icon

chevron plus none native

chevron (same exception)

iconPosition

start end

end

questionSize

px, 12–40

the style's own

questionWeight

300–800

the style's own (500)

answerInk

default muted

default

gap

px between questions, 0–64

the style's own

singleOpen

true — opening one closes the others (starts all closed)

off

expandAll

true — "Expand all / Collapse all" above the list

off

feedback

each hover open-only off

open-only

open-only shows "Was this helpful?" under a question the reader opened; on the sectioned preset, where every answer starts open, it shows on hover or focus (always on touch screens). hover keeps the row's space, so nothing moves.

faq.search — the search and its hero.

Key

Values

Default

placement

hero (heading + lede + large box), inline (the search band), none

hero

headline

text

the space's name

lede

text

the space's description

placeholder

text

"Search questions…"

size

sm md lg

lg

align

start center

center

layers, layersDark

the title band's layers (image, gradient or colour)

none (no background)

ink, inkDark

the heading and lede colour over a painted hero

the page's

shortcut

true — ⌘K / Ctrl K focuses the box, and it says so

on

The box filters the questions in place. When nothing matches, the reader is offered the site's own search (when the header has one) and Ask AI (when the space has it), with the query carried over. The hero is the front page's title band, so hiding or moving that band moves it; settings.frontDoor.showSearch: false still removes every box.

{
  "faq": {
    "search": {
      "headline": "How can we help?",
      "layers": [{ "gradient": { "stops": [{ "color": "#eef2ff" }, { "color": "#ffffff" }] } }]
    },
    "item": { "style": "card", "icon": "plus", "singleOpen": true }
  }
}

faq.contact — the "Still need help?" cards (the front page's contact band, after the questions, and the foot of every question page).

Key

Values

Default

contact

true/false — the contact space you chose in settings.faq.contactSpaceId

on

community

true/false — your forum

on

docs

true/false — your documentation

on

status

true/false — your status page, with its live state

on

email

an address, printed as "Or email …"

none

heading

text

"Still need help?"

A community, docs or status card appears only when your organization has exactly one public space of that kind, so a reader is never sent to the wrong one. With no card to draw, the one-line contact link stays. settings.frontDoor.showContact: false turns the cards off.

faq.categories — the category cards (the front page's categories band, after the intro): one card per headed section with its icon, its description, "9 questions", and a link to the section's page (/c/<slug>, the collection hub). A group taken off the questions' Topic field has no hub; its card jumps to its heading on the landing. The cards are the spaceCategories block reading this FAQ, so a composed page can place the same block with every block key (it reads FAQ, docs, custom and legal spaces as well as forums).

Key

Values

Default

show

auto (from minSections headed sections up), always, never

auto

minSections

1–12

3

layout

grid tiles list nav

grid

columns

1–4 on the widest screen (2 on a tablet, 1 on a phone)

3

icon

true/false — the section's icon (nav.icon) in a soft disc

on

description

true/false — the section's description

on

count

true/false — "9 questions"

on

heading

text over the cards

none

settings.frontDoor.showCategoryCards: false turns the cards off.

faq.featured — "Popular questions" (the featured band, after the cards): rows with the answer's first line and the category as a chip. Mark a question popular with its Popular question field (fields.featured: true). It is a spaceFeed of this FAQ, which now also takes filter.featured and the sorts helpful ("Was this helpful?" votes, smoothed so one yes does not beat 40 of 42) and views (the last 90 days).

Key

Values

Default

show

auto (when there is something to list), always, never

auto

from

featured (marked questions), helpful, views

featured

limit

1–12

5

layout

rows compact grid accordion

rows

excerpt

true/false — the answer's first line

on

chips

true/false — the category chip

on

heading

text

"Popular questions"

settings.frontDoor.showFeatured: false turns the band off.

Presets. Besides sectioned and accordion, an FAQ offers:

  • help-centre — the search hero, the category cards, popular questions and "Still need help?". Each category's questions live on its hub page, so the landing lists them only as search results; a Topic group (no hub) keeps its questions on the landing, closed. With no questions on the page, it emits no FAQPage markup.

  • two-column — the pricing-page FAQ: heading, intro and contact in a wide left rail (first on a phone), the questions closed on the right. The rail is the front page's sidebar, so composing the page can move or replace it.

{ "faq": { "categories": { "layout": "tiles", "columns": 4 },
           "featured": { "from": "helpful", "limit": 6 } } }

Search depth, feedback and the long tail. More keys on the families above, and three of their own:

Key

Values

Default

search.suggestions

up to 8 short texts, drawn as chips under the box; a chip fills it

none

search.highlight

true/false — mark the matched words in questions and answers

on

search.count

true/false — "4 results" while a query is typed

on

search.languages

false hides the language links

shown when the FAQ is published in 2+ languages

item.followUp

true/false — after a "No", ask "What were you looking for?"

on

item.helpfulStat

true — "Helped 93% of readers" under an answer

off

item.helpfulStatMin

votes before the rate shows, 5–1000

20

item.copyLink

true/false — a copy-link button on each question (on hover/focus; always on touch)

on

jump

chips (the sections as links above the list), rail (the on-this-page rail), none

the rail from 4 headed sections

audience.chip

true/false — the question's Asked by field as a chip ("For: Trial users")

on

audience.filter

auto (tabs from 2 audiences up), tabs, off

auto

audience.label

the word before the audience

"For:"

follow.sections

true/false — a Follow button beside each named section's heading

on

follow.all

true/false — "Follow all questions" above the list (the whole FAQ)

off

  • Searches are recorded. Each settled query (three characters up, 1.5 s after the last keystroke, once per query per visit) is posted with how many questions it matched, so a FAQ's unanswered searches appear in the space's analytics under "Searches with no results", beside docs search.

  • The follow-up note lands in the space's written feedback (docs/feedback) next to the "No" it followed; the "No" still counts.

  • Deep links. #<question-slug> (or any anchor inside an answer, or #section-<slug> for a section) opens the question, scrolls to it and flashes it once — in every browser, not only those that open <details> on a fragment by themselves.

  • Languages. The FAQ reads ?locale=<code> like the docs reader: each question is served in that language where its translation is published and in the original otherwise. The landing names every published language as an hreflang alternate, and a translation is its own canonical.

  • Audience tabs narrow the list to one audience; a question with no audience is for everyone and stays under every tab.

  • Follow. A follower is emailed when a question is published in the section for the first time, or republished with different words. A republish that changes nothing, or one the author marked editorial, mails nobody; translations do not mail. A signed-in reader with a verified address follows in one click; anyone else gives an address and confirms it (the org's captcha applies). Followers are the FAQ space's newsletter subscribers, scoped {"sections": ["<slug>"]} (no scope = the whole FAQ), and every mail carries the newsletter's one-click unsubscribe. The two mails are templates faq.question-added and faq.question-changed (Settings → Email templates).

  • Comments on a question's own page are the space's comments setting (Settings → Readers), like any docs page.

  • elementStyles hooks: faq-item, faq-question, faq-answer, faq-section, faq-search, faq-category-card (see the hooks table in the advanced theming guide).

theme.roadmap — the board, its cards and the vote

A roadmap's board draws from four families of its own, beside item (the item page's side rail and actions). Every key is optional; look: "paper" is the board exactly as it was before any of them existed.

Key

Values

Unset (paper)

Default layer

lanes.style

plain panel tinted-header

loose text

panel

lanes.headerRule

true/false — a hairline under each lane's head

off

—

lanes.gap

0–48 px between lanes

16

—

lanes.minWidth

160–480 px, a lane's narrowest (fit, scroll)

260 / 300

—

lanes.overflow

wrap (2 columns, 5 at full width), fit (columns sized to the lanes shown; a wide lane grids its cards), scroll (sideways)

wrap

fit

lanes.empty

show ("Nothing here yet"), collapse (one thin row under the board), hide

show

collapse

lanes.countStyle

text pill

text

pill

lanes.phone

below 768px: stack, tabs (one lane at a time, server-rendered), swipe

stack

tabs

lanes.glyph

every state's mark: none dot ring half check

none

dot

lanes.tones.<state>

a state colour ({ tone, color, colorDark }, the shared part) for submitted planned inProgress shipped declined

brand chip

grey, info, warning, success, grey

lanes.statuses.<state>

{ glyph, description } — that state's own mark and one line under its name (120 chars)

—

—

card.frame

the shared Card part (surface, radius, pad, shadow)

today's surface

—

card.density

the forum list's compact cozy comfortable

—

—

width

the board page: board (--reader-board, so layout.widths.board still measures it), max (the page shell), full (the window, gutter kept — a many-lane GitHub-style board)

board

—

card.eyebrow

none tag (first tag) theme (the theme field) board (the item's board, on a feed of several boards' lanes) number (the item's number on the board, "#24")

none

—

card.meta

the card's foot, in order: comments target effort tags author number

none

comments, target

card.numberPrefix

the word before the number, 24 chars: "roadmap" → "roadmap #24". The number is the item's own (assigned once, in order of asking — the forum's topic number)

"#24"

—

card.titleLines / card.excerptLines

1–4 / 0–4 (0 hides the excerpt)

unclipped / 2

—

card.progress

the item's progress field (0–100): bar (a thin bar under the title), text ("40% done" in the foot), none

none

bar

card.cover

the item's coverUrl across the top of the card: top none (box reserved, no shift)

none

top

card.coverAspect

wide (16:9) banner (3:1) square

wide

—

vote.style

box pill inline ghost

the bordered "▲ n" box

pill

vote.position

start end top-end footer (the forum list's start/end, plus two)

start

start

vote.glyph

chevron arrow thumb triangle

▲

chevron

vote.votedFill

brand ink — what a cast vote fills with

brand

—

header.subtitle

the line under the board's title (280 chars)

"What we're building…"

—

header.subtitlePlacement

under-title under-submit

under the submit band

under-title

header.layout

line (the title row) hero (a portal hero in its place: header.hero, with header.subtitle as its lede and Subscribe beside its button)

line

—

header.hero.eyebrow / .headline

a kicker over the headline (60) / the <h1> (140; unset = the board's name)

—

—

header.hero.align / .size

start center / sm md (the title it replaces) lg (a display step)

start / md

—

header.hero.submit / .submitLabel

a button that jumps to the submit band and opens the form (only when the page places that band) / its words (40)

shown / "Suggest an idea"

—

header.hero.layers / .layersDark / .ink / .inkDark

the hero's background and the words over it — the title band's layers, as faq.search

none

—

{ "roadmap": { "lanes": { "style": "tinted-header", "tones": { "planned": { "color": "#525df9" } } },
               "vote": { "style": "box", "glyph": "chevron" } } }

The lane name and the item page's status chip are the shared badge, so frontDoor.badgeStyle shapes them — and ROADMAP now offers the forum's dot, square and bar too (dot is Canny's coloured dot before a muted name). frontDoor.showEmptyLanes: false still hides empty lanes whatever lanes.empty says.

Voting from the reader. The vote control is the same on every card and beside the item's title. A signed-in reader votes as themselves (trust weights and voteBudget apply) whether or not the board takes anonymous votes; a visitor votes anonymously only where anonymousVotes is on, and is otherwise offered the space's sign-in. A vote stays pressed on reload.

Importance votes and votes on behalf (RM-5). roadmap-settings voteMode: "importance" turns the press into a choice of Nice to have, Important or Critical (1–3), and the board ranks by the sum; a reader can change their level or take the vote back from the same popover. binary (the default) ignores stored levels, so switching back ranks by plain counts again. Staff with forum.moderate record a vote for a customer with no account (POST …/roadmap/{topicId}/proxy-votes {name, importance}); it counts like a visitor's vote and is never named on the public voters list. Moderators also leave internal notes on an item (whisper: true on the console comment door) that no public door ever returns.

Hidden lanes are private (#81): items in a lane in hiddenStatuses 404 on every public door unless the lane is also in publicStatuses (the lane editor's "Items open by their own link"). Boards that hid lanes before this existed were migrated with their hidden lanes public, so their links still open.

theme.events — the calendar, its cards and the event page

An events space reads list (the calendar), page (one event), the state chips and their colours, beside item (the shared side rail and actions; set, it wins over page). Every key is optional; look: "paper" is the calendar and page exactly as they were before any of them existed.

The layout preset is an arrangement of these keys. The space's preset (standard, luma, community, conference, webinar — settings.preset, chosen at creation) lays its keys between the default layer and yours, so a key you set always wins and the rest stay the preset's:

Preset

What it sets

standard

the default layer only

luma

list: rows grouped by day (gutter-label), pastMode: tabs, square thumbnails on the right, hosts; page: split-cover-left, sticky-side, facts.look: tiles

community

list: timeline grouped by month (eyebrow), pastMode: tabs, hosts and who is going; page: hero-wide, sticky-bottom-mobile

conference

list: grid, covers on top at 16:9, dateTile: pill; page: hero-full, sticky-side, countdown, tiles; agenda: grid-by-stage; speakers: 5 across, square

webinar

list: 16:9 thumbnails on the left, dateTile: outline, 12 past events, times in both zones; page: hero-wide, sticky-side, timeDisplay: both, countdown; agenda: table in both zones; speakers: list; series.list: next

list takes the shared index keys (layout, group, facets, … — see "A type's index and item pages" below; any of them hands the calendar to the generic feed, which still draws the calendar's own card in cards, grid, rows and timeline), and these:

Key

Values

Unset (paper)

Default layer

list.pastMode

stacked (a dimmed list under the upcoming one), tabs (Upcoming / Past, the past an address: ?when=past), hidden, link (one "All past events" link)

stacked

—

list.pastLimit

1–50 past events before "All past events (N)"

20

6

list.dateTile

none block outline pill — the month over the day, in the event's own zone

none (a text line)

block

list.cover

none thumb-left thumb-right top

top

thumb-right

list.coverAspect

1:1 4:3 16:9 2:1 3:1

2:1 on top, 1:1 as a thumbnail

1:1

list.showAttendees / showCapacity / showLocation

"12 going" / "12 spots left" / the place

on

—

list.showHosts

the hosts' names and pictures

off

—

list.density

the forum list's compact cozy comfortable

—

—

list.timeDisplay

source (the event's zone) viewer both

source

—

list.zoneToggle

an "Event time / My time" switch over the list — every card's time flips at once, remembered on the device; shown only to a reader whose zone differs

off

on

list.view

list, or calendar — a month grid, one month per address (?month=2026-11), each event on its day in its own zone, a list of the month's days on a phone

list

—

list.viewSwitch

"List | Month" links over the calendar; the reader's pick is an address (?view=calendar)

off

on

list.weekStart

sunday monday — the grid's first column

Sunday in English, Monday otherwise

—

list.filters

filter rows over the own list, beside the tag row: mode (in person / online — a hybrid event matches both) and month; the shared facet bar (facetStyle), ?f_mode= / ?f_month=; a row with one choice is not drawn

none

["mode", "month"]

list.pageSize

(shared key) past events per page on the past view, which says the total — "Past events (81)" — and pages with ?page=

20

—

page.layout

single (a banner over the title), split-cover-left (the square cover and the register card in a left column — Luma), hero-wide (a wide cover across the page), hero-full (edge to edge, the title over its foot)

single

—

page.registerCard

inline (after the description), sticky-side (beside it, in view while it scrolls; on a phone straight after the facts), sticky-bottom-mobile (inline, plus the phone bar)

inline

sticky-side

page.phoneBar

a bar at a phone's screen foot — when, and an "RSVP" button to the card — while the event is open

on under sticky-bottom-mobile

on

page.cover

show, aspect (as above), radius 0–48 px, shadow (none sm md lg)

shown, the layout's aspect, 12 px

—

page.facts.look

list (a line each), tiles (date and place as tiles with a glyph), chips, card, kicker

list

tiles

page.showCountdown

"Starts in 2 days 4 hours" before an open event

off

—

page.showRelated / relatedCount

"More events": the calendar's next 1–6

off / 3

on

page.timeDisplay

source viewer both

source

—

page.zoneToggle

the same switch under the event's date

off

on

agenda.layout

the programme (EV-4): list (time gutter, title, stage, speakers — Config), table (time · session · speakers — Vercel Ship), grid-by-stage (a column per stage; list with one stage)

list

list

agenda.dayTabs

one tab per day (?day=); off: every day stacked under its heading

on

—

agenda.showFilters / showCount

the track / stage / level / tag row (?track=&stage=&level=&tag=) / "Showing N sessions"

on

—

agenda.showSpeakers / showSave / showAddToCalendar

speakers on each session / the "My agenda" star and view (?mine=1, signed-in readers) / each session's own .ics

on

—

agenda.timeDisplay / zoneToggle

the session times' zone / the zone switch on the programme pages

source / on

—

speakers.layout

grid (cards), carousel (a sideways row with scroll-snap, no script), list (with bios)

grid

grid

speakers.columns

2–6 cards across under grid (fewer on smaller screens)

4

4

speakers.avatarShape

circle rounded square

circle

circle

speakers.showBio / showSocials

the bio's first lines on each card / their links

off under grid/carousel, on under list / on

—

page.showMap

where it is, under the description, for an event with a venue or a place (not an online-only one)

off

on

page.map

style: card (drawn by us — a pin, the name and address, an "Open map" link; no request leaves our origin) or tile (OpenStreetMap tiles at venue.lat/lng, fetched by our server through the image proxy so the reader's browser still asks only us; without coordinates it draws the card); zoom 3–18; aspect (as coverAspect); linkTo google apple openstreetmap none

card, 15, 2:1, google

—

page.showAttendees

"N going" with the faces of attendees who chose to be shown — only when the space shows attendees publicly

off

on

series.list

a repeating event (EV-6) in the list: each (every date its own card — each is RSVP'd on its own) or next (one card per series, its next date, "Repeats · +N more dates"; the past keeps every date)

each

each

series.showBadge

a "Repeats" line on a series' cards

on

on

series.showDates / datesCount

"More dates" on an event's page: the rule in words ("Every week on Tuesday · 10 dates"), the next 1–24 dates (this one marked, a cancelled one struck), the rest under "All dates"

on / 6

on / 6

series.showSubscribe

"Add the series to your calendar": ONE repeating calendar entry (RRULE, skipped dates as EXDATE)

on

on

after.recordingFirst

once an event is over and has a recording, the page leads with it and the RSVP card steps aside

off

on

after.recording

player (a known video host embedded through the same sandboxed renderer as a page's embeds, or a video file played in place; anything else falls back to the card) or card ("Watch the recording", linking out)

player

player

after.gallery

the event's photos: layout grid carousel (scroll-snap, no script) hidden; columns 2–6 on a desk; aspect 1:1 4:3 3:2 16:9 — every photo reserves its box

grid, 3, 4:3

grid, 3, 4:3

after.showRecap

"Read the recap" once it is over, when the event links one (a blog post)

on

on

after.showFeedback

the event's feedback form (one of the organization's Forms) under it once it is over

on

on

chips

the state pill's style (the badge looks) and case

frontDoor.badgeStyle

—

tones.<state>

a state colour ({ tone, color, colorDark }) for cancelled postponed live rescheduled full

danger, warning, success, info, grey

—

dateTileTone

the date tile's colour ({ tone, color, colorDark })

the brand

—

{ "events": { "list": { "pastMode": "tabs", "dateTile": "outline", "cover": "top", "coverAspect": "16:9" },
              "page": { "layout": "hero-wide", "showCountdown": true },
              "tones": { "live": { "color": "#e11d48" } } } }

The front door adds showSearch (a search box, off; ?q=), showTags (a row of the calendar's tags that filters it, ?tag=), showSubscribe and showRss (the calendar's subscribe menu: email for each new event, the RSS/Atom/JSON feeds, and the calendar as a webcal:// subscription on the reader's own host — each narrowed to the tag on a ?tag= view), and showPast (off hides past events whatever pastMode says). With nothing upcoming the calendar offers to subscribe rather than showing a bare past list. A live event is listed first, under "Happening now".

An event with sessions (EV-4) gets a programme: …/{event}/agenda, …/sessions/{session}, …/speakers and …/speakers/{speaker}, a teaser of the first sessions and the speakers' faces on the event page, each session as a schema.org subEvent and each speaker as a performer.

A titleBand reaches the event title with titleBand.pages: "all" (the title is the page title, not a post's). Four theme-component outlets sit on the page: events-page-above-body, events-page-aside, events-page-after-body and, once per card, events-card-footer.

Statuses as data, and horizon boards (RM-4). Board data, not theme keys — set on PUT /orgs/{orgId}/spaces/{spaceId}/roadmap-settings (console: Lanes and voting):

Key

What it does

Default

statusOrder

Lane order (drag or arrows in the console). Orders, never filters

the five, then the board's own

statuses[]

Up to 10 statuses beyond the five: {key, label, description?, tone?, glyph?}. tone/glyph are the same words as lanes.tones/lanes.statuses.*.glyph, which a theme cannot name for a key the board invented. SHIPPED stays built in (the release loop moves items into it). Removing a status that holds items is a 409

none

groupBy

status, target or theme: lanes by status, or by the item's horizon / theme, with the status as a chip on each card and a last lane for items with nothing set

status

horizons[]

The horizons in order (Now / Next / Later). Written as the target field's select options on the space shape; [] makes target free text again. Old free-text values still read and get their own lane

[]

GET /public/orgs/{orgId}/spaces/{spaceId}/roadmap/lanes serves the visible statuses (in order, in the board's words, with their look) and the groups.

theme.roadmap — filters and the item page (RM-2)

The board's filter bar and what an item page says beyond its body. Same rule: every key optional, look: "paper" is the page as it was.

Key

Values

Unset (paper)

Default layer

filters.sort

the orders offered, in order: top (most votes) new trending (votes and comments decayed by age)

no sort row

all three

filters.tags

a row of the board's tags

off

on

filters.status

a row of the lanes (one lane at a time)

off

—

filters.mine

"My votes", for a signed-in reader

off

on

filters.search

a search box over titles and summaries

off

—

filters.style

the X3b facet bar's pills tabs segmented

pills

—

item.rail

the shared side rail (layout, items, sticky, align); items here: vote voters follow facts share actions

stacked, no rail

side: vote, voters, follow, facts, share, actions

item.voters

none, stack (faces + "12 people want this"), list (faces and names)

none

stack

item.activity

the item's history under its body: requested, every lane move, "Shipped in"

off

on

item.response

the team's latest answer in a panel under the title

on

on

item.shippedIn

"Shipped in v2.4 — title →" for an item a published release cites

on

on

item.commentSort

oldest newest

oldest

—

item.commentSortControl

a Newest / Oldest switch over the comments

off

on

item.reactions

the space's reaction palette on each comment

off

on

item.replies

answer a comment in place; answers sit under it, one level

off

on

item.staffBadge

a "Team" chip on a staff comment

off

on

Every filter is a URL parameter on the board (?sort=new&tag=api&status=PLANNED&mine=1&q=…), so a filtered board is a link, it arrives filtered, and it works with no script. The item page's comment order is ?comments=newest.

The team's answer. Moving an item with a message (POST …/roadmap/{id}/status { "status": "PLANNED", "message": "Starting next sprint." }, or "Post an official response" on the console's item) records the move in the item's history, shows the words under the title, and puts them in the email every voter and watcher gets. Every move is recorded, with or without words; a release that ships the item records the release.

The changelog half. theme.changelog.closes — list (default), count, none (paper) — names the roadmap requests a release shipped under its body ("Closes 3 requests"), each with its votes. Only items on a board a reader can open are named.

theme.contact — the contact page, its form and its topics

The contact page at /contact/{org}/{space} is a front page: the intro band (the home page's copy and the declared facts), the topics band and the form band, stored or generated. Its heading and the line under it are the front door's words — frontDoor.title and frontDoor.subtitle (empty: "Contact us" and the standard line, in the reader's language). The form's own words are the form's settings (PATCH …/contact/form): submitLabel, successTitle, successMessage (the thank-you's body), nextSteps (up to four links under the thank-you) and redirectUrl. The thank-you also repeats the page's responseTarget fact ("We usually answer within one working day").

The arrangement is the space's layout preset:

Preset

What it draws

standard

Heading, intro and facts, the topic cards, then the form (the default)

split

The pitch (intro, facts, topics) in a wide left column; the form in a card on the right that stays in view

router

"How can we help?" and the topic cards; the form opens on the card picked (a topic with a redirect links there)

steps

The email (and the topic) first; name, message and the topic's questions after Continue

Topics are the space's departments (PATCH …/contact/departments), each { name, email, description?, icon?, fields?, redirectUrl?, autoAssign? }: the line on its card, a card-vocabulary icon, the form questions it asks (by field name; unset = all), somewhere else that answers it, and the member its messages are assigned to. Readers never see the address or the assignee. ?topic=<name> opens the form on a topic; ?email= prefills the address.

Key

Values

Unset → default layer

What it does

contact.form.card

the shared card surface

none → outline, 24px pad

The box around the form: surface (flat outline raised), radius, pad, shadow.

contact.form.width

narrow full

the column

narrow keeps the form to 30rem inside a wider page.

contact.form.labelPosition

above beside inside

above

beside: a label column from 640px. inside: the label is the placeholder (still a real label).

contact.form.columns

1 2

2

Name and email side by side from 640px, or stacked.

contact.form.submitAlign

start center end

start

Where the send button sits.

contact.form.fullWidthSubmit

true false

off

The send button as wide as the form.

contact.facts.look

list chips tiles kicker card

list → tiles

How the declared facts (email, hours, phone, response time…) are laid out.

contact.facts.placement

above beside below

above

In the intro above the form, in a column beside it (from 1024px), or under it.

contact.topics.look

cards select tabs

select → cards

Cards in the topics band, the "What is this about?" dropdown, or tabs over the form. Cards need a page that places the topics band; a page composed before it existed keeps the dropdown.

contact.topics.columns

1–4

as many as fit, up to 3

Topic cards per row from 640px.

look: paper restores the page as it was before these keys: no card, the facts as a list, the dropdown. Hooks: data-fx-contact-form, -field, -facts, -topic (a card or a tab) and -success (the thank-you).

CT-3: the backdrop, and what the form does after sending.

Key

Values

Unset

What it does

contact.backdrop.layers

1–4 title-band layers (gradient, color or image)

none

A painted backdrop behind the page — Stripe's contact-sales gradient. The same layers and painter as theme.titleBand.

contact.backdrop.layersDark

the same

the light layers

The dark-mode backdrop.

contact.backdrop.extent

top page

top

top paints the top of the page and fades out; page paints all of it.

contact.backdrop.height

160–1200 px

560

How far the top backdrop reaches before it has faded.

The form's growth settings live with its words (PATCH …/contact/form), not in the theme: workEmailOnly (refuse gmail.com, outlook.com and the other consumer mailboxes with a message beside the address; off), spamFilter (score each message — links, known spam phrases, the org's screening list — and file likely spam in the inbox's Spam folder with no alert, webhook or auto-reply; on), bookingUrl (a Cal.com or Calendly page framed under the thank-you, with the sender's name and address filled in; a topic's own booking wins), retentionDays (messages older than this are deleted nightly; kept until deleted when unset) and translations (per reader locale: the topics' names and lines, the questions' labels, help and placeholders, the button and the thank-you — what is posted and stored stays the original). A topic can also send people to the site's own docs, help centre, community, status page or changelog by kind (redirectTo: "docs"), found when the page is served. Hook: data-contact-booking on the booking step, data-contact-backdrop on the painted wrapper.

theme.courses — the landing, the outline, a lesson and the certificate

A course's landing, its outline, every lesson and the completion certificate draw from five families. Every key is optional; look: "paper" is the course exactly as it was before any of them existed (an h1 over a stacked details block, a checklist, a one-column lesson, a navy-and-gold certificate).

Key

Values

Unset (paper)

Default layer

landing.layout

minimal hero-split (text + button beside the cover) hero-cover (wide cover over a stats strip) cohort (sticky panel with the facts and the button) path (centred)

minimal

hero-split

landing.showStats / showInstructors / showOutcomes / showPrereqs

true/false

shown

shown

landing.ctaStyle

solid outline link

solid

solid

landing.coverAspect

video (16:9) classic (4:3) square wide (21:9)

16:9

video

landing.coverRadius

0–48 px or full (a circle)

rounded

—

outline.style

checklist accordion numbered-chapters timestamp-table path-nodes cards

checklist

checklist

outline.showDurations / showKinds

a lesson's duration / kind field

shown

shown

outline.showModuleProgress

a signed-in learner's "3 of 5" and bar per module

off

on

outline.collapse

modules start folded (the one holding the next lesson stays open)

open

—

outline.numbering

none 01 (through the course) 1.1 (per module)

none

none

lesson.sidebar

curriculum (the outline in the docs rail), drawer (behind "Course contents"), none

none

curriculum

lesson.progressBar

top (over the lesson) sidebar (head of the rail) none

none

sidebar

lesson.completion

separate (toggle + next link) complete-and-continue (one button)

separate

complete-and-continue

lesson.endCard

none completed-next ("You've completed …" + a "Next up" card)

none

completed-next

lesson.chapterBadge

"Chapter 3" over the title

off

off

lesson.objectivesBox

the objectives field as "In this lesson"

shown

shown

progress.style

thin thick-rounded (16px, Duolingo) ring

8px bar

thin

progress.doneMark

check fill (a dot) strike (title struck through)

check + strike

check

progress.tone

a palette tone ({ tone, color, colorDark }) for the bar and the marks

brand

brand

certificate.layout

classic (centred, framed) modern (left-aligned, accent band) minimal

the old page

classic

certificate.logo

the theme's logoUrl over the heading (the brand's name when there is none)

off

on

certificate.border

double single none

double navy/gold

double

certificate.accent

a palette tone for the heading, the name and the frame

navy

brand

certificate.signature

{ name, title, imageUrl } — drawn over the signature rule

—

—

certificate.line

one line of your own under the course title (200 chars)

—

—

video.speed

the 0.75×–2× row under every lesson video

off

on

video.resume

a lesson video starts where this viewer stopped (kept in their browser; signed in, on their account, so the phone picks up where the laptop paused)

off

on

video.transcript

a video's transcript starts collapsed or open

collapsed

collapsed (open in the video preset)

video.completeAt

10–100: the lesson completes once that percent of its video has actually been played

never

never

gamification.streaks

"4-day streak" and a two-week strip beside the progress bar (signed-in learners; days in the learner's own time zone)

off

off (on in the path preset)

gamification.xp

the XP the course paid this learner (settings.courseLearning rates) beside the bar, and "+5 XP" on a completed lesson

off

off (on in path)

gamification.mastery

a ring per module filled by its progress, named on Khan's ladder: attempted, familiar (half), proficient (every lesson), mastered (and the module assessment)

off

off (on in path)

gamification.celebration

off card ("You finished …", the XP, the certificate) confetti (the card with a CSS burst, skipped under reduced motion) — once per learner per browser

off

off (confetti in path)

commerce.priceOnButton

a paid course's button says "Buy course · $49"

—

on

commerce.refundNote

the refund window and the owner's terms under the button

—

on

commerce.couponField

folded (behind "Have a code?") open

—

folded

Streaks, XP and paid courses (CO-6). The game layer is off on every course until an owner turns a key on (the path preset turns them all on). It needs no setup: streak days are recorded as a signed-in learner opens lessons, XP is what the course's own XP rates already pay. A course is SOLD when its settings.courseCommerce (studio: "Price & coupons") is on with a price, and the org has connected its own Stripe account (org settings, "Payments"): lessons that are not free previews then open on purchase, on every surface. The commerce.* keys only say how the price is drawn.

Lesson videos and checks. A video block's own speed, resume, transcriptOpen and completeAt (0 = never) beat theme.courses.video; its chapters and transcript (WebVTT, which is also the captions track, or plain text) render under the player as server HTML — the transcript is searchable and indexed. The player is the browser's own <video> with our controls around it: no third-party script or request; a YouTube/Vimeo lesson is an embed block the owner chose. A quiz block is an inline knowledge check (ungraded, checked in the browser) or, given a quizId, the course's own graded quiz mounted in the prose. "Reveal the solution" is an expandable in the solution style.

The certificate keys are read by both the print view (/co/…/certificate/{id}) and the PDF the API renders, so the download is the certificate the learner saw. The PDF embeds PNG and JPEG logos and signatures (an SVG logo falls back to the brand's name).

Layout presets are look bundles. A COURSES space's preset (settings.preset) picks a patch over the default layer: standard (the table above), tutorial (numbered chapters, 01 numbering, chapter badge — Next.js Learn), video (hero-cover with a 21:9 cover, timestamp-table), cohort (cohort landing, folded accordion), path (path landing, path-nodes, thick-rounded progress over the lesson). Every key a preset sets is one key away from being changed alone.

Docs keys on a lesson. A lesson is a docs page: docs.feedback ("Was this helpful?", on by default), docs.toc, and — COURSES only — docs.sidebar (width, rows, active row, background, rules) style the curriculum rail, so a course on a site whose docs rail is themed wears the same rail. The console shows them under "Lesson rail".

The landing as bands. The generated front page is hero, then intro, outcomes, outline, instructors and faq (the course's own FAQ, from the course details, with FAQPage markup), each drawing nothing when the course has no data for it. A front page stored before hero existed keeps its title and details above the page. Front-door toggles: showProgress (the learner's bar over the outline) and showContinue (the button becomes "Continue course" for a returning learner), both on. Outlets: courses-landing-hero-after, courses-lesson-above-body, courses-lesson-after-body, courses-certificate-footer. The landing has its own share card at /co/{org}/{course}/social-card.png. Feed fields for a lesson list: duration, kind, progress ("Lesson 3 of 12").

{ "courses": { "landing": { "layout": "cohort" },
               "outline": { "style": "accordion", "showDurations": true },
               "certificate": { "layout": "modern", "signature": { "name": "Ada Park", "title": "Lead instructor" } } } }

Locks, cohorts and the social parts (CO-5). Whether a lesson is locked, when a cohort starts and what learners said are course data (settings.courseAccess, course details — see the space types guide); these keys say how they are drawn:

Key

Values

Default

What it does

courses.access.lockStyle

icon · dim · hide

icon

A locked lesson in the outline: a padlock and why, faded, or not listed.

courses.access.showDripDates

boolean

true

"Opens Mar 10" / "Opens 7 days after you start" beside a dripped lesson.

courses.cohort.weekHeadings

boolean

true

"Week 2 · Mar 10–16" over each module of a cohort course.

courses.cohort.showSessions

boolean

true

The linked events space's next sessions (the schedule band, the panel).

courses.social.testimonials

cards · spotlight

cards

The testimonials band: a grid of quote cards, or one large quote.

courses.social.linkedIn

boolean

true

"Add to LinkedIn" on a certificate (a plain link; no request until clicked).

courses.social.shareLink

boolean

true

"Copy link" on a certificate.

courses.social.lessonComments

boolean

true

The lesson's discussion, when the space takes comments.

New landing bands: schedule (after outcomes) and testimonials (after instructors), each empty until the course has a cohort or a quote. A spaceFeed with "source": "courses" is the org's course catalogue (card fields cover, title, subtitle, level, duration, lessons, instructor, topic, learners, updated; facets level, topic), and "path": "<slug>" narrows it to one learning path, in order. A certificate's verify page has its own share card at …/certificate/{verifyId}/social-card.png.

Offices, hours, channels and deflection (CT-2). Two more bands follow the form: offices and channels. The page's facts gain three fields:

  • offices, a place field. It is a list of { name, street, city, region, postcode, country, phone, email, image, mapUrl, hours }.

  • openingHours, an hours field: { tz: "Europe/London", slots: [{ days: ["mo",…], open: "09:00", close: "17:00" }] }. A close at or before open runs past midnight.

  • languages, comma-separated text.

place and hours are field kinds every type can declare. With a schedule, the facts start with "Open now · closes 17:00 BST" (or "Closed · opens Mon 09:00 BST"). The state is judged on the schedule's own clock, so it reads the same to every visitor.

The front door has four switches:

Switch

Default

What it does

showOffices

on

Draws the offices band

showChannels

on

Draws the channels band

showOpenNow

on

Shows the "Open now" line

deflect

off

While a visitor types the subject or message, up to contact.deflect.limit docs pages appear under the box, from the site's docs space via its public search

Each band draws nothing when it has nothing to show. Each topic can also carry autoReply: an acknowledgement mailed to the sender instead of the org-wide contact.autoresponse.

Key

Values

Unset → default layer

What it does

contact.offices.look

cards list

cards

Office cards (photo, address, phone, email, hours, open-now) or one row per office.

contact.offices.columns

1–4

as many as fit, up to 3

Office cards per row from 640px.

contact.offices.map

link none

link

A "Map" link: the office's mapUrl, else an OpenStreetMap search. Never a map tile, which would be a third-party request on every view.

contact.offices.showImage

true false

shown when set

The office photo on its card. It is proxied like every reader image.

contact.channels.look

cards list

cards

The site's other places. Each of docs, help centre, community, status and changelog is found when the org has exactly one public space of that kind.

contact.channels.hide

docs help community status changelog

none

Drops a found channel.

contact.channels.links

up to 8 { label, url, description? }

none

The owner's own channels (a Slack, a phone line). Each url is a /path, an https:// address, mailto: or tel:.

contact.channels.heading

text

"Other ways to get help"

The band's heading.

contact.deflect.limit

1–5

3

How many docs pages deflection suggests.

Under paper the channels band is left out of the generated page.

Markup: the page is a ContactPage whose mainEntity is the Organization. It carries a ContactPoint for the general email or phone, with hoursAvailable and availableLanguage. Each topic gets a ContactPoint too: contactType is the topic's name, and the url opens the page on that topic. Each office is a location Place with a PostalAddress and its own opening hours. More hooks: data-fx-contact-offices, -office, -channels, -channel, -open-now and -deflect.

theme.roadmap — submitting, merging, following (RM-3)

Key

Values

Unset (paper)

Default layer

submit.sections

the shape's slots the public form asks for, by key, in order; each is its own box under the space's label and stored as that slot

one plain box

["problem", "proposal"]

submit.rich

a "Formatting" switch to the block editor

off

on

submit.similar

requests like the title being typed, as links to vote on

off

on

submit.tags

pick up to 5 of the board's own tags

off

on

submit.theme

pick one of the themes the board's items carry

off

on

item.merged

a merged request's page: redirect (308 to the survivor) or notice ("Merged into X")

notice

redirect

item.mergedRequests

the survivor lists what was merged into it, with votes

off

on

header.subscribe

a Subscribe menu beside the title (the board's moves by email, its RSS) and, for a member, Follow this board

off

on

The Subscribe control's look is frontDoor.subscribeStyle (default menu). A subscribe band (offered, never generated) draws the same channels open. A member's Follow (item rail follow, and the board's head) is a notification level — no email asked; a visitor watches an item by email. Board email subscribers hear every lane move and ship (narrowed by scope.tags). Tags and themes on a submission must be ones the board already uses (422 otherwise).

Private lanes. A lane in hiddenStatuses is private: its items 404 on every public door (item, .md, vote, comment, watch, follow, voters, search, RSS) unless the lane is declared public: true in roadmapStatuses. A merged item still redirects.

Webhooks. roadmap.status_changed, roadmap.merged, roadmap.shipped (also on the Jira and Linear integrations).

A changelog's widget, boosts and notifications (theme.changelog.widget, CL-4)

The in-app "What's new" widget (/api/embed/changelog.js, <forumulate-changelog>) reads its defaults from theme.changelog.widget; any data-* attribute on one embed beats them for that embed. Each card links to the release's own page on the changelog's address (its custom domain when it has one). The widget also takes the theme's accentColor (else primaryColor) and colorScheme.

Key

Values

Unset

mode

dropdown (under the bell), sidebar-left, sidebar-right (full-height sheets), modal (a centred dialog), inline (the list in flow, no bell)

dropdown

counter

the unread count on the bell (or on your own button)

on

limit

1–30 releases

10

width

260–640 px, the panel

360

cover / typeBadge / tags / excerpt

each card's cover image, change-type badge, tags, summary

on / on / off / on

footer

a "See all releases" link

on

announce

show a boosted release once per visitor

on

title

the panel's heading

"What's new" in the embed's language

radius

0–32 px

10

Per embed: data-mode, data-selector (a CSS selector for your own button — the panel opens from it and the count sits on it), data-counter, data-limit, data-announce="false", and data-user-<field> for segmentation: data-user-plans="Pro" or data-user-audience="Admins" shows only the releases whose plans / audience field includes that value (a release that leaves the field empty shows to everyone). JS API: window.Forumulate.changelog.open(), close(), toggle(), count(), onCount(cb). Opens and card clicks are counted once per visitor per day; the console's changelog list shows each release's views and the widget's opens.

Boosting a release. A release's announce field (Bar, Toast, Modal) pushes it harder than the rest: the widget shows it once per visitor as a top bar, a corner toast or a dialog, until announceUntil (a date, inclusive). The site's announcement strip can name it too — source: "latest-release" draws the newest boosted release's version and title, linked to its page, and hides itself when there is none. release.spaceId picks the changelog (unset: this space), release.only: "any" takes the newest release younger than release.maxAgeDays (default 14) instead of only boosted ones, and release.fallback: "text" shows the strip's own text when there is no release to name.

{
  "changelog": { "widget": { "mode": "sidebar-right", "tags": true, "limit": 8 } },
  "announcement": { "text": "See what's new", "source": "latest-release", "release": { "fallback": "text" } }
}

The head's extra items. header.right (and rightPhone) also take copyFeed (copies the feed's address) and social (the owner's chrome.socialLinks marks).

{ "changelog": { "header": { "right": ["subscribe", "copyFeed", "social"] } } }

Choosing what to hear about. The front page's subscribe band takes chooseTags: true on a changelog: the form offers the changelog's change types and tags as boxes, and the ticked ones become the subscription's scope (scope.kinds for types, scope.tags for tags) — "only Fixed", "only the API releases". Nothing ticked is the whole changelog. A release is mailed to a scoped inbox when its tags and its declared type match. A blog's subscribe band takes it too (CL-5): its tags as boxes, and a post is mailed to a scoped inbox when the post carries one of them. Other types ignore chooseTags.

Notify me. A signed-in reader sees "Notify me of new releases" in the subscribe menu (subscribeStyle: button | menu, or a menu band). It puts them on the changelog's watchers: every new release reaches their bell, their notification email and — where they turned it on in their account — web push, under their own notification preferences (release.published). The public routes are GET | PUT | DELETE …/changelog/watch.

A changelog of several products, and its archive (theme.changelog, CL-5)

Several tags at once. The index takes ?tag=ios,android (or a repeated ?tag=) and ?match=all: a release with any of the tags (unset), or with every one of them. The public API takes the same (GET …/changelog?tag=a,b&match=all). filters.style (and a placed tags / filters band's own style) adds two looks that tick several tags: panel (a card — placed in the front page's sidebar it stays in view as the list scrolls, Stripe's product card) and checklist (the same list, bare, LaunchNotes' category list). The panel also carries the change types and Breaking as choices; with script a tick applies itself, without it the Apply button does.

Key

Values

Unset

filters.match

the panel's "Any of these / All of these" switch

on

filters.tagLinks

where a tag tab goes: path (the tag's own page, /t/<tag>) or query (?tag= on the index)

path (paper: query)

A tag's own page. /c/<org>/<space>/t/<tag> (and <your domain>/<changelog>/t/<tag>) is the changelog narrowed to one tag, with the tag's label in its title, its own canonical and an entry in the sitemap — Raycast's per-platform changelogs. A ?tag=ios view canonicalises to it when tagLinks is path. A tag no release carries is a 404.

The archive band. ?month=2026-09 narrows the list to one month (it wins over ?year=); the archive response lists months beside years.

Key

Values

Unset

archive.style

years (a row of years), months (each year's months on a row), rail (a "Jump to month" list, for the sidebar)

years

archive.paging

links (newer / older), numbers (numbered pages between them), load-more (one button that grows the list in place)

numbers (paper: links)

archive.sticky

the rail stays in view while the page scrolls

on

archive.counts

each year and month with its release count

on

load-more is the older-page link underneath, so crawlers and readers without script still page; with script it re-renders the index as pages 1..N (?through=N, up to 100 releases) without scrolling, then falls back to the next page's link.

{
  "changelog": {
    "filters": { "style": "panel", "tagLinks": "path" },
    "archive": { "style": "rail", "paging": "load-more" }
  }
}

One timeline from several spaces. A front page's spaceFeed block takes spaces: ["<space id>", …] (up to 6): the feed's own space and each of these merged into one list by date — every product's changelog as one, or a changelog, blog and press room as one "what's new". Each item keeps its own space's card and link; a space the reader cannot see adds nothing; a ranked sort reads as newest first. The studio's feed inspector lists the spaces to tick under "Merge in these spaces".

A changelog in several languages (theme.changelog.languages, CL-6)

A changelog speaks the languages its space declares (settings.locales, the same declaration a docs or FAQ space makes — Changelog → Languages in the console, or POST …/docs/locales). Each release is translated per language in the console (by hand, or with the organization's own DeepL key), or through PUT …/changelog/{releaseId}/translations/{locale} and …/{locale}/auto. A machine translation stays unpublished until somebody publishes it, and never replaces one a person wrote unless asked.

Readers switch with ?locale=<code> on the index and on a release page. A release with no published translation into that language is shown as written, so a half-translated changelog still shows every release. Each page served in a language canonicalises to its own ?locale= address, and every published language is named as an hreflang alternate (the original as x-default and under the space's content language). Neither depends on the key below; the key only places the visible links.

Key

Values

Unset

languages.placement

above-title, below-title, none (no visible links; hreflang stays)

above-title

languages.index

the links on the index too, not only on release pages

on

The links show only when at least two languages have something published.

A release's sections, glyphs and bullets (theme.changelog, CL-6)

A release's Keep-a-Changelog sections (Added, Changed, Fixed …) used to wear the docs reader's section heading — on the index, a release's "Fixed" was larger than the release's own title. The default look now labels them one step above the body text; paper keeps the docs heading.

Key

Values

Unset

sections.look

heading (the docs heading), label (a bold label, GitHub/Linear), eyebrow (small muted caps)

label (paper: heading)

sections.size

11–32 px, the label's size in any look

the look's own

sections.glyphs

a glyph before each section's label (Raycast's New / Improvements / Fixes)

off

sections.glyph

{ "<section key>": "<icon name>" | "none" } over the defaults: added sparkle, changed star, fixed check-circle, removed lock, deprecated clock, security shield, upgrade-notes warning. Icon names only (GET /icons?for=card), never an emoji

the defaults

sections.tone

the glyph's colour: kind (each section's tone — Added green, Security red, the same pairing as the type badge), brand, muted, ink

kind

sections.rule

a hairline between sections

off

prose.listMarker

the release notes' bullet: disc, dash, emdash, arrow, square, none (the components.list.marker vocabulary)

the theme's components.list.marker

The glyphs are drawn into the server HTML. A "bold area prefix" in a bullet (Raycast's Search: …) is authoring — write it bold.

{
  "changelog": {
    "sections": { "look": "eyebrow", "glyphs": true, "glyph": { "fixed": "shield" } },
    "prose": { "listMarker": "dash" }
  },
  "layout": { "heroDecor": { "style": "grid" } }
}

Subscriptions and feeds (every type that offers them)

One subscribe control is shared by every type (<SubscribeMenu>): email, RSS, Atom, JSON Feed, a calendar where the type has one, "copy feed URL", and the owner's social links. Two settings place and shape it.

  • frontDoor.subscribeStyle — how the compact control is drawn: link (a text link straight to the feed), icon (a bell, straight to the feed), button (a solid "Subscribe to updates" button opening every channel), menu (the same popover behind a text link) or email-popover (a button opening just the email box). Defaults: CHANGELOG link (the head's item as it always was), STATUS button (beside the page title, as every reference status page has it). A type gains the key when its reader draws the control.

  • Front-page subscribe band, menu: true — the band draws the menu open in flow (email box plus every feed) instead of the email form alone. Unset, the band is the email form, as before. A type with no subscribe band of its own draws the menu for a placed subscribe band.

Feeds: every feed the shared writer publishes answers at rss.xml, atom.xml and feed.json beside the page (STATUS adds maintenance.ics, EVENTS calendar.ics), and takes a filter — ?tag= / ?type= on a changelog, ?component= / ?severity= on a status page. A changelog's ?tag= view advertises the filtered feed in its <link rel="alternate">.

Scoped email subscriptions: the public subscribe body takes scope: { tags?, components?, kinds? } — "only releases tagged api", "only the EU component". Absent or empty is the whole space. A scope can only be widened without the inbox's own confirmation.

# A space setting, not a theme key: the front door is written with the preset.
curl -X PUT "$API/orgs/$ORG/spaces/$SPACE/preset" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"frontDoor": {"subscribeStyle": "menu"}}'

A type's index and item pages (list, item)

events, careers, faq, courses and legal take list; events and roadmap take item (a type gains item with the change that mounts it on its item page, never before). Both are the same vocabulary on every type (packages/shared/src/reader-index.ts). Unset is the type's own page exactly.

list draws the type's index with the front page's feed instead of its own markup, all on the server (the page arrives as it stays, and every filtered or paged view is an address):

Key

Values

Unset

layout

cards (the type's own card), grid, rows, compact, tiles, table, timeline, accordion

the type's own index

columns

1–6, desktop columns for grid/tiles/timeline

the layout's own

fields

card fields / table columns, in order (["title","team","remote"])

the type's summary

group.by

none, day, month, year (of the item's date), field

no groups

group.field

the field by: "field" reads (team, section, version)

—

group.style

heading, divider, gutter-label (a label in a left margin), eyebrow

heading

group.collapsible / sticky / counts

each group folds; its heading stays in view; "Team · 4"

off

facets

the filter bar's fields ([] for none); the reader's pick is ?f_<field>=

the type's declared facet: true fields (careers: employment, remote, team, experienceLevel, location)

facetStyle

pills, tabs, segmented, select

pills

pageSize

1–50

20

Any of layout, a group.by or non-empty facets switches the generic index on. Careers is the exception: its board draws rows, compact, cards and table itself, with the same keys (see the next section), and hands only grid, tiles, timeline and accordion to the generic index.

{ "careers": { "list": { "layout": "table", "fields": ["title", "location", "remote"],
  "group": { "by": "field", "field": "team", "counts": true } } } }

item places an item page's parts:

  • item.rail — layout: stacked | side (unset: the page as it is), items (in order: back, date, version, tags, share, facts, people, actions, follow, subscribe, voters, toc, cta; an item the page does not have is skipped), sticky (default on), align: start | end. An event's cta is its register card — { "layout": "side" } stands it beside the description, sticky.

  • item.actions — items from copyLink, copyMarkdown, viewMarkdown, print, share (unset: all the space allows: the Markdown pair needs readerTools.copyMarkdown, print needs readerTools.pdf, share needs frontDoor.showShare), and style: row | icons | menu.

The front page's spaceFeed block takes the same parts: layout: "table" and "accordion" (an FAQ accordion feed is the page's FAQPage unless the page already has one), group (the object above) and facets / facetStyle.

A roadmap of roadmaps (layout: "lanes", RM-5 R15). On a ROADMAP feed, layout: "lanes" draws the board's own status lanes, cards and vote, and spaces: [id, …] (up to 8 ROADMAP spaces in the org) adds their items to the same lanes — one board per product, one roadmap on the site's front page. Lanes follow the first board's order and words, then any status only another board has; each card names its board (theme.roadmap.card.eyebrow: board, the default here unless the theme chose another eyebrow) and links and votes on its own board. limit is items per lane (default 6), sort top or latest, filter.lane / tags / pinned / featured narrow it. A board the reader may not open, hidden lanes and their items, and held submissions never appear. Any other type draws lanes as feed.

A careers board (theme.careers.list, frontDoor, CA-2)

The board at /jobs/{org}/{space} (a site's /careers) is drawn by components/careers/role-list.tsx from one plan (role-board.ts): the owner's theme.careers.list, else the space's preset, else one list of rows. Every filter is a GET on the board's own address, answered on the server — ?q= searches, ?f_<field>= picks a facet (X3b's bar), ?page=N shows the first N pages ("Show more" is cumulative), ?team= is a team's own address.

theme.careers.list is the shared index object above plus:

Key

Values

Unset

layout

rows, compact (facts beside the title), cards, table; the generic layouts go to the feed

the preset's: list/departments rows, table table, cards cards

columns

cards to a row at full width

2

fields

the facts a row/card shows and the table's columns, in order

rows: location · type · level · pay (the front door's toggles hold); table: team, location, employment, remote

group

as above; by: "field" defaults field to team, counts follows frontDoor.showCounts

departments: by team, eyebrow headings with counts; else none

facets

the filter bar's fields

the declared facets: team, location, remote, employment, experienceLevel

pageSize

roles before "Show more"

50

density

compact, cozy, comfortable

cozy

divider

none, rule, gap

rule

hover

none, tint, lift, underline

none

chips

{ style, case } — the facts as chips (pill, outline, text, chip, …)

one muted line

rowAction

none, arrow, text (the words are frontDoor.rowCta)

none

filtersFrom

open roles before the search box and the filter bar show; 0 = always

never, unless facets is set

filterPlacement

where the search box and filters stand: bar (over the list), rail-left / rail-right (a 16rem column beside the list, each field a stack of choices with counts; the page widens by the rail rather than squeezing the list, and under 1024px the rail folds back into the bar over the list), dropdowns (the search and one menu per field on one line) — all server-rendered links

bar

The default look (theme.careers.look unset) lays rowAction: "arrow", hover: "tint" and filtersFrom: 8 under the owner's keys; look: "paper" is the plain list the board drew before.

The careers front door adds showSearch (on), showCounts (on: "12 open roles" over the list and a count beside each team) and three words keys — emptyText (no open roles), rowCta ("Learn more") and allTeamsLabel — each blank for the reader's translated default. CA-4 adds the board's channels beside the title: showSubscribe (on: the talent community, email when a role opens — a team's address signs up for that team), showRss (on: rss.xml/atom.xml/feed.json of the open roles, ?tag=<team> for one team) and subscribeStyle (unset button; X4's <SubscribeMenu> words).

On its own front (no search, filter, team or page) the board emits an ItemList of its open roles, unless the front page carries the owner's own JSON-LD.

{ "careers": { "list": { "layout": "table", "fields": ["team", "location", "employment"],
  "filtersFrom": 0, "pageSize": 20, "density": "compact" } } }

A careers role (theme.careers.role / facts / item, piecePreset, CA-3)

A role page is the docs reader with the role's own furniture (components/careers/role-page.tsx, role-rail.tsx). How it is laid out is the space's piece layout, PUT …/preset { "piecePreset": … }:

piecePreset

Reads as

single (default)

the role, then the apply form under it (Lever, Greenhouse)

split

the role beside a sticky column of its facts, Apply and share (Stripe); the article goes wide and the contents rail steps aside

tabs

"Overview" and "Application" tabs over one address (Ashby) — plain in-page links, the Application tab is the form's #apply anchor, so every Apply opens it; no script

The preset is laid under the owner's keys at read, so these still win:

Key

Values

Unset

role.layout

single, split, tabs

the piece preset's

role.topApply

an Apply button beside the facts, over the prose

off; the default look: on

role.stickyApply

an Apply bar that rides the bottom of the screen and settles above the form (position: sticky, CSS only)

off; the default look: on

role.moreRoles

how many other open roles on the same team to list under the form (0 = none)

0; the default look: 4

facts.look

the shared facts look: list, chips, tiles, kicker, card

list; the default look: chips (the split column draws card for list/chips)

facts.placement

head, rail

head; rail under split

item.actions

X3b's item actions (items, style) — copy link and share

every action the space allows

item.rail

X3b's side rail keys for the split column (items of facts/cta/actions, align, sticky, layout)

side, sticky, facts → Apply → actions

The careers front door adds showShare (on: copy link and share on every role) and two statements shown under every role: eeoStatement (the equal-opportunity statement) and accommodations (who to ask for an adjustment) — said once for the space rather than pasted into each role.

A role's payNote field (pay-transparency note) is drawn as a sentence under its facts. location takes several places separated by ; ("London; Berlin"): the row prints "London · Berlin", the location facet offers each, and the JobPosting gets one Place each, with the country the country field gives (one for all, or one per place in the same order: "GB; DE").

{ "careers": { "role": { "layout": "split", "moreRoles": 0 },
  "facts": { "look": "tiles", "placement": "head" },
  "item": { "rail": { "align": "start" } } } }

Per-page customization

Everything above is the space's look. A single piece — one landing page, one launch post, one policy — can carry its own on top of it, without a space-wide rule aimed at that page's address.

Seven keys, the same on every piece:

Key

What it does

customCss

CSS for this piece only (max 20 000 characters).

theme

A partial theme for this piece, layered over the space's.

shareImageUrl

og:image / twitter:image for this piece, separate from its cover.

ogTitle

og:title / twitter:title (max 200 characters). The page's own <title> is unchanged (that is seoTitle).

ogDescription

og:description / twitter:description (max 400 characters).

twitterCard

summary (thumbnail) or summary_large_image (the default).

jsonLd

Structured data for this piece (max 20 000 characters) — replaces the automatic markup.

Where each piece keeps them

  • Pages — docs, FAQ, careers, custom spaces, course lessons, and a legal document written as a page — keep them flat in the page's metadata, beside layout, onThisPage and seoTitle:

    PATCH /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/metadata
    {
      "customCss": ".forumulate-prose h2 { letter-spacing: -0.01em; }",
      "theme": { "primaryColor": "#0f766e", "palette": { "primary": "#0f766e" } },
      "shareImageUrl": "/api/assets/9b1f0c3e-…",
      "ogTitle": "Getting started in five minutes",
      "twitterCard": "summary_large_image",
      "jsonLd": { "@type": "HowTo", "name": "Getting started" }
    }

    Top-level metadata keys are replaced on write, so theme is sent whole.

  • Blog posts and changelog releases take them under customization on their own create and PATCH doors (…/blog, …/blog/{id}, …/changelog, …/changelog/{id}). Here the object merges: a key you send replaces, a key you send as null is cleared, a key you leave out stays.

  • Inline legal documents (body stored on the document, not a page) take customization on POST /orgs/{orgId}/legal, with the same merge. Changing it never bumps the document's version — it is presentation, not wording.

  • Events take customization on POST …/events and PATCH …/events/{idOrSlug} (stored on the event, migration 0355), same merge. Both doors already need forum.moderate.

  • Forum topics take customization on POST …/forum and PATCH …/forum/{topicId}; roadmap items on POST …/roadmap and PATCH …/roadmap/{topicId} (that PATCH also renames and retags an item). Same merge. A topic or an item is usually written by a member, but its page is on your site — so customization on these doors needs forum.moderate in the space, and is refused with a 403 naming the key from anyone else (the author included), never silently dropped. The posts in a thread render inside the topic's page, so the topic's customization is the thread's.

  • Status incidents and maintenance windows take customization on the incident create and PATCH doors (stored on the incident), same merge. It is worn on the incident's own page (…/incidents/{id}), which is also the maintenance window's page.

  • Contact topics take customization on each topic of PATCH …/contact/departments. It is worn on the contact page when that topic is chosen in its address (?topic=<name>): the topic's own look, share card and JSON-LD on the link you give out for it.

Sub-pages wear their parent's look. An event's agenda, speakers, speaker and session pages wear the event's theme and customCss, and a course lesson wears the course landing's (its front page's) under the lesson's own. The parent's share card and JSON-LD stay on the parent page — they describe it.

null clears any key back to the space. customization: null clears all of them. Every read hands back only the keys a piece actually sets.

In the console these are under Page look & sharing in a page's details rail, and in the details of a post, a release and an inline legal document — and, for moderators, on each event in the events manager, on a roadmap item's view, and on a forum thread's view.

customCss — sanitised and scoped like the space's

The piece's CSS goes through the same sanitiser as the space customCss — @import is removed, javascript:/expression( are stripped, and nothing in it can close the <style> element it is printed into — and is then scoped to the reader: every selector is rewritten so its subject is inside .forumulate-reader.

  • h2 { … } styles this page's headings, not the console around a preview.

  • :root, html, body and .forumulate-reader all mean "the reader" here, so :root { --reader-brand: #c00 } re-brands this one page.

  • @keyframes, @font-face and @property names are prefixed, so this page's spin cannot redefine the space's. References inside the same CSS are rewritten to match.

  • A selector that starts with + or ~ (a sibling of the reader) is dropped.

It is emitted only on that piece, after the space theme, so at equal specificity the piece wins. Assets it references (url(…)) must come from origins the space already allows — uploads under /api/assets always do.

theme — a partial theme, layered page → space → site

The keys a piece may set are the token keys: primaryColor, accentColor, palette, paletteDark, font, customFontFamily, headingFontFamily, bodyFontFamily, monoFontFamily, colorScheme, contentWidth, layout, components, motion and typography. Same values and the same validation as on the space theme — and the same refusal of unknown keys by name, all the way down: {"theme": {"palette": {"backdrop": "#f00"}}} is a 422 naming palette.backdrop, never a 200 that renders nothing.

A piece may also override its own type's layout — the namespace its pages read — and no other type's:

Piece

Its namespace(s)

docs page, custom page

docs

FAQ question

faq, docs

careers role

careers, docs

course lesson

courses

blog post

post

changelog release

changelog

forum topic

forum

roadmap item

roadmap

event

events

legal document

legal, docs

status incident / maintenance window

status

contact topic

contact

So one event can stand its register card at the side ({"theme": {"events": {"page": {"layout": "split-cover-left"}}}}) while every other event keeps the space's. Another type's namespace (post on a release) is a 422 naming it — it would render nowhere. A key that is out of scope but already stored, and unchanged by the write, is left alone.

A front page's piece takes no namespace (the index is laid out by the space's own), but it is the only piece that takes the index-only layout.indexHeader; on an item it is refused, because an item page has no index title bar.

A piece's contentWidth and layout.widths.text move the article column of a docs, FAQ, careers or custom page, not only the shell around it.

Not settable per piece: header/footer chrome, logos, favicon, scripts, origins, ads and customFontUrl — those are the site's furniture or its security policy, decided once for the address. A piece can still name a font family the space (or the piece's own @font-face) loads.

The theme a piece wears is its keys over the space's, which is already the site's under the space's — the same layering a site and a child theme component use. A palette slot merges ({"palette": {"primary": …}} keeps the space's other slots); everything else replaces per key. It is compiled into a sheet emitted only on that piece, after the space's.

Share card

On every piece-rendering surface (docs, FAQ, careers, custom spaces, course lessons, blog posts, changelog releases, legal documents, events, roadmap items, forum topics) shareImageUrl is the image a share previews, ahead of the cover, a blog post's declared socialImageUrl field and the generated social card — on og:image and twitter:image alike, and as the image of the page's automatic JSON-LD. A path from your asset library (/api/assets/…) is published absolute on the address being read: on a path-mounted custom domain that is https://acme.com/docs/_api/assets/….

socialBadge

A piece whose share card is the generated one can print a badge on it: the space's seo.socialCard.badge (a post field such as releaseDate or retailPrice), which a piece overrides with socialBadge — another field, literal text ("Sold out"), a label, or hide: true. null goes back to the space's badge. Topic-backed pieces (blog posts, releases, forum topics, roadmap items). See Newsroom extras.

jsonLd

Send a JSON object or an array of objects (as JSON, or as a JSON string). It is refused unless it parses. A node with no @context gets https://schema.org; an array becomes one @graph. It is printed as <script type="application/ld+json"> carrying the page's CSP nonce, with <, > and & escaped, so no value in it can close the element.

When a piece sets jsonLd, it replaces the automatic markup on that piece. When it does not, the automatic defaults apply: TechArticle for docs pages, BlogPosting for blog posts, Article for changelog releases and course lessons, WebPage for legal documents (plus the type-specific nodes — a careers role's JobPosting, an FAQ question) — each with its headline, dates, author, publisher, image and a BreadcrumbList. And for the community pieces:

  • Events — Event with startDate/endDate, eventStatus (cancelled events say so), eventAttendanceMode from what you filled in (a location is a Place, a join link a VirtualLocation, both is mixed), organizer (your site), image (share image, then cover), capacity, and an offers node for the RSVP — its URL is the event page and its availability is SoldOut when the event is full. No price is stated (an RSVP is free); a ticketed event should set jsonLd with its own offers (price, priceCurrency, url).

  • Forum topics — DiscussionForumPosting (headline, text, author, dates, image, keywords, comment/view/like counters and the first replies as comment), or QAPage with the accepted and suggested answers for a question.

  • Roadmap items — Article whose creativeWorkStatus is the item's lane under your board's label ("Planned", "Shipped"), with votes and comments as interaction counters and isPartOf the board.


Reader themes and theme components

Four separate things, often confused:

  • The space theme (this document) — one object, the space's look. One per space.

  • Reader themes (PUT .../reader-themes) — up to 8 named alternates a reader can pick from a control on the reader, on top of the light/dark toggle. Each entry is { name, theme } where theme is a partial space theme; the picker compiles each one and swaps it in client-side. They do not change the space's own theme. Shown on forum and documentation readers.

  • Theme fragments (the older /orgs/{orgId}/theme-components routes, which predate the theme library) — saved partial themes, optionally attached to a parent library theme as a child. A child's CSS is appended after the parent theme's, so it wins the cascade where the two overlap.

  • Theme components (/orgs/{orgId}/component-installs) — Discourse-style installable add-ons: CSS, HTML placed into named reader outlets, typed settings and strings. Many per site, switched on per space or site in order, as a draft. See Theme components.

Theme bundles, the library and the marketplace

A whole look travels as a theme bundle: one versioned JSON file with the theme (palette and its dark twin, fonts and faces, typography, components, layout, motion, CSS, chrome, element styles), its metadata (name, author, version, tags, the space types it suits, preview images) and every outside origin it loads from, declared up front. The full format and every route are in Themes: bundles, your library and the marketplace; the short version:

  • Export what a space or site wears: GET …/spaces/{spaceId}/theme/bundle (or …/sites/{siteId}/theme/bundle). Import a file with PUT on the same address — it lands as a draft, never live.

  • What never travels: your brand (logoUrl, logoMark, faviconUrl, brandName, mobile.logoUrl, the footer logos), scripts (customJs, customHead, scriptOrigins, ads) and this address's own wiring (apiOrigins, frameOrigins, mediaOrigins, notFound, announcement, whiteLabel). They are stripped on export and on import, each one named in the answer's stripped list, and an applied theme keeps yours.

  • Strictness: a bundle is checked all the way down — an unknown key, an absolute URL whose origin is not in requiredOrigins, or a non-exact origin is a 422 naming the path. Its CSS goes through the same sanitiser the reader uses.

  • The library (/orgs/{orgId}/theme-library, themes.manage) keeps saved, imported and installed themes; installing copies a theme's font files and images into your own assets.

  • The marketplace (/registry/themes) lists reviewed community themes and eight first-party starters; installing applies a theme as a draft.

  • Components travel with a theme. A bundle in format 2 carries the theme components its look switches on (components), installed into your organization when the bundle is — see Theme components.


CSS variables worth targeting

These are the names a theme PUBLISHES, and this is the only list of them. They are the stable contract: we keep them working, and they are what customCss should target. The theme's own are --reader-*, --font-* and --setting-* names, scoped to .forumulate-reader. The last two tables are the variables a block or a section publishes on its own element (set by that block's attributes, not by the theme), so a rule on the block's hook can move them.

Any other variable you find in the compiled output is one of our internal names. That includes --bg, --surface, --ink, --muted, --signal and --accent. Internal names may change without a release note, even the ones a theme also writes. Theming: the rest of it lists them and explains why.

Variable

Set by

--reader-brand, --reader-brand-ink

primaryColor, and the ink that reads on it

--reader-accent, --reader-accent-ink

accentColor, and its ink

--reader-background, --reader-surface

palette.background / palette.surface

--reader-primary … --reader-quaternary

The four text/emphasis palette slots

--reader-highlight, --reader-success, --reader-danger, --reader-love, --reader-info, --reader-warning

The signal palette slots (the four tones also paint the callouts)

--reader-header-background, --reader-header-primary

The header palette slots

--reader-ink, --reader-heading, --reader-muted, --reader-line

The text palette slots (ink, heading, muted, line)

--reader-footer-background, --reader-footer-primary

The footer palette slots

--reader-media

The media breakout width (layout.widths.media)

--reader-link

components.link.color / colorDark

--reader-meta-font, --reader-meta-case, --reader-meta-tracking

components.meta

--reader-img-radius

components.image.radius

--reader-img-margin, --reader-caption-align

components.image.margin, components.caption.align

--reader-eyebrow

typography.levels.eyebrow.color / colorDark

--reader-page-bg, --reader-page-ink

The PAGE's surface and ink, even inside a band in the other scheme

--reader-max

The shell width (contentWidth)

--reader-board, --reader-list, --reader-text

The board, index and piece widths (contentWidth, layout.widths)

--reader-measure

The long-form line length (layout.measure)

--reader-frame, --reader-column

The header/footer inner row (layout.frame), this page's column

--reader-toggle-radius, --reader-toggle-active, --reader-toggle-active-ink

chrome.themeToggle.radius / .activeColor

--font-display, --font-body

Heading and body families (font / customFontFamily)

--font-mono

The code family (monoFontFamily)

--reader-fs-<level>, --reader-lh-<level>

typography: size and line height per level

--reader-tracking-<level>, --reader-fw-<level>

typography: letter-spacing and weight per level

--reader-radius, --reader-shadow

components.radius / components.shadow

--reader-focus, --reader-focus-width, --reader-focus-offset

components.focus

--reader-code-bg/-ink/-border/-radius

components.code

--reader-input-bg/-ink/-border/-radius

components.input

--reader-button-bg/-ink/-border/-radius/-shadow/-weight

components.button (-shadow carries edge too)

--reader-space-block, --reader-space-section, --reader-space-pad

components.spacing: unitless multipliers

--reader-selection-bg, --reader-selection-ink

components.selection

--reader-scrollbar-width/-thumb/-track

components.scrollbar

--reader-syntax-comment … --reader-syntax-emphasis

components.syntax / components.syntaxDark

--reader-motion-*

motion; see Motion

--setting-<key>

One per resolved theme setting

--docs-sidebar-width

A documentation space's sidebar width (theme.docs.sidebar.width); 15rem, 272px under the clean look

--docs-content-width

A documentation article's max width

--docs-nav-height

The documentation header's rendered height — what sticky offsets read

--docs-ui-font

The documentation clean look's UI sans

--reader-ui-font

The clean look's UI sans (forum looks other than paper); --font-display and --font-body read it

Published by the interactive blocks, on the block's own [data-fx-*] element (see the fx-1 hook set):

Variable

Block and what it is

--car-per, --car-per-md, --car-per-sm, --car-gap

carousel: slides in view (wide / ≤ 900px / ≤ 640px), gap

--gal-cols, --gal-gap, --gal-aspect

gallery: columns, gap, tile shape

--pr-cols

pricing: plan columns

--tm-cols

testimonials: columns

--logo-cols, --logo-h

logoWall: columns, logo height

--st-cols

stats: columns

--mq-duration, --mq-gap

marquee (and a scrolling logo wall): loop time, gap

--cmp

imageCompare: the divider's position

--hero-title-size, --hero-measure

hero ([data-fx-hero]): headline size, words' max width

--person-photo

person ([data-fx-person]): the photo's size

Published by a styled section, on the section element (see Hooks you put on a block yourself):

Variable

What it is

--sec-pt, --sec-pb, --sec-px

padding.top / .bottom / .x

--sec-min-h, --sec-radius, --sec-border

minHeight, radius, border

--sec-bg-color, --sec-bg-image, --sec-bg-size, --sec-bg-position

the background

A variable is only emitted for a key you actually set — that is what lets a site theme and a space theme stack. Our own stylesheet reads each of the components tokens with the built-in value as its fallback, so an unset key changes nothing.

Two exceptions, and they are exceptions for the same reason: --reader-shadow and the selection pair are read from rules we emit only when you set the key, because there is no fallback to write. The cards carry no shadow at all by default, and unset selection is the browser's own highlight — no CSS value spells that. Reading either from your own customCss works; it just will not do anything unless the theme set the key.

Everything the theme emits is scoped to .forumulate-reader, which is on the reader shell. Write your own rules against that too if you want them to stop at the reader:

.forumulate-reader .doc-callout {
  background: color-mix(in srgb, var(--reader-brand) 8%, var(--surface));
  border-color: var(--reader-brand);
}

Habits that save a day

  1. PATCH, not PUT, unless you mean to replace the whole theme.

  2. Read the 422. It names the key and its path (chrome.footerColumns.0.title). There is no such thing as a theme write that succeeds and stores nothing.

  3. Check /public/.../chrome, not /theme. The public read is what the reader actually gets, including a site theme merged underneath.

  4. customFontUrl and customFontFamily travel together. One without the other does nothing.

  5. A width is four variables, not one. --reader-max only moves the shell; an index reads --reader-list and a single piece reads --reader-text. Override one of those, never a Tailwind class name.

  6. chrome is for a lone space. On a site, set the header and footer on the site.


Published. This is the source text for https://forumulate.com/guides/theming, in the guides space's "Building your site" collection. Re-import after editing — the page is not generated from this file, so the two drift unless you do.

The Forumulate teamUpdated