Themes: bundles, your library and the marketplace
A theme is a whole look — colours for light and dark mode, fonts and font files, the type scale, component shape, layout, motion, custom CSS, the header and footer chrome and per-element styles — packaged as one portable JSON file called a theme bundle. You can save any space's or site's look as one, keep it in your organization's theme library, apply it anywhere, download it, import somebody else's, and publish it to the theme marketplace.
Every way a theme reaches a space or site lands as a draft: nothing a reader sees changes until you have previewed it and pressed publish (see Draft a theme, and preview it on your own domain).
This page assumes you know what the keys inside a theme do — that is Theme your site. To style a part of any type's pages by hand (from customCss or elementStyles), see Theming hooks, the selectors every type's pages share.
What never travels in a theme
A bundle is a look, never a brand and never code. Three groups of keys are left out when a theme is exported, ignored when one is imported, and kept from your own theme when one is applied:
Group | Keys | Why |
|---|---|---|
Your brand |
| Installing a stranger's theme must not put their logo on your docs. |
Scripts |
| A bundle is data. Nothing in one can run, ever. |
This address |
| Each names something that exists on one site and nowhere else. |
Every export and import answers a stripped list naming each key it left out and why (brand, script or local), so nothing disappears silently.
The shape and size each logo, cover and avatar is drawn at is in Image sizes and crops.
The bundle format
{
"format": "forumulate.theme-bundle",
"formatVersion": 1,
"meta": {
"name": "Harbour Lights",
"slug": "harbour-lights",
"description": "Navy and coral, calm and even.",
"author": { "name": "Acme Design", "url": "https://acme.example" },
"version": "1.2.0",
"license": "MIT",
"tags": ["dark", "docs"],
"spaceTypes": ["DOCS", "FAQ"],
"previewImages": [{ "url": "https://img.acme.example/harbour-light.png", "scheme": "light" }]
},
"requiredOrigins": {
"fonts": ["https://fonts.googleapis.com", "https://fonts.gstatic.com"],
"images": ["https://img.acme.example"]
},
"theme": {
"primaryColor": "#1e3a5f",
"palette": { "background": "#f5f8fb", "surface": "#ffffff" },
"paletteDark": { "background": "#0c1726", "surface": "#13223a" },
"customFontUrl": "https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;700&display=swap",
"headingFontFamily": "Source Sans 3",
"bodyFontFamily": "Source Sans 3",
"typography": { "scale": "major-third" },
"components": { "radius": 8, "button": { "radius": 8, "weight": "semibold" } },
"motion": { "preset": "subtle" }
},
"components": []
}The rules a file is held to, all of them refused with a 422 that names the path:
Strict, all the way down. A key the format does not have — in the envelope, in
meta, or anywhere insidetheme(theme.components.radiuss) — is refused by name, never dropped.Every outside origin is declared up front. Each absolute URL in the bundle — a font file in
theme.fonts.faces[].src, a hosted font stylesheet incustomFontUrl, aurl(…)in the CSS, a preview image — must be under an origin listed inrequiredOrigins(fontsfor fonts and stylesheets,imagesfor pictures; a CSSurl()may be under either). Origins follow the same exact-https rule as every other origin list:https://cdn.example.com, never a path, a wildcard or a bare scheme.CSS is sanitised with the reader's own sanitiser on the way in:
customCss, everycssSnippets[].css,mobile.cssand everyelementStylesvalue.At most 256 KB of JSON.
componentscarries theme components. It must be empty (or absent) in format 1; a bundle with components is"formatVersion": 2, and each entry is a whole theme component file. Installing the bundle installs them and switches them on in the draft; a script in one is stripped and named, likecustomJs.frontPagescarries composed front pages (optional) — at most one pertarget, which is a space type (BLOG,FORUM, …) orSITE. Each entry is the body of a front pagePUTplus itstarget; installing applies the entry for the address's kind and ignores the rest. The layout and the page's own CSS and theme travel; its share card and JSON-LD (shareImageUrl,ogTitle,ogDescription,twitterCard,jsonLd) belong to one address and are stripped with alocalnotice. An export carries a front page only when one is stored, never the generated default.
meta.version is major.minor.patch (2.0.0-beta.1 is fine). meta.spaceTypes is any of the space types (DOCS, FORUM, BLOG, …); empty means "suits any space".
Export and import
Call | What it does |
|---|---|
| The space's current look as |
| Body: a bundle file. Applied as the space's draft; answers |
| The same for a site. |
| A bundle onto the site's draft. |
| Read or throw away a site's draft. |
| Make the site draft live (409 if the live site theme moved; |
These need space.update. An imported look replaces the drafted look and keeps your brand, scripts and wiring; if a draft already exists it starts from that draft, so a logo you changed in a draft yesterday is not lost.
A space draft is previewed at the address readers use with a preview token (POST …/spaces/{spaceId}/preview). A site draft has no token — it would change every space under the site at once — so the console previews it on the theme sample page instead.
Your theme library
Needs the themes.manage capability (Admins and Owners have it; a custom role can be given it; the themes:manage token scope carries it).
Call | What it does |
|---|---|
| Every theme the organization keeps, with its metadata and where it came from. |
| Save as theme: |
| Body: a bundle file. Its font files and preview images are copied into your assets. |
| Edit name, description, version, tags, space types, preview images, author. |
| The library theme as a downloadable bundle. |
| Remove it. Spaces already wearing it keep their look. |
To put a library theme (or a first-party one) on a space or site as a draft:
POST /orgs/{orgId}/spaces/{spaceId}/theme/draft/apply
{ "componentId": "…library theme id…" }
POST /orgs/{orgId}/sites/{siteId}/theme/draft/apply
{ "firstParty": "aurora" }That takes space.update on the space, because it is the write that will change what its readers see.
Why files are copied. A theme whose fonts live in somebody else's library breaks the day they delete a file. Installing or importing copies every font file, preview image and url() file into your own assets (fetched through the same SSRF guard every other server-side fetch uses, capped at 5 MB a file) and points the theme at the copies. A file that cannot be copied is left at its original address — its origin was declared, so it still loads — and the answer's assets list says which and why. A hosted stylesheet (customFontUrl) is not copied; its origin stays declared.
The marketplace
Browse without signing in:
Call | What it does |
|---|---|
| Live listings. |
| One listing with its look, for a preview. |
| A picture of the theme in both modes, drawn by us from its tokens. |
| The bundle file of a free listing. |
From inside an organization (themes.manage):
Call | What it does |
|---|---|
| The same browse, marking your own listings |
| Copy into your library; with |
| Buy a paid listing (402 from install means "buy first"). |
| Your listings and where each review stands. |
| Submit a library theme: |
|
|
| Take it down. Organizations that installed it keep their copy. |
Review
Every listing is reviewed before it is public, like extensions:
A new submission is
submittedand visible to nobody but you.A platform administrator approves it (it goes live) or rejects it with a note (you can fix and resubmit).
Resubmitting a live listing queues the revision; the approved version keeps serving until the revision is approved, and stays if it is rejected.
Only a live listing can be featured.
Paid listings use the same checkout as paid extensions. Free is the default.
First-party themes
Forumulate ships eight complete starter themes, featured in the marketplace — each with a light and a dark palette, three type roles, a type scale, component shape, syntax colours for both modes and a motion preset:
Slug | Look |
|---|---|
| Night blue with a violet-to-teal glow, glass cards, lively motion. |
| Ivory pages, ink serif, a red-pencil accent — long-form reading. |
| Engineering blue, tight geometry, monospaced detail — reference docs. |
| Fresh greens and round corners — friendly communities and help centres. |
| Black and white only, grotesk display, square corners. |
| Tangerine and lemon, pill buttons, a bouncy entrance. |
| Navy, sea-glass and a coral call to action — enterprise help. |
| Blossom pink by day, plum by night, elegant serif headings. |
The eight built-in presets (classic, midnight, editorial, terminal, sunrise, slate, forest, grape — see the advanced theming guide) are listed beside them. First-party themes are free and install like any other.
The ten official templates are listed first, as template-<id> (for example template-corvane): each is the look of a complete showcase site, with its screenshot, a previewUrl to the live site and a template field naming the template it belongs to. Installing one from here brings the look; to get the layout, tags, authors and sample posts as well, start a new space from it — see Start from a template.
Mix and match: looks are patches
The space types have looks: theme.status.look, theme.faq.look, theme.changelog.look and so on. A look is not a stylesheet. It is a small set of ordinary theme keys, written for you as defaults and resolved when a reader loads the page — never stored in your theme. A look may set any theme key: its own type's keys (theme.status.banner, theme.changelog.entry), and the shared ones (palette.paper, typography.ui, components.radius, chrome.header.paint). Every key a look writes is one you can set yourself, under any look, and every key you set wins over the look, field by field. So you can take one piece of a look without the rest, or keep a look and drop one piece of it.
Why it works this way: a look you cannot take apart is a look you outgrow. If minimal were a stylesheet, wanting its quiet list with the bold header would mean writing CSS against our markup. Because both are only keys, you set the one key you want from the other look and you are done.
paper is the way back on every type: it writes nothing, so the reader looks as it did before that type had looks. An unknown or unset look resolves to the type's default.
Layering: theme, space, item
Precedence, highest first: block attribute → the item's own override → your space theme → the site theme → the look's patch → the built-in default.
The site theme is the base for every space in the site. It may carry several types' namespaces at once (
statusfor the status page,eventsfor the events calendar); each space reads its own namespace and ignores the others.The space theme wins over the site's, key by key.
One item — a post, an incident, an event, a role, a legal document, a contact topic — can override the space for itself alone through its
customization.theme. An item takes the shared token keys (palette, typography, components, layout, motion) and its own type's namespace (a question or a role also takesdocs, because those pages are drawn by the docs reader). A namespace another type owns is refused with a 422 that names it, rather than stored and ignored. A type's front page takes no namespace: the index already reads the space's.
curl -sS -X PATCH \
-H "Authorization: Bearer $FORUMULATE_TOKEN" -H 'content-type: application/json' \
https://forumulate.com/api/orgs/$ORG/spaces/$SPACE/status/incidents/$INCIDENT \
-d '{ "customization": { "theme": { "palette": { "paper": "white" }, "status": { "banner": { "style": "tint" } } } } }'Which looks each type has
Type | Key | Looks (default first) | Guide |
|---|---|---|---|
Docs |
|
| |
Community spaces |
|
| |
Blog |
|
| |
Changelog |
|
| |
Roadmap |
|
| |
Status |
|
| |
Legal |
|
| |
FAQ |
|
| |
Careers |
|
| |
Contact |
|
| |
Events |
|
| |
Courses |
|
| |
Custom |
|
|
The blog is the one type whose default is paper: every blog was tuned against the reader without a layer, so choosing nothing changes nothing. Each type's guide lists the keys each of its looks writes.
In the studio, each type has a "… : every setting" folder (for example Status page: every setting) holding the look picker and every key under it. Double-click a part of the page and the inspector opens This part's look: the keys that draw that part (see Theming hooks).
Combinations no look gives you
A calm status page with a white paper and a brand header:
{
"status": { "look": "calm" },
"palette": { "paper": "white" },
"chrome": { "header": { "paint": "brand" } }
}The devlog changelog's ruled rows and chips, but with every release in full on the index:
{ "changelog": { "look": "devlog", "entry": { "body": "full" } } }The cards changelog with the platform's sans face everywhere:
{ "changelog": { "look": "cards" }, "typography": { "ui": "sans" } }What the docs and forum looks write
Key | Scope |
| Other looks |
|---|---|---|---|
| theme |
| docs |
| theme |
| — |
| theme |
| — |
| theme |
| — |
| theme |
| — |
| theme |
| — |
| theme |
| — |
| theme |
| — |
| theme |
| — |
| theme |
| — |
| theme |
| — |
| theme |
| — |
| theme | — | docs |
| theme | — | forum |
| theme |
| forum |
paper writes none of them: the reader as it was before 2026-10. The keys palette.paper, layout.canvas, typography.ui, typography.rhythm, components.code.inline.wash and chrome.header.paint work on every type, not only docs and forum, so any type's look (or you) can set them; a site theme that sets one reaches every space in the site.
palette.paper
The paper set — page, surface, panel, ink, muted and rule tokens — in one choice, both schemes: warm (the reader's beige), white, soft (the page on the #fafafa surface tone) or grey (the page on the #f4f4f5 panel tone). Unset is warm, or whatever the look writes. In paletteDark it picks the dark set alone. Every explicit palette slot (background, ink, …) still wins over the paper.
{ "palette": { "paper": "soft" }, "paletteDark": { "paper": "white" } }layout.canvas
document paints the page behind the reader — <html> and <body>, what you see when you overscroll or the page is short — in the reader's paper. page (unset) leaves the global canvas. Never over your own palette.background, which paints it already.
{ "layout": { "canvas": "document" } }typography.ui and typography.rhythm
typography.ui — sans, serif or system — is the face for display and body alike, from the platform's own fonts (nothing is downloaded). Unset: the reader's own faces. headingFontFamily / bodyFontFamily still win.
typography.rhythm: "product" sets the page title at 36px, weight 600, tracking −0.02em (30px on a phone), running text on a 1.6 leading and H2/H3 at 600. reader (unset) is the reader's own rhythm. Any level you size under typography.levels still wins.
{ "typography": { "ui": "sans", "rhythm": "product" } }components.code.inline.wash
The inline-code chip's default wash when you chose no code colour: brand (unset — a tint of your brand) or ink (a 6% ink wash on the page hairline). components.code.inline.background: "none" still removes the fill whatever the wash.
{ "components": { "code": { "inline": { "wash": "ink" } } } }chrome.header.paint
brand paints the header bar in your brand (or your palette.header_background) with the ink that reads on it — on a space of any type, under any look. page (unset) leaves the bar on the page.
{ "chrome": { "header": { "paint": "brand" } } }forum.qa
The Q&A behaviour, on top of whichever forum look you chose: votes and answers in the list's stats, the vote gutter on posts, the accepted answer pinned first (marked once, by the check under the gutter) and replies sorted by votes. on always; auto when most of your published topics (of ten or more) are questions; off never — not even the automatic switch of an unchosen look to qa. Unset is the old rule: only an unchosen look turns into qa, automatically.
{ "forum": { "look": "board", "qa": "on" } }forum.front.categoriesLayout
The category band of the forum's generated home: rows, tiles, grid or table, under every look — paper included. Unset: the look decides (see the table above). A home page you composed yourself keeps what you placed.
{ "forum": { "front": { "categoriesLayout": "tiles" } } }Three combinations no look gives you
Each is a complete theme body you can PATCH as it is.
A classic board that votes, with a tiled home and a brand header. The board's table with Q&A voting, logo tiles on the home, and the header in your brand:
{
"primaryColor": "#0f766e",
"forum": {
"look": "board",
"qa": "on",
"front": { "categoriesLayout": "tiles" }
},
"chrome": { "header": { "paint": "brand" } }
}The old paper with product type. Keep the warm paper (docs.look: "paper") and take only the clean look's type:
{
"docs": { "look": "paper" },
"typography": { "ui": "sans", "rhythm": "product" }
}Clean, but warm, with bare inline code. Everything clean gives you except its white paper, and inline code as bare text:
{
"docs": { "look": "clean" },
"palette": { "paper": "warm" },
"components": { "code": { "inline": { "background": "none" } } }
}The header and navigation keys compose the same way — see Theming: the rest of it → Header and navigation keys you can mix.
Loading placeholders (components.skeleton)
While a part of a page is still on its way — a forum's search results, the reply box under a topic, a New topic button that waits to learn who is reading — the reader draws a placeholder the exact size of what is coming, so nothing jumps when it lands. Every placeholder reads the same four settings, so one key restyles all of them. Each key is optional and mixes with the rest; unset, the placeholder is a soft shimmer mixed from your page's text colour, which already suits a light and a dark page.
Key | Value | Unset |
|---|---|---|
|
|
|
| corner in px (0 is square) | 6 |
|
| 9% of your text colour |
|
| 4% of your text colour |
|
|
|
{
"components": {
"skeleton": { "style": "pulse", "radius": 2, "baseColor": "#ece7df", "baseColorDark": "#2a2622" }
}
}firstPaint: "whole" holds the browser's first paint until the page's main column has arrived, so a header that takes its width from the content (layout.frame: "column", where it is the default) is never drawn on its own and then re-laid-out. A page that is still loading paints its placeholder as before. progressive (the default everywhere else) paints the header the moment it arrives.
A reader whose system asks for reduced motion always gets a still placeholder, whatever style says. Placeholders never hold real content back: on a fast connection the page arrives before any of them is drawn.
In the console
Themes in the sidebar has three tabs:
Library — Save as theme from any space or site, Import a theme file (into the library or straight onto a space or site as a draft), and every library theme with Apply as draft, Preview, Download, Details (description, tags, space types, screenshots), Publish and Delete.
Marketplace — search and filter, and a detail page for each theme with a live preview (a sample site wearing it, in light and dark), everything it loads from, and Install onto your library or a space or site.
Your listings — submit, see review notes, set a price, take down.
The theme editor in a space has Save as theme too.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Public Theme Registry
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List themes |
| |
| Get the theme |
| |
| Get the card svg |
| |
| Get the bundle |
|
Theme Bundle
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the bundle in a space |
| |
| Replace the bundle in a space |
| |
| Apply the draft (spaces) |
| |
| Get the bundle in an organization (sites theme) |
| |
| Replace the bundle in an organization |
| |
| Apply the draft (sites) |
| |
| List draft theme in an organization |
| |
| List draft theme in an organization (replace) |
| |
| List draft theme in an organization (update) |
| |
| List draft theme in an organization (delete) |
| |
| Publish the draft |
|
Theme Library
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the theme library in an organization |
| |
| Create the theme library in an organization |
| |
| Import the theme library |
| |
| Get the theme library |
| |
| Update the theme library |
| |
| Delete the theme library |
| |
| Get the bundle in an organization (theme library) |
|
Theme Marketplace
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the theme marketplace in an organization |
| |
| Get one marketplace theme, with its look |
| |
| Install the theme |
| |
| Create the purchase in an organization (theme marketplace themes) |
| |
| List listings in an organization (theme marketplace) |
| |
| Create a listing in an organization (theme marketplace) |
| |
| Replace the pricing in an organization |
| |
| Delete the listing (theme marketplace) |
| |
| Get the theme registry in an organization |
| |
| Publish the theme registry |
| |
| Install the theme registry |
| |
| Delete the theme registry |
|