Theme your site
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 |
|---|---|---|
|
|
|
|
| Replaces the whole theme. Keys you omit are gone |
|
| Deep-merges into the stored theme. Nested objects merge key by key |
|
| Alternate looks a reader may switch to |
|
|
|
|
| What the reader actually gets: |
|
| Every interface string you may override, as |
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 |
|---|---|
| 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). |
| The reader header and footer, the docs reader's sidebar (unless |
| Not limited — every reader surface listed above, including its 404 page. See the paragraph above the table |
| Every reader page, rendered by the chrome above the header and spanning the full page width. Not shown in an embed ( |
| Only on your own domain — never on |
| Every reader surface on your own domain — never on |
| An external |
| Reader surfaces only. The console never resolves a space theme |
| Every reader surface, including the 404, the legal register and the site index. Not in an embed ( |
| Compiled into one |
| Blog posts ( |
| How translations are addressed on every reader surface: |
| Where the content-language switcher sits on every translated surface ( |
| Every reader dropdown: the box, option highlight and check, or |
| Every reader surface: each plain blockquote. A quote's own |
| Every reader surface that draws a body: the product card, sources and corrections blocks' defaults (a block's own attribute wins), and |
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 |
|---|---|---|
|
| The brand. Sets |
|
| The brand in dark mode, when it should differ from |
|
| The secondary accent: |
| object, see below | Named colour slots, each an independent override. Applies in every mode |
| same shape | The same slots, applied only in dark mode. A slot you leave out here falls through to |
|
|
|
| boolean | With |
Palette slots
{ "palette": { "background": "#101014", "surface": "#18181f", "primary": "#e9e5db" } }Slot | Paints |
|---|---|
| The page canvas. Also sets |
| Cards, panels, dropdowns. Also sets |
|
|
|
|
|
|
|
|
| The header bar's background. Also sets |
| The header bar's text, links and brand. Also sets |
|
|
|
|
|
|
|
|
| Running text. Also sets |
| Every heading and page title. Also sets |
| Secondary text: dates, bylines, captions. Also |
| Hairline rules and borders. Also sets |
| The footer band. Also sets |
| The footer's text and links. Also sets |
|
|
|
|
| boolean — mix the brand a few percent into |
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" }
}headingcolours every heading and page title through a weightless rule, so a heading that carries a colour of its own (a level colour fromtypography.levels, a card title in the brand) still wins. A section painted in a scheme of its own starts from that scheme's ink.paletteapplies 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 apalette.ink,headingormutedthat does not read on our dark paper, and thatpaletteDarkleaves unset, is moved along its own hue until it does, in dark mode only. Set thepaletteDarkslot to choose the dark colour yourself.footer_backgroundandfooter_primarypaint the footer the way the two header slots paint the header: the band's fill, and its text —--ink,--mutedand--lineare 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 |
|---|---|---|
|
| The built-in choice. Rebinds the reader's |
| https URL, ≤ 2048 | A stylesheet that declares the face — Google Fonts, or your own |
| string, ≤ 120 | The family name to apply from it, e.g. |
{
"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 |
|---|---|
|
|
| px, 12–24. The body size the scale grows from. Default 16. Set alone, it sizes the body text and nothing else |
|
|
| One key per level — |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|
| The size — a |
| The line height (unitless) |
| The letter-spacing, in |
| 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 |
|---|---|---|
|
| The standfirst under a title — a post's dek, the docs standfirst |
|
| 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 |
|---|---|---|
|
| The face for this level — a dek set in the display face ( |
|
| Letter case for this level |
|
| A hairline above (or under) the prose headings of this level — the editorial section head. |
| a length, or one per screen size | The space above this level in running text |
|
| 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 |
|---|---|---|
| a heading block with | The small kicker over a headline. Its |
|
| An index's own title — the bar above a changelog or a blog index — sized apart from its entries ( |
|
| 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 |
|---|---|---|
| 0.8–3, or | One leading per screen size; |
| a | This level's own axes ( |
| a | This level's own OpenType features |
| the usual level keys | Figure captions and |
Top-level (not under typography):
Key | Shape | What it does |
|---|---|---|
|
| The "link to this section" glyph beside a heading. Unset: on hover on a desktop, quietly visible on a touch screen. |
{ "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 |
|---|---|
| The name the family keys then use. No quotes, braces or semicolons |
| https URL of the file. Its origin goes into |
|
|
|
|
|
|
|
|
| e.g. |
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 |
|---|---|---|
|
| How wide every reader surface may grow — see the table below. Unset is not the same as |
|
| Whether a surface carries the "on this page" rail. |
|
| The longest a line of long-form text may run, whatever the surface's width. Unset = no cap of its own; see below |
|
| How wide the header's and footer's inner rows run. Unset = |
|
| The page's backdrop behind every surface: an image ( |
|
| Decorative art behind the top of every index (blog, changelog and forum fronts, the blog's list pages): |
|
| 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; |
|
| Fills the two gutters just outside |
| 0.75–1.25 | Multiplier on body and heading type on phones |
|
| Tighter or roomier spacing on phones |
| https URL, library path or data image | A smaller logo used only on phones |
| string[], ≤ 50 | Reader selectors hidden on phones |
| 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 |
| unset (fallback) |
|
|
|---|---|---|---|---|---|
| The page-spanning shell: header, footer, the docs reader's nav + article + rail row | 80rem | 96rem | 96rem | 100% |
| A board of lanes or cards: the roadmap index, a site's front door | 64rem | 72rem | 72rem | 100% |
| An index: forum, blog, status, the legal register and its documents, FAQ, careers, events, courses, a list-preset roadmap | 42rem | 56rem | 56rem | 100% |
| 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:
| Article column when the page names no |
|---|---|
unset |
|
|
|
|
|
|
|
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 |
|---|---|
| a length with its unit: |
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 |
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 |
|
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 footer frame (layout.frame)
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" } }
| The header's and footer's inner row |
|---|---|
unset |
|
| The same as unset, stated: pins it, and overrides a site theme's |
| 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 |
| The whole window, less the side padding |
| Accepted for themes that stored it, and the same as |
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:
Make the widths equal —
layout.widthswithlistandtext(andboard, if you have a board) all one length, or--reader-list/--reader-textincustomCss.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 |
|---|---|---|
|
| The shell: header, footer, a docs page |
|
| A board of lanes or cards — a roadmap, a front door |
|
| An index — posts, topics, releases |
|
| One piece — a post, a thread, a release |
|
| 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 |
|---|---|
| The default: each surface decides. A docs guide page and a careers role carry it; the |
| 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 |
| 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 |
|
|
|---|---|---|---|
Docs pages ( | On under the | On | Off |
Blog posts ( | On under | On | Off |
Changelog releases ( | Off | On | Off |
Course lessons ( | Off | On | Off |
The FAQ ( | Off | On | Off |
Legal documents ( | 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:
the piece's own
onThisPage, when it has one;otherwise the space theme's
onThisPage;otherwise the surface default in the
autocolumn 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 |
|
Blog post | The post |
|
Changelog release | The release |
|
Inline legal document | The document |
|
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 |
|---|---|
| string ≤ 120, or |
|
|
| https URL, library path ( |
| the same shapes — a small square mark shown before the name (a lockup) |
| 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 |
|
|
| the same shapes — the browser-tab icon, on every reader surface (published absolute) |
| 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:
| The name slot shows |
|---|---|
a non-empty string | those words — including beside a |
| nothing |
unset or | the site's name, else the organization's — and only when no |
"" 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:
| The header's name slot |
|---|---|
| Hidden beside a wordmark (it is kept for screen readers) and shown beside a mark. A wordmark is a |
| Always shown — the old "explicit |
| 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.
Header and footer (chrome)
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 |
|---|---|
|
|
|
|
| The bar's look: |
| ≤ 12 items: a plain |
| How those menus open and look: |
| ≤ 20 links — the thin bottom row |
| ≤ 400 chars |
| ≤ 12 columns, each |
| ≤ 10, |
| ≤ 200 chars |
| The chrome in other languages: |
|
|
|
|
| boolean — the header follows the reader down the page. Default |
|
|
|
|
|
|
| boolean, default |
|
|
|
|
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, thenlogoUrl, then the name. The name isbrandName, or, when that is unset, the space's name, shown only when nologoUrlis set. With no logo keys at all,showis just the space name as text.hideremoves 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 |
|---|---|
|
|
| 120–560 — the pill's width in px from 768px up. Ignored for |
|
|
|
|
| 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 |
| Hex: the pill's fill and ink in dark mode. Unset: |
| How many |
| A glyph name, an image, or a brand mark — any |
| The dark-mode twin of an image |
|
|
|
|
|
|
|
|
| boolean — the docs page's section after the brand ("Acme / Guides"). Nothing on other surfaces or for a top-level page. |
| Where the header's brand links — a path or an http(s)/mailto URL. Default the reader's home |
| A small dropdown naming each mode |
| At the foot of the docs sidebar; on a surface with no docs sidebar it falls back to |
| boolean — a hairline between link columns, from 768px up |
|
|
| 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 |
|---|---|
| https URL, library path or data image — the footer wordmark. |
| the same three answers for the square mark, against the header's |
|
|
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 |
|---|---|
| the bottom row, after your |
| the brand block, under |
| the last link of your last |
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.
socialLinks platforms
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 |
|---|---|
|
|
|
|
|
|
| corner radius in px, |
| the active choice's fill: |
|
|
|
|
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 |
|---|---|---|
| The outline / track radius ( |
|
| The active fill ( |
|
| The text or thumb on that fill |
|
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 |
|---|---|
| boolean — the header sits ON the first section (a hero runs underneath it), transparent. See below |
| hex — the header's text and icon colour while it is overlaid (white over a dark hero) |
| px, 32–200 — the bar's minimum height. Wins over |
|
|
| hex — the bottom rule's colour |
|
|
| px, 0–40 — background blur behind a translucent header |
| 0–1 — the header background's opacity (a |
| px, 12–120 — the lockup's images in the header only; the footer keeps its own. Or |
| px, 0–80 — space between header items |
| 100–1000 |
| px, 10–24 |
|
|
| 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/navInkDarkcolour the nav items at rest — a muted nav beside a full-ink brand — and leave the hover to the brand.navFontsets them in theheading,bodyormonoface.floatinglifts the bar off the edges into a rounded pill:insetpx from the viewport edge (default 12),radiuspx (default 16; 999 is a pill), andmaxWidthas a length (default the shell). A sticky floating header staysinsetbelow the top.floating.phonedecides the pill on a phone (below 768px):fullspans 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 tomaxWidth(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(andgradientDarkfor dark mode) is the same shape a section background's gradient takes:linearat anangleorradial, two to fourstops, each a hex or one of the reader's roles —bg,surface,panel,ink,muted,brand,accent— with an optionalatpercentage. It paints overpalette.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) orpill(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: truedraws 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.
Where the links sit, a separator, the rows on a phone
{
"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 |
|---|---|
|
|
|
|
| The brand NAME's size in px, in the header (10–64), or |
| One or two characters drawn between top-level nav items — |
|
|
|
|
|
|
| On a phone: |
| On a phone: |
|
|
Round-4 keys (2026-10-11)
Key | What it does |
|---|---|
| px, or |
| px, or |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| A glyph name (the header menus' list) or an image, before the label |
|
|
| The bar's face ( |
| The section row's own fill and text |
|
|
| A 1px border in this hex |
|
|
| Keep that button in the phone header row, beside the menu button, in order |
|
|
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 |
|---|---|
| Also per screen size: |
|
|
|
|
| A glyph or image before a header link's label; |
| Also on |
| Up to 16 links (was 8) |
|
|
|
|
| The bar hides on a phone and its links move into the header row, before the menu button |
|
|
|
|
| The search pill's words on a phone. |
|
|
| Which modes it offers, in render order: |
|
|
| px between a column's heading and its first link (default 8) |
| The column headings' weight, 100–900 (default 600) |
|
|
| The footer's own frame, a length with its unit ( |
| The band straddles the footer's top rule, half above it. |
|
|
| 0–1 (for the |
| A muted line under the newsletter heading (≤ 200 chars). In |
|
|
Wave-6 keys (2026-10-11)
Key | What it does |
|---|---|
| The header's control glyphs — search, the theme toggle's icons, the menu button — in px (10–40), or |
| With |
| Each link column's width in px (40–600), wide screens: fixed tracks from the columns' start, |
| Like |
|
|
| The bottom row's text in px (10–24) — the copyright and its words. Its links follow |
| Your footer HTML's text size in px (10–24). Unset: 14 |
| Your footer HTML's link colour, and its dark twin. Hover keeps the brand colour |
|
|
|
|
| A |
|
|
| With |
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 |
|---|---|
|
|
| gains |
| gains |
|
|
| The button's label and hairline in dark mode. Unset: |
|
|
|
|
| The link's own colour and a 1px outline in it — a neutral pill instead of the brand-outlined |
|
|
| now optional, so |
|
|
{
"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.
Your own footer HTML, and a newsletter row
{
"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 |
|---|---|
| ≤ 20,000 chars of your markup — badges, a note, a newsletter provider's static embed |
| ≤ 10,000 chars, scoped to your |
|
|
|
|
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 |
|---|---|
|
|
|
|
| 1–6 link columns side by side on a wide screen, or |
| The band's fill ( |
| A picture behind the footer, from your asset library or a host in |
| The same gradient shape the header takes |
| A shape along the footer's top, in the footer's own fill: |
|
|
| Keep the field's label for screen readers only |
|
|
|
|
|
|
|
|
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 |
|---|---|
| The column headings' colour, and its dark-mode twin |
| The headings' face: |
| The headings' size in px (10–32) |
| The whole footer's face: |
|
|
|
|
| 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 |
|
|
| The words' colour (hex); |
| The brand column's width, |
|
|
|
|
| The footer's links (columns, bottom row, the |
| Space between two links in a column, px (0–40). Default 6 |
| The links' colour, and its dark-mode twin (also used on a |
| The links' weight, 100–900 |
|
|
|
|
| The social marks |
|
|
|
|
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.
Footer columns: stacking a group under another
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.
Footer layouts
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.
Footer art
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 |
|---|---|
|
|
| An SVG/PNG from your asset library instead, drawn as it is. One of |
| The preset's colour (hex). Default your brand colour |
| px, 24–600, or |
|
|
|
|
|
|
|
|
Hook: [data-fx-footer-art] (with [data-art], [data-preset], [data-fade]). It is decorative: hidden from screen readers, and it takes no clicks.
Navigation menus: dropdowns, mega menus and HTML panels
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 (
panelwithcolumns): 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 (
panelwithhtml, plus optionalcss).
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 |
|---|---|
| 1–60 chars — the word in the bar |
| where the word goes. Optional for an item that opens something; when set it stays a real link, even with JS disabled |
| 1–12 links — a dropdown list. Not allowed together with |
| a mega menu or an HTML panel (below). Not allowed together with |
|
|
| 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 |
|---|---|
| ≤ 4, each |
|
|
|
|
| ≤ 20,000 chars of your own markup. Not allowed together with |
| ≤ 10,000 chars, only 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 |
|---|---|---|
|
|
|
|
| ms of hover before it opens (0–1000). A pointer that only passes over the bar opens nothing |
|
| ms of grace after the pointer leaves (0–2000), so a diagonal move to the panel keeps it open |
|
|
|
|
| panel corner radius in px (0–48) |
|
|
|
| your | 6-digit hex |
| your | 6-digit hex — the panel's text |
| your | 6-digit hex |
|
| 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.
Scripts in an HTML panel or the footer HTML
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 acustomHead<script src>uses: the page's CSP nonce, thefunctionalconsent tier, after the page is interactive. Onforumulate.com, a*.forumulate.comsubdomain or/p/…it is dropped, the same rule as every owner script (see Where owner and vendor scripts run).External https only. A
srcmust be an absolutehttps://URL. Inline<script>bodies,on…handlers,javascript:URLs and relative orhttp: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-srcand Trusted Types allowlist are built from — so you do not have to repeat it inscriptOrigins. 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.parentNodeis not your panel; give your markup an id or class and look it up, or listen forforumulate: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 |
|---|---|
|
|
| A glyph name (the header menus' list) or an image, drawn before the button's label |
|
|
| The section row's fill and text in dark mode. Unset keeps |
|
|
|
|
|
|
{
"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 |
|---|---|
| a page whose blocks are the 404's body (below) |
| boolean, default |
| ≤ 120 chars — replaces "This page isn't here". With |
| ≤ 600 chars — the paragraph under the heading. Not shown when a page renders |
| ≤ 8 |
| boolean, default |
| boolean, default |
| boolean — the older spelling of |
| written by us when |
|
|
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 thenoindexevery reader 404 carries, do not change. That is the difference from a "soft 404" — a missing page answered200— 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 anylinks.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,
pageIdmust be a page of that space. In a site'sconfig.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 nonotFoundof its own uses the site's too. ApageIdthat is not there is refused with a 422 naming the key (notFound.pageId, orconfig.theme.notFound.pageIdon 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 sendfalse). 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 |
|---|---|
| Hex colours for the strip and its words, in place of the level's tint |
| Their dark-mode twins |
| A gradient over |
|
|
| ≤ 60 chars, bold words before |
|
|
| ≤ 50 page ids, and |
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" }
}
}Dropdowns and popovers (components.popover)
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 |
|---|---|---|
|
| Panel fill (and dark, unless |
|
| Panel fill in dark mode |
|
| Text in the panel |
|
| …in dark mode |
|
| The 1px border |
|
| …in dark mode |
| number, 0–32 (px) | Corner radius |
|
| Elevation |
| 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 |
|---|---|---|
|
|
|
|
| The box's fill (unset: |
|
| Text in the box |
|
| The box's 1px border |
| number (px) | The box's corner (unset: |
|
| The highlighted option. Unset: a light wash of the text colour |
|
| The chosen option's row. Unset: no fill (bold, with a check) |
|
| 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 |
|---|---|---|
|
| Where each surface always put it: the docs sidebar; above the content everywhere else |
|
|
|
headerputs it in the header beside the other header controls (and in the phone menu);sidebarat the head of the docs sidebar;footerjust above the site footer;content-topabove the article;nonehides it (the languages' addresses still work, andhreflangstill 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 (seedocs.localeFallback).A surface that has no sidebar (a changelog, a status page) reads
sidebarascontent-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/.stylestill work and apply whereveri18n.switcherleaves 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 bare0b835bor a three-digit#0b8is normalised; anything else is a 422.Elevations (
shadow,button.shadow) arenone,sm,mdorlg, not a rawbox-shadowstring. 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 |
|---|---|---|---|
| number, 0–999 | px | The corner of cards ( |
|
| — | Cards and panels only. Unset, they have no shadow |
focus: the keyboard focus ring
Key | Type | Unit | What reads it |
|---|---|---|---|
|
| — | The |
| number, 1–6 | px | The outline's thickness. Unset: 2 |
| 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 blocks ( |
|
| — | The base text colour of all three. Highlighted tokens take their colours from |
|
| — | The edge of all three, the line under a filename bar, and the edge of the copy button |
| number, 0–999 | px | The corner of code blocks, inline code and the copy button. Wins over |
input: text fields
Key | Type | Unit | What reads it |
|---|---|---|---|
|
| — | Reader fields drawn with the shared |
|
| — | The text typed into those fields |
|
| — | Their edge. While a field has focus, the edge takes |
| number, 0–999 | px | Their corner. Wins over |
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 |
|---|---|---|---|
|
| — | The fill of primary buttons ( |
|
| — | The text on that fill, in the same places |
|
| — | The elevation of the same filled buttons |
|
| — | The edge of unfilled buttons: the plain |
| number, 0–999 | px | The corner of every button — the header buttons in every slot, and the button block — in every |
|
| — | The label weight of every button, and of the header call-to-action in every |
|
| px | A raised bottom edge (the "3D" button): a solid band |
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 |
|---|---|---|
| hex per | The method pills, published as |
|
|
|
|
| The block's card. |
|
|
|
| boolean |
|
spacing, selection, scrollbar and syntax
Key | Type | Unit | What reads it |
|---|---|---|---|
| number, 0.5–2 | multiplier | All three of the axes below at once |
| number, 0.5–2 | multiplier | The gap between blocks of prose. Wins over |
| number, 0.5–2 | multiplier | The space a heading leaves above itself. Wins over |
| number, 0.5–2 | multiplier | Padding inside code blocks, inline code, callouts and caption bars. Wins over |
|
| — | Selected text inside the reader. Unset, the browser's own highlight |
|
| — | The reader's scroll areas and the page's own scrollbar |
|
| — | The thumb and the track of those scrollbars |
|
| — | Code highlighting, one colour per highlight.js group (table below). |
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.scalesets 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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 Dark |
| GitHub Dark | GitHub Dark |
| One Light | One Dark |
| One Dark | One Dark |
| Solarized Light | Solarized Dark |
| Solarized Dark | Solarized Dark |
| Nord | Nord |
| Dracula | Dracula |
| Monokai | Monokai |
| 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 |
|---|---|---|
|
| Code blocks only. Beats the four keys and a |
|
| Inline code only. |
|
|
|
|
| The copy button: words, an icon (in the caption bar when there is one), a header cell, or none |
|
| Adds an "Ask AI about this code" button; leaving |
|
| A language label in the caption bar or the block's corner |
|
| Wraps long lines everywhere; a block's own |
|
| Every shell block ( |
| hex | A highlighted line's left bar and wash |
| hex | Inserted / deleted lines ( |
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.
Links in running text (link)
{
"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 |
|---|---|---|
|
| The marker of every bullet list that did not choose its own |
|
| Publishes |
|
| The same in dark mode |
| 0–64 px | The room between the items of every list that set no |
|
| 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, |
|
| A round per-style glyph at the start of each callout |
|
| The 3px leading bar, a bar that curls into the top edge, or a plain hairline like the other sides |
|
| One wash for every callout style |
|
| The hairline round the panel |
| 0–999 px | The panel's corner; unset is |
|
| Hangs the leading bar into the gutter by its width and moves an |
Round-5 keys (2026-10-11, Package D)
list.marker/markerColor/itemGapreach 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.itemGapandhint.hangare new (rows above). A list block's ownitemGapandweight, a paragraph'sweight, and a row of columns'spaceBefore/spaceAfter/spaceAroundare 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 |
|---|---|---|
|
|
|
|
| a 14% brand wash over the paper |
|
| a 4% brand wash |
| 0–360 (degrees) | 135 |
| 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 |
|---|---|---|
|
|
|
| integer, 14–96 (px) | The pull quote's size ( |
|
| 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 |
|---|---|
| Nothing. Same as no |
| 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 |
|
|
|
|
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 |
| Paragraphs, at any depth (not a table cell's) |
| Bullet, numbered and checklists, each as a whole |
| Images, figures, galleries, before/after comparisons, logo walls |
| Card grids, any |
| Code blocks with their title bar, code groups, file trees, examples, playgrounds, OpenAPI |
| Hints and callouts |
| Tables, each as a whole |
| Blockquotes and testimonials |
| Video, audio, carousels, marquees, drawings, diagrams, math, swatches |
| Embeds and iframes, custom code, extension blocks, office maps, system status |
| Tab sets |
| Steppers, walkthroughs, scrollytelling stories |
| Expandables, expandable groups, modals |
| Buttons, button rows, forms, contact forms, RSVPs, quizzes |
| Sections, columns, grids, heroes, front-page bands and feeds, leaderboards, updates, dividers |
| 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 |
| Every top-level block of a body (the earlier default; |
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 |
|---|---|---|
|
| unset |
|
|
|
| See above |
|
| A multiplier on every duration, 0.25–4. |
|
|
|
|
|
|
|
|
|
|
|
|
|
| ms between blocks that come into view together, 0–1000 |
|
| px a block travels on the way in ( |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| unset ( |
| A translucent, blurred backdrop on a header that floats over content |
|
|
|
|
| A soft brand glow that follows the pointer. Mouse and trackpad only, never on touch |
|
| A 3px brand-coloured reading-progress bar across the top |
|
| Anchor links glide instead of jumping |
|
| The keyboard focus ring settles in instead of appearing |
|
| Your own animations; see below. Up to 12, each 2–12 steps | none |
| Documented hook → animation; see below | none |
| 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 |
|---|---|
| 0–1 |
|
|
|
|
| A hex colour, |
| Same as |
|
|
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) andcomponent(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 blockslayer,story,story-step,playground, the landing blocksheroandperson, and the office map'soffice-map,officeandmap;the three class hooks
button,inputandcard.
Any other key is refused. If you need an arbitrary selector, use customCss.
Target field | Values | Default |
|---|---|---|
| One of your | required |
| ms, 50–20000. Not scaled by | 600 ÷ speed |
| ms, 0–10000 |
|
| 1–100, or |
|
|
|
|
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 |
|---|---|---|
| A documented hook (the | one of |
| A block you named with its | |
|
|
|
| A preset, below. Any channel you set overrides the preset's | none |
| Parallax, −1 to 1. | none |
|
| none |
|
| none |
|
| none |
|
| none |
|
| none |
|
| from |
| The slice of the range the effect runs over, 0–100 (%) |
|
|
|
|
The presets:
| Means |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|
| The base duration after |
| The resolved timing function |
|
|
| Per block: its place in a group that entered together. Set and cleared by the script |
| 0–1 scroll position, on |
| The pointer position, on |
| Instructions to the reader's motion script. Read them if you like; do not set them yourself |
| Present once the script has armed reveals |
| On a revealed block or hook |
| Past the top of the page; |
| The main region while its entrance plays |
| What the scroll-linked fallback drives. An instruction to the script; do not set it |
| 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 |
|---|---|---|
| 1–4 layers, painted top first (as CSS paints backgrounds). Each is exactly one of | required |
| The same, for a visitor in dark mode |
|
| px, 80–1200 |
|
| px on a phone (≤640px), 60–900 | 70% of |
| px the band starts above the title, 0–400 — far enough and it runs under the header |
|
| How the whole band mixes with the page behind it (a blend mode, below) |
|
| The title's colour over the band, light and dark | the title's own |
|
|
|
A layer:
Field | Values | Default |
|---|---|---|
| An absolute | |
|
|
|
| A flat colour | |
|
|
|
|
|
|
| Where a picture sits, as percentages of the band |
|
| How this layer mixes with the layers below it: |
|
| Parallax as the page scrolls, −1 to 1: |
|
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 |
|---|---|---|
| 20 000 chars | Injected into the reader, scoped nowhere — write your own selectors. Sanitised: |
| ≤ 50 × 10 000 chars |
|
| ≤ 400 rules |
|
| 20 000 chars | Runs on every reader surface once the page has loaded and hydrated (browser idle after |
| ≤ 20 origins | Third-party script origins. Honoured only on your own domain, never on |
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 | Consent |
|---|---|---|---|
| emitted | emitted | none needed: they run no code |
| run | not emitted |
|
| honoured | ignored | — |
Chat-widget connectors | run | not emitted, unless the provider is one we mark safe for our origin | the provider's category |
| run in the page | only inside a sandboxed iframe |
|
| run | run |
|
| runs | not emitted ( |
|
| loaded once | not emitted (inline code and |
|
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 |
|---|---|---|
|
| A set of forum defaults laid under your keys: neutral paper and UI sans (all but |
| see the guide | The topic list: avatars, columns, excerpt, thumbnails, density, paging, stats, header; date style |
| see the guide | The front page's filter row and panel |
| see the guide | The native sidebar, the |
| see the guide | The topic page: avatars, byline, body size, votes, quotes, menu, map, reply order, answer placement, share, rail, timeline |
| 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.
Page keys on FAQ, legal, careers, custom, contact and course pages
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 ( | no tree, no rail, no breadcrumb, no byline, no date, no feedback, no previous/next, full-width article (unless you set |
Custom ( | 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 |
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 nochrome.headerLinksof 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;autodraws it for a page with four or more H2s.theme.onThisPageand a page's ownmetadata.onThisPagestill 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.phonenow also drives the legal, FAQ-index and lesson pages:disclosureshows 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 |
|---|---|
| arm |
| the control, everyone |
| the test again |
| the test, until an arm wins (below) |
| (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 |
|---|---|
| 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. |
| 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 …/themestill returns only what you wrote.Only on its own type. A space of another type ignores the namespace, so a site theme can carry
statusfor its status page andeventsfor 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/falseare1/0), for the type's own stylesheet and for yourcustomCss.lookitself 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 |
|
|
Card |
|
|
Chip |
| the tag chip's |
State colours | one key per state of the type, each |
|
Date |
| the blog's date formats ( |
Side rail |
| the changelog release page's |
Filter bar |
|
|
Subscribe |
|
|
Group headings |
|
|
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 | the front door's |
Facts ( |
|
|
People ( |
|
|
Dates |
|
|
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 |
|---|---|---|
| states | the palette slot the state means ( |
|
|
|
|
| icon on, subline off, "Last updated" on |
|
| start; square; 16 px |
|
|
|
| 8–64 px; 2–16 px (a fixed bar width; unset shares the row); 0–8 px; 0–8 px | the preset's |
|
| every day the API sends; 30 on a phone |
|
|
|
|
|
|
|
| on |
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
| the shared card part: |
|
|
| off |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| off |
|
|
|
|
|
|
|
| on |
|
| on |
|
|
|
| a state colour ( | the brand colour; 270 px |
| the hero illustration — up to four title-band layers (an https image or inline | no hero |
|
|
|
|
| off; the reader's radius |
|
| 7; |
|
|
|
|
| on |
|
|
|
| the shared group keys ( | a plain heading |
|
| 3 |
| 0–50: incidents listed per month before "Show all N" ( | 5 |
|
| on, on |
|
|
|
|
| on |
| the shared item actions ( | every action the space allows |
|
| 3, |
|
|
|
|
|
|
|
| 60; |
|
|
|
| "Report a problem" beside the title: an | no link; "Report a problem" |
|
| on |
|
| on |
| the |
|
|
| on |
|
|
|
|
| on |
|
| on |
|
|
|
|
|
|
|
|
|
| what a row's chart ( |
|
|
|
|
| the card's accent (unset: the incident's impact colour); px 0–24; | impact colour, 10, |
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
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.
theme.legal — the legal register and its documents
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 |
|---|---|
| A ruled list (the default) |
| A large heading in a left column, the documents ruled on the right, each with a Read button |
| Each document as a card with its description, two to a row |
| 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 |
|---|---|---|---|
|
|
| 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. |
|
|
| The line under each title: the page's search description, else the first sentence of its "In plain English" summary. |
|
|
| Which date each row prints. |
|
| shown | The edition ("Edition 3") beside the date. |
|
| on under | A "Read" pill on each row. |
|
|
| A filter box over the documents; |
|
|
| The line under a document's title. |
|
|
| Moves one of those facts to a small upper-case line above the title (Linear's "EFFECTIVE DATE: …"). |
|
| on | The kicker in the monospace face. |
|
| on | The 2px ink rule above the register's heading and every document's title. |
| the shared Date part | browser short date → | How every date on the register, the head and the facts strip is spelled — one key for all three. |
| the shared Facts part |
| The declared-facts strip (governing law, what it replaces, …). Facts the head already states are not repeated there. |
|
|
| Automatic clause numbers: |
| 1–3 | 2 | H2 only, H2–H3, or H2–H4. |
|
|
| The "In plain English" slot in a tinted, ruled box. |
|
|
| 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 ( |
|
|
| 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" ( |
| the shared item-actions part ( | every allowed action, | A document's actions: copy link, copy / view as Markdown ( |
|
|
| The register beside a document, grouped like the register, the current one marked: in the left column from 1280px ( |
|
|
| Clause glosses — a |
|
|
| The "Definitions" slot (one |
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 |
|---|---|---|---|
|
|
| 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, |
|
|
| Versions on the register. |
|
|
| On a version marked "Translation for information only": "This translation is provided for information only. The {original} is the version that governs." |
|
| on | Click a heading of the sub-processor table to sort by it. |
|
|
| "Since edition N: Added … Removed …" and "From {date}: …" (the upcoming edition) over the document. |
|
| none | The |
|
| none | The |
|
|
| The |
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 ( |
|---|---|---|
|
|
|
| on/off; the shared chip looks; | on, |
|
|
|
| glyph before the word (icons, never emoji) | on |
|
| New success, Improved info, Fixed brand, Deprecated warning, Retired neutral, Security danger |
|
|
|
| a breaking release's page opens on its "Upgrading" slot | on (off) |
| the channel beside the version; channels with no badge | on, |
|
|
|
| 1–12 chips, then "+N" | 3 (all) |
|
|
|
| the version pill | brand soft fill; nowrap on (off) |
|
|
|
| the summary's link words | "Read release" |
| title sized by | on (off) |
| the shared facts looks; "Try it" as |
|
| the roadmap requests the release shipped: |
|
| change-type chips on the filter and tags bands | on when any release has a type (off) |
| tag tabs in |
|
|
|
|
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 ( |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
| "4 min read" in each entry's meta | off |
| the shared grouping: | none |
| what the date gutter holds, in order: | the date alone |
| the blog's date formats ( | "October 14, 2026" |
|
|
|
| 1–10 releases for | 3 |
| "Changelog / Release" in place of the back link | off |
| the people it credits, as |
|
| "4 min read" under the title | on (off) |
| the shared item actions ( | every allowed action (none) |
| the cover over the release's title; it is also the page's share image | on (off) |
| the side column's items now include | 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 |
|
the rail / date gutter, or the gap between entries |
|
a type, breaking or channel badge |
|
the version pill |
|
a release's tags |
|
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
| px, 12–40 | the style's own |
| 300–800 | the style's own (500) |
|
|
|
| px between questions, 0–64 | the style's own |
|
| off |
|
| off |
|
|
|
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 |
|---|---|---|
|
|
|
| text | the space's name |
| text | the space's description |
| text | "Search questions…" |
|
|
|
|
|
|
| the title band's layers (image, gradient or colour) | none (no background) |
| the heading and lede colour over a painted hero | the page's |
|
| 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 |
|---|---|---|
|
| on |
|
| on |
|
| on |
|
| on |
| an address, printed as "Or email …" | none |
| 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 |
|---|---|---|
|
|
|
| 1–12 | 3 |
|
|
|
| 1–4 on the widest screen (2 on a tablet, 1 on a phone) | 3 |
|
| on |
|
| on |
|
| on |
| 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 |
|---|---|---|
|
|
|
|
|
|
| 1–12 | 5 |
|
|
|
|
| on |
|
| on |
| 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 noFAQPagemarkup.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 |
|---|---|---|
| up to 8 short texts, drawn as chips under the box; a chip fills it | none |
|
| on |
|
| on |
|
| shown when the FAQ is published in 2+ languages |
|
| on |
|
| off |
| votes before the rate shows, 5–1000 | 20 |
|
| on |
|
| the rail from 4 headed sections |
|
| on |
|
|
|
| the word before the audience | "For:" |
|
| on |
|
| 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 anhreflangalternate, 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 templatesfaq.question-addedandfaq.question-changed(Settings → Email templates).Comments on a question's own page are the space's comments setting (Settings → Readers), like any docs page.
elementStyleshooks: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 ( | Default layer |
|---|---|---|---|
|
| loose text |
|
|
| off | — |
| 0–48 px between lanes | 16 | — |
| 160–480 px, a lane's narrowest ( | 260 / 300 | — |
|
|
|
|
|
|
|
|
|
|
|
|
| below 768px: |
|
|
| every state's mark: | none |
|
| a state colour ( | brand chip | grey, info, warning, success, grey |
|
| — | — |
| the shared Card part ( | today's surface | — |
| the forum list's | — | — |
| the board page: |
| — |
|
| none | — |
| the card's foot, in order: | none |
|
| the word before the number, 24 chars: | "#24" | — |
| 1–4 / 0–4 (0 hides the excerpt) | unclipped / 2 | — |
| the item's | none |
|
| the item's | none |
|
|
| wide | — |
|
| the bordered "▲ n" box |
|
|
| start |
|
|
| ▲ |
|
|
| brand | — |
| the line under the board's title (280 chars) | "What we're building…" | — |
|
| under the submit band |
|
|
|
| — |
| a kicker over the headline (60) / the | — | — |
|
|
| — |
| 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" | — |
| the hero's background and the words over it — the title band's layers, as | 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 |
|---|---|
| the default layer only |
|
|
|
|
|
|
|
|
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 ( | Default layer |
|---|---|---|---|
|
|
| — |
| 1–50 past events before "All past events (N)" | 20 | 6 |
|
| none (a text line) |
|
|
|
|
|
|
|
|
|
| "12 going" / "12 spots left" / the place | on | — |
| the hosts' names and pictures | off | — |
| the forum list's | — | — |
|
|
| — |
| 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 | Month" links over the calendar; the reader's pick is an address ( | off | on |
|
| Sunday in English, Monday otherwise | — |
| filter rows over the own list, beside the tag row: | none |
|
| (shared key) past events per page on the past view, which says the total — "Past events (81)" — and pages with | 20 | — |
|
|
| — |
|
|
|
|
| a bar at a phone's screen foot — when, and an "RSVP" button to the card — while the event is open | on under | on |
|
| shown, the layout's aspect, 12 px | — |
|
|
|
|
| "Starts in 2 days 4 hours" before an open event | off | — |
| "More events": the calendar's next 1–6 | off / 3 | on |
|
|
| — |
| the same switch under the event's date | off | on |
| the programme (EV-4): |
|
|
| one tab per day ( | on | — |
| the track / stage / level / tag row ( | on | — |
| speakers on each session / the "My agenda" star and view ( | on | — |
| the session times' zone / the zone switch on the programme pages |
| — |
|
|
|
|
| 2–6 cards across under | 4 | 4 |
|
|
|
|
| the bio's first lines on each card / their links | off under | — |
| where it is, under the description, for an event with a venue or a place (not an online-only one) | off | on |
|
|
| — |
| "N going" with the faces of attendees who chose to be shown — only when the space shows attendees publicly | off | on |
| a repeating event (EV-6) in the list: |
|
|
| a "Repeats" line on a series' cards | on | on |
| "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 |
| "Add the series to your calendar": ONE repeating calendar entry ( | on | on |
| once an event is over and has a recording, the page leads with it and the RSVP card steps aside | off | on |
|
|
|
|
| the event's photos: |
|
|
| "Read the recap" once it is over, when the event links one (a blog post) | on | on |
| the event's feedback form (one of the organization's Forms) under it once it is over | on | on |
| the state pill's |
| — |
| a state colour ( | danger, warning, success, info, grey | — |
| the date tile's colour ( | 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 |
|---|---|---|
| Lane order (drag or arrows in the console). Orders, never filters | the five, then the board's own |
| Up to 10 statuses beyond the five: | none |
|
|
|
| The horizons in order (Now / Next / Later). Written as the |
|
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 ( | Default layer |
|---|---|---|---|
| the orders offered, in order: | no sort row | all three |
| a row of the board's tags | off | on |
| a row of the lanes (one lane at a time) | off | — |
| "My votes", for a signed-in reader | off | on |
| a search box over titles and summaries | off | — |
| the X3b facet bar's |
| — |
| the shared side rail ( | stacked, no rail |
|
|
| none |
|
| the item's history under its body: requested, every lane move, "Shipped in" | off | on |
| the team's latest answer in a panel under the title | on | on |
| "Shipped in v2.4 — title →" for an item a published release cites | on | on |
|
|
| — |
| a Newest / Oldest switch over the comments | off | on |
| the space's reaction palette on each comment | off | on |
| answer a comment in place; answers sit under it, one level | off | on |
| 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 |
|---|---|
| Heading, intro and facts, the topic cards, then the form (the default) |
| The pitch (intro, facts, topics) in a wide left column; the form in a card on the right that stays in view |
| "How can we help?" and the topic cards; the form opens on the card picked (a topic with a redirect links there) |
| 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 |
|---|---|---|---|
| the shared card surface | none → | The box around the form: |
|
| the column |
|
|
|
|
|
|
|
| Name and email side by side from 640px, or stacked. |
|
|
| Where the send button sits. |
|
| off | The send button as wide as the form. |
|
|
| How the declared facts (email, hours, phone, response time…) are laid out. |
|
|
| In the intro above the form, in a column beside it (from 1024px), or under it. |
|
|
| Cards in the topics band, the "What is this about?" dropdown, or tabs over the form. Cards need a page that places the |
| 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 |
|---|---|---|---|
| 1–4 title-band layers ( | none | A painted backdrop behind the page — Stripe's contact-sales gradient. The same layers and painter as |
| the same | the light layers | The dark-mode backdrop. |
|
|
|
|
| 160–1200 px | 560 | How far the |
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 ( | Default layer |
|---|---|---|---|
|
|
|
|
|
| shown | shown |
|
| solid |
|
|
| 16:9 |
|
| 0–48 px or | rounded | — |
|
| checklist |
|
| a lesson's | shown | shown |
| a signed-in learner's "3 of 5" and bar per module | off | on |
| modules start folded (the one holding the next lesson stays open) | open | — |
|
| none |
|
|
| none |
|
|
| none |
|
|
| separate |
|
|
| none |
|
| "Chapter 3" over the title | off | off |
| the | shown | shown |
|
| 8px bar |
|
|
| check + strike |
|
| a palette tone ( | brand |
|
|
| the old page |
|
| the theme's | off | on |
|
| double navy/gold |
|
| a palette tone for the heading, the name and the frame | navy |
|
|
| — | — |
| one line of your own under the course title (200 chars) | — | — |
| the 0.75×–2× row under every lesson video | off | on |
| 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 |
| a video's transcript starts | collapsed |
|
| 10–100: the lesson completes once that percent of its video has actually been played | never | never |
| "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 |
| the XP the course paid this learner ( | off | off (on in |
| 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 |
|
| off |
|
| a paid course's button says "Buy course · $49" | — | on |
| the refund window and the owner's terms under the button | — | on |
|
| — |
|
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 |
|---|---|---|---|
|
|
| A locked lesson in the outline: a padlock and why, faded, or not listed. |
| boolean |
| "Opens Mar 10" / "Opens 7 days after you start" beside a dripped lesson. |
| boolean |
| "Week 2 · Mar 10–16" over each module of a cohort course. |
| boolean |
| The linked events space's next sessions (the |
|
|
| The |
| boolean |
| "Add to LinkedIn" on a certificate (a plain link; no request until clicked). |
| boolean |
| "Copy link" on a certificate. |
| boolean |
| 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, aplacefield. It is a list of{ name, street, city, region, postcode, country, phone, email, image, mapUrl, hours }.openingHours, anhoursfield:{ tz: "Europe/London", slots: [{ days: ["mo",…], open: "09:00", close: "17:00" }] }. Acloseat or beforeopenruns 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 |
|---|---|---|
| on | Draws the offices band |
| on | Draws the channels band |
| on | Shows the "Open now" line |
| off | While a visitor types the subject or message, up to |
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 |
|---|---|---|---|
|
|
| Office cards (photo, address, phone, email, hours, open-now) or one row per office. |
| 1–4 | as many as fit, up to 3 | Office cards per row from 640px. |
|
|
| A "Map" link: the office's |
|
| shown when set | The office photo on its card. It is proxied like every reader image. |
|
|
| 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. |
|
| none | Drops a found channel. |
| up to 8 | none | The owner's own channels (a Slack, a phone line). Each |
| text | "Other ways to get help" | The band's heading. |
| 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 ( | Default layer |
|---|---|---|---|
| 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 |
|
| a "Formatting" switch to the block editor | off | on |
| requests like the title being typed, as links to vote on | off | on |
| pick up to 5 of the board's own tags | off | on |
| pick one of the themes the board's items carry | off | on |
| a merged request's page: |
|
|
| the survivor lists what was merged into it, with votes | off | on |
| 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 |
|---|---|---|
|
|
|
| the unread count on the bell (or on your own button) | on |
| 1–30 releases | 10 |
| 260–640 px, the panel | 360 |
| each card's cover image, change-type badge, tags, summary | on / on / off / on |
| a "See all releases" link | on |
| show a boosted release once per visitor | on |
| the panel's heading | "What's new" in the embed's language |
| 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 |
|---|---|---|
| the panel's "Any of these / All of these" switch | on |
| where a tag tab goes: |
|
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 |
|---|---|---|
|
|
|
|
|
|
| the rail stays in view while the page scrolls | on |
| 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 |
|---|---|---|
|
|
|
| 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 |
|---|---|---|
|
|
|
| 11–32 px, the label's size in any look | the look's own |
| a glyph before each section's label (Raycast's New / Improvements / Fixes) | off |
|
| the defaults |
| the glyph's colour: |
|
| a hairline between sections | off |
| the release notes' bullet: | the theme's |
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) oremail-popover(a button opening just the email box). Defaults: CHANGELOGlink(the head's item as it always was), STATUSbutton(beside the page title, as every reference status page has it). A type gains the key when its reader draws the control.Front-page
subscribeband,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 placedsubscribeband.
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 |
|---|---|---|
|
| the type's own index |
| 1–6, desktop columns for | the layout's own |
| card fields / table columns, in order ( | the type's summary |
|
| no groups |
| the field | — |
|
|
|
| each group folds; its heading stays in view; "Team · 4" | off |
| the filter bar's fields ( | the type's declared |
|
|
|
| 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'sctais its register card —{ "layout": "side" }stands it beside the description, sticky.item.actions—itemsfromcopyLink,copyMarkdown,viewMarkdown,print,share(unset: all the space allows: the Markdown pair needsreaderTools.copyMarkdown,printneedsreaderTools.pdf,shareneedsfrontDoor.showShare), andstyle: 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 |
|---|---|---|
|
| the preset's: |
| cards to a row at full width | 2 |
| 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 |
| as above; |
|
| the filter bar's fields | the declared facets: team, location, remote, employment, experienceLevel |
| roles before "Show more" | 50 |
|
|
|
|
|
|
|
|
|
|
| one muted line |
|
|
|
| open roles before the search box and the filter bar show; | never, unless |
| where the search box and filters stand: |
|
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": … }:
| Reads as |
|---|---|
| the role, then the apply form under it (Lever, Greenhouse) |
| the role beside a sticky column of its facts, Apply and share (Stripe); the article goes |
| "Overview" and "Application" tabs over one address (Ashby) — plain in-page links, the Application tab is the form's |
The preset is laid under the owner's keys at read, so these still win:
Key | Values | Unset |
|---|---|---|
|
| the piece preset's |
| an Apply button beside the facts, over the prose | off; the default look: on |
| an Apply bar that rides the bottom of the screen and settles above the form ( | off; the default look: on |
| how many other open roles on the same team to list under the form ( | 0; the default look: 4 |
| the shared facts look: |
|
|
|
|
| X3b's item actions ( | every action the space allows |
| X3b's side rail keys for the |
|
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 |
|---|---|
| CSS for this piece only (max 20 000 characters). |
| A partial theme for this piece, layered over the space's. |
|
|
|
|
|
|
|
|
| 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,onThisPageandseoTitle: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
themeis sent whole.Blog posts and changelog releases take them under
customizationon their own create andPATCHdoors (…/blog,…/blog/{id},…/changelog,…/changelog/{id}). Here the object merges: a key you send replaces, a key you send asnullis cleared, a key you leave out stays.Inline legal documents (body stored on the document, not a page) take
customizationonPOST /orgs/{orgId}/legal, with the same merge. Changing it never bumps the document's version — it is presentation, not wording.Events take
customizationonPOST …/eventsandPATCH …/events/{idOrSlug}(stored on the event, migration 0355), same merge. Both doors already needforum.moderate.Forum topics take
customizationonPOST …/forumandPATCH …/forum/{topicId}; roadmap items onPOST …/roadmapandPATCH …/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 — socustomizationon these doors needsforum.moderatein 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
customizationon the incident create andPATCHdoors (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
customizationon each topic ofPATCH …/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,bodyand.forumulate-readerall mean "the reader" here, so:root { --reader-brand: #c00 }re-brands this one page.@keyframes,@font-faceand@propertynames are prefixed, so this page'sspincannot 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 |
|
FAQ question |
|
careers role |
|
course lesson |
|
blog post |
|
changelog release |
|
forum topic |
|
roadmap item |
|
event |
|
legal document |
|
status incident / maintenance window |
|
contact topic |
|
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 —
EventwithstartDate/endDate,eventStatus(cancelled events say so),eventAttendanceModefrom what you filled in (a location is aPlace, a join link aVirtualLocation, both is mixed),organizer(your site),image(share image, then cover), capacity, and anoffersnode for the RSVP — its URL is the event page and its availability isSoldOutwhen the event is full. No price is stated (an RSVP is free); a ticketed event should setjsonLdwith its ownoffers(price,priceCurrency,url).Forum topics —
DiscussionForumPosting(headline, text, author, dates, image, keywords, comment/view/like counters and the first replies ascomment), orQAPagewith the accepted and suggested answers for a question.Roadmap items —
ArticlewhosecreativeWorkStatusis the item's lane under your board's label ("Planned", "Shipped"), with votes and comments as interaction counters andisPartOfthe 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 }wherethemeis 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-componentsroutes, 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 withPUTon 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'sstrippedlist, 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 |
|---|---|
|
|
|
|
|
|
| The four text/emphasis palette slots |
| The signal palette slots (the four tones also paint the callouts) |
| The header palette slots |
| The text palette slots ( |
| The footer palette slots |
| The media breakout width ( |
|
|
|
|
|
|
|
|
|
|
| The PAGE's surface and ink, even inside a band in the other scheme |
| The shell width ( |
| The board, index and piece widths ( |
| The long-form line length ( |
| The header/footer inner row ( |
|
|
| Heading and body families ( |
| The code family ( |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| One per resolved theme setting |
| A documentation space's sidebar width ( |
| A documentation article's max width |
| The documentation header's rendered height — what sticky offsets read |
| The documentation |
| The |
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Published by a styled section, on the section element (see Hooks you put on a block yourself):
Variable | What it is |
|---|---|
|
|
|
|
| the |
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
PATCH, notPUT, unless you mean to replace the whole theme.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.Check
/public/.../chrome, not/theme. The public read is what the reader actually gets, including a site theme merged underneath.customFontUrlandcustomFontFamilytravel together. One without the other does nothing.A width is four variables, not one.
--reader-maxonly moves the shell; an index reads--reader-listand a single piece reads--reader-text. Override one of those, never a Tailwind class name.chromeis 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
guidesspace's "Building your site" collection. Re-import after editing — the page is not generated from this file, so the two drift unless you do.