Theme components: small add-ons, many per site

Embed
Preview of the card
<a href="https://forumulate.com/guides/theme-components"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/21b9a349-0a5c-46f8-a47f-aa3b2a2ca152/social-card.svg?v=3e7ae33eec8b" alt="Theme components: small add-ons, many per site" width="600" height="315"></a>

A theme component is a small installable add-on — a dismissible banner, a sponsor strip in the forum's side column, a "back to top" button, banner images on your categories. Where a theme is a whole look, a component is one piece of furniture, and you can have as many as you like: install them once in your organization, switch each on for the spaces and sites that should wear it, in the order you want, and turn any one off without uninstalling it.

A component is four things, all optional except that it must put something on the page:

  • CSS — restyles the reader (and only the reader).

  • HTML in named outlets — markup placed into fixed, documented slots of the reader page: under the header, above a forum's topic list, after every post, on a member card.

  • Settings — typed values an owner tunes per install (a colour, a message, a link), read by the CSS as var(--setting-name) and by the HTML as {{settings.name}}.

  • Strings — the words its markup says, per language, as {{strings.key}}.

The component file

{
  "format": "forumulate.theme-component",
  "formatVersion": 1,
  "meta": {
    "name": "Release banner",
    "slug": "release-banner",
    "description": "A strip under the header announcing the latest release.",
    "author": { "name": "Acme Design", "url": "https://acme.example" },
    "version": "1.0.0",
    "license": "MIT",
    "tags": ["banner"]
  },
  "settings": {
    "message": { "type": "string", "default": "Version 4 is out", "maxLength": 120 },
    "link_url": { "type": "url", "default": "" },
    "tone": { "type": "enum", "default": "brand", "choices": ["brand", "quiet"] },
    "background": { "type": "color", "default": "#1e3a5f", "label": "Background" }
  },
  "strings": {
    "en": { "more": "What changed" },
    "fr": { "more": "Les nouveautés" }
  },
  "outlets": {
    "header-after": {
      "position": "before",
      "html": "<p class=\"rb rb--{{settings.tone}}\">{{settings.message}} <a href=\"{{settings.link_url}}\">{{strings.more}}</a></p>"
    }
  },
  "css": ".rb{margin:0;padding:.5rem 1rem;text-align:center;background:var(--setting-background);color:#fff}\n.rb--quiet{background:transparent;color:inherit}\n.rb a[href=\"\"]{display:none}"
}

The rules a file is held to — every one refused with a 422 that names the path:

  • Strict, all the way down. A key the format does not have is refused by name, never dropped.

  • Outlets come from the registry below. An outlet name that is not in it is refused (outlets.sidebar-of-dreams).

  • Every reference must resolve. A {{settings.x}} or var(--setting-x) that names no declared setting, or a {{strings.k}} that no locale defines, is refused where it is written — so a component never ships a placeholder that silently prints nothing.

  • Defaults fit their declaration. An enum default must be one of its choices, a number default inside min/max, a string inside maxLength.

  • At most 64 KB, CSS and each outlet's HTML at most 20,000 characters, 30 settings, 20 locales.

meta is the same metadata a theme bundle carries (slug is lowercase letters, digits and hyphens; version is major.minor.patch).

Outlets

An outlet is a named slot the reader draws. A component's HTML for an outlet goes into that slot on every page that has it, with position:

position

Where

after (default)

After whatever the reader draws in the slot (and after before pieces)

before

Before it

replace

Instead of the reader's own content — only on an outlet that wraps some; elsewhere, after

Several components in one outlet keep the order you switched them on in, with every before piece first.

An outlet costs nothing until it is used. A slot no component targets renders exactly what it rendered before components existed — no wrapper element, no attribute, no script. When a component fills one, the slot gets one wrapper and each component its own element, both with stable hooks:

Hook

The element

[data-fx-outlet][data-outlet='user-card']

An outlet a component has filled

[data-fx-component][data-component='my-slug']

One component's markup inside it (by its slug)

Both are part of the fx-1 hook contract and both are theme.motion.targets keys (outlet, component), so you can animate a component like any other part of the reader.

Some outlets offer facts about what they sit next to as placeholders — a member card offers {{user.website}}, a topic {{topic.title}}. They are escaped like every placeholder, empty when the reader does not know one, and only valid in the outlets that offer them.

Outlets on every type

Every space type has outlets of its own, on each kind of page it draws, besides the eight chrome outlets (header-before … footer-after, front-page-top, front-page-bottom) that every page has. An outlet's surface is the type it belongs to: a status-… outlet appears only on a status page, a legal-… one only on legal documents and the legal register.

Surface

Outlets

Pages with a slot

Wraps something

forum

16

index, category, item, hub

category-header, user-card

blog

8

index, item, hub

docs

8

item, category, hub

changelog

8

index, item, hub

roadmap

7

index, item

status

7

index, item, subpage

faq

3

index

events

11

index, item, subpage

events-empty

courses

16

index, item, subpage, hub

courses-lock-card

careers

9

index, category, item

careers-apply-sent

legal

6

index, item, subpage

contact

5

index, item

contact-sent

A custom space has no surface of its own: its pages are drawn by the docs reader, so they offer the docs-page-…, docs-sidebar-… and docs-rail… outlets, and its /c/<slug> collections offer docs-hub-above-list and docs-hub-below-list. FAQ and careers collections offer those two as well.

Pages. Each outlet names the pages it is on, in one vocabulary for every type: index (the type's front door), item (one post, release, incident, role, document or contact topic), subpage (a page that belongs to an item or to the type but is not its index — an event's agenda, a status history page, a course quiz, a legal document's past version), hub (a listing of one slice: a tag, an author, an archive month, a course module), category (a /c/<slug> collection or a forum category) and every (the chrome). GET /theme-outlets returns each outlet's pages, so a tool can list exactly the slots one page offers.

Wraps. Only an outlet that wraps something the reader already draws (a category's header, the "message sent" confirmation, a locked lesson's card) honours position: "replace". Everywhere else there is nothing to replace, so replace reads as after.

Facts. The Offers column below lists the placeholders an outlet hands a component ({{incident.title}}, {{role.slug}}, {{document.version}}). An outlet on a sub-page that several pages share also offers {{subpage}}, the name of the page it is on (history, agenda, …), so one component can say something different on each.

Once per page, or once per row. An outlet that is not marked as repeating appears exactly once on a page, whatever the page's layout: the status board's status-above-board and the roadmap's roadmap-above-board sit beside the composed front page, not inside one of its bands, so removing a band does not remove your component and placing two bands does not draw it twice. A repeating outlet (forum-post-after-body, blog-card-footer, roadmap-card-footer, status-component-row-after, events-agenda-row-after, contact-topic-after and the other per-row slots) draws your markup once for every post, card or row, each with that row's facts. Write CSS for a repeating outlet with classes, never an id: the markup appears many times on one page.

Extensions in outlets

An installed extension can fill the same outlets as a theme component. It declares them in its manifest:

{
  "outlets": [
    { "name": "forum-post-after-body", "position": "after", "minHeight": 48 },
    { "name": "docs-sidebar-end" }
  ]
}
  • name must be an outlet from the registry below. Each outlet can be listed once.

  • position is before or after (default after). An extension can never replace the reader's own content, so a page still works when the extension's server is down.

  • minHeight (0 to 600 px, default 0) is the space the reader reserves before the extension answers, so the page does not jump when the content arrives.

While the extension is installed and enabled, every space of the organization shows it in those outlets. The surface rules still apply, so a forum outlet only appears on forum pages. The reader asks the extension through the signed proxy after the page loads. It sends a render request with block: "outlet:<name>", and context holds outlet plus the facts that outlet offers (topic.title, post.number, …), the same facts a component placeholder gets. The extension answers with declarative UI. Buttons in that UI send action requests the same way. If the extension does not answer, answers with an error, or answers with nothing, the slot is left empty and the reader sees no error. The console lists the outlets an extension fills on its card.

Extension pieces use the same wrapper ([data-fx-outlet]) as component pieces. Each one is in its own [data-extension-outlet='<slug>'] element, placed before the before component pieces or after the after ones. Turning the extension off or uninstalling it removes it from the reader on the next page view.

The registry

The fixed list, also served as JSON by GET /theme-outlets (no credential needed). A name here is never renamed or removed. Pages says which pages of the type mount the slot: index (the front door), item (one post, release, incident, role or document), subpage (an event's agenda, a status history page), hub (a tag, author or archive listing), category (a /c/<slug> collection or a forum category) and every (the chrome).

Outlet

Surface

Pages

Wraps

Offers

Where

header-before

all

every

Above the site header, full width, on every reader page

header-after

all

every

Directly below the site header, full width, on every reader page

above-main

all

every

The top of the main content area, inside the header and footer

below-main

all

every

The bottom of the main content area, above the footer

footer-before

all

every

Directly above the site footer

footer-after

all

every

Below the site footer — the very end of the page

front-page-top

all

index

A space's front page or a site's front door, above the document

front-page-bottom

all

index

A space's front page or a site's front door, below the document

forum-index-above-list

forum

index, category

Forum index and category views, above the topic list

forum-index-below-list

forum

index, category

Forum index and category views, below the topic list

forum-topic-row-after-title

forum

index, category

topic.id, topic.slug, topic.title

Each row of the forum topic list, after the title (both the table and the card list)

category-header

forum

category

yes

category.name, category.slug, category.id, category.color, category.description

Wraps the heading of a category view (its banner, logo and name)

forum-categories-above

forum

category

The forum’s /categories page, above the category layout

forum-sidebar-top

forum

index, item

The top of the forum side column (index and topic pages) — the column appears only when filled

forum-sidebar-bottom

forum

index, item

The bottom of the forum side column

forum-topic-above-title

forum

item

topic.title, topic.slug

A topic page, above the title

forum-topic-below-title

forum

item

topic.title, topic.slug

A topic page, below the title and its byline, above the first post

forum-topic-below-first-post

forum

item

topic.title, topic.slug

A topic page, between the first post (and its polls) and the replies

forum-post-after-body

forum

item

post.number, post.author, topic.title

After the body of every post in a topic

forum-post-author-after

forum

item

post.number, post.author, topic.title

After the author name in every post header

forum-topic-bottom

forum

item

topic.title, topic.slug

A topic page, after the last post

user-card

forum

hub

yes

user.name, user.username, user.website, user.location

Wraps the member card on a forum member page

blog-index-above-list

blog

index

The blog index, above the list of posts

blog-post-before

blog

item

post.title, post.slug

A blog post, above its body

blog-post-after

blog

item

post.title, post.slug

A blog post, after its body

blog-post-end

blog

item

post.title, post.slug

A blog post, after suggested posts and the piece page, before the comments; as wide as post.after.width

docs-page-header

docs

item

page.title

A documentation page, above the article

docs-page-footer

docs

item

page.title

A documentation page, after the article

docs-sidebar-start

docs

item

Top of the docs left rail, under the brand

docs-sidebar-end

docs

item

Foot of the docs left rail (sponsor walls, promos)

forum-hub-above-list

forum

hub

Forum hub pages (tags, a tag, members, badges, leaderboard, about, search), above the list

forum-hub-below-list

forum

hub

Forum hub pages, below the list

blog-index-below-list

blog

index

The blog index, below the list of posts and its pager

blog-card-footer

blog

index, hub

post.title, post.slug

The foot of every post card in a blog list (once per item)

blog-hub-above-list

blog

hub

Blog hub pages (archive, an author, a series, a tag, search), above the list

blog-hub-below-list

blog

hub

Blog hub pages, below the list

docs-rail

docs

item

page.title

A documentation page, the top of the right rail (above "On this page")

docs-rail-end

docs

item

page.title

A documentation page, the foot of the right rail

docs-hub-above-list

docs

category, hub

collection.title, collection.slug

A /c/<slug> collection hub (docs, FAQ, careers and custom spaces) or a docs tag page, above its list

docs-hub-below-list

docs

category, hub

collection.title, collection.slug

A /c/<slug> collection hub or a docs tag page, below its list

changelog-above-list

changelog

index

The changelog index, above the releases

roadmap-above-board

roadmap

index

The roadmap, above the board

status-above-board

status

index

The status page, above the board and its bands (once, whichever are placed)

faq-above-list

faq

index

The FAQ, above the questions

events-above-list

events

index

The events index, above the list of events

events-page-above-body

events

item

event.title, event.slug

An event page, between the details and the description

events-page-aside

events

item

event.title, event.slug

An event page, under the register card (beside the description when it stands at the side)

events-page-after-body

events

item

event.title, event.slug

An event page, after the description and the register card, before "More events"

events-card-footer

events

index

event.title, event.slug

The foot of every event card on the calendar (once per card)

courses-above-list

courses

index

The course index, above the lessons

courses-landing-hero-after

courses

index

The course landing, under the hero (title, cover and start button)

courses-lesson-above-body

courses

item

A lesson, between its title and its body

courses-lesson-after-body

courses

item

A lesson, under its body and completion controls

courses-certificate-footer

courses

subpage

A completion certificate page, under the certificate

careers-above-list

careers

index

The careers index, above the open roles

changelog-below-list

changelog

index

The changelog index, below the releases and the pager

changelog-entry-footer

changelog

index, hub

release.title, release.slug, release.version

The foot of every release entry in a changelog list (once per item)

changelog-release-after-head

changelog

item

release.title, release.slug, release.version

A release page, under its title, date and facts, above the notes

changelog-release-side

changelog

item

release.title, release.slug, release.version

A release page, in the side column (or under the head when there is none)

changelog-release-after-body

changelog

item

release.title, release.slug, release.version

A release page, after the notes, before the previous/next links

changelog-hub-above-list

changelog

hub

Changelog hub pages (a tag, people, a person), above the list

changelog-hub-below-list

changelog

hub

Changelog hub pages, below the list

roadmap-below-board

roadmap

index

The roadmap, below the board

roadmap-lane-head

roadmap

index

lane.name, lane.status

Under the heading of every lane (column) of the board (once per item)

roadmap-card-footer

roadmap

index

item.title, item.slug, item.status

The foot of every item card on the board (once per item)

roadmap-item-above-body

roadmap

item

item.title, item.slug, item.status

A roadmap item page, between its title and facts and its description

roadmap-item-aside

roadmap

item

item.title, item.slug, item.status

A roadmap item page, under the vote and status card

roadmap-item-after-body

roadmap

item

item.title, item.slug, item.status

A roadmap item page, after the description, before the discussion

status-below-board

status

index

The status page, below the monitors and incident history

status-component-row-after

status

index, subpage

component.name, component.slug, component.status

Under every component row on the board and on a component page (once per item)

status-subpage-top

status

subpage, item

subpage

Every status sub-page (history, uptime, maintenance, a component, an incident), under the back link

status-subpage-bottom

status

subpage, item

subpage

Every status sub-page, at the end of its content

status-incident-above-body

status

item

incident.title, incident.id, incident.kind, incident.status

An incident or maintenance page, under its title and impact, above the updates

status-incident-after-body

status

item

incident.title, incident.id, incident.kind, incident.status

An incident or maintenance page, after the last update

faq-below-list

faq

index

The FAQ, below the questions (above the contact card)

faq-item-after

faq

index

question.title, question.slug

Under the answer of every question in the FAQ list (once per item)

events-below-list

events

index

The events index, below the list (or calendar) of events

events-empty

events

index

yes

Wraps the events index's empty state (no upcoming events). replace draws your own

events-subpage-top

events

subpage

event.title, event.slug, subpage

An event's sub-pages (agenda, speakers, a speaker, a session), under the event header

events-subpage-bottom

events

subpage

event.title, event.slug, subpage

An event's sub-pages, at the end of their content

events-agenda-row-after

events

subpage

event.title, event.slug, session.title, session.slug

Under every session row of an event agenda (once per item)

events-speaker-card-footer

events

subpage

event.title, event.slug, speaker.name, speaker.slug

The foot of every speaker card on the speakers page (once per item)

events-session-after-body

events

subpage

event.title, event.slug

A session's page, after its description and stream link, before feedback and recordings

events-speaker-after-bio

events

subpage

event.title, event.slug

A speaker's page, after the bio, before their sessions

courses-below-list

courses

index

The course landing, below the lessons (the syllabus)

courses-lesson-rail-start

courses

item

A lesson, the top of the course rail (the lesson list beside it)

courses-lesson-rail-end

courses

item

A lesson, the foot of the course rail

courses-lock-card

courses

item

yes

Wraps the card a locked lesson shows (enrol or buy to continue). replace draws your own

courses-module-above-list

courses

hub

module.title, module.slug

A module page, above its lessons

courses-module-below-list

courses

hub

module.title, module.slug

A module page, below its lessons

courses-quiz-above

courses

subpage

module.title, module.slug

A module quiz, above the questions

courses-quiz-after

courses

subpage

module.title, module.slug

A module quiz, after the questions and the result

courses-checkout-above

courses

subpage

The course checkout page (a learner back from Stripe or PayPal, or redeeming a gift), above the confirmation. The plans themselves are picked on Stripe

courses-checkout-after

courses

subpage

The course checkout page, below the confirmation and the first-lesson link

courses-progress-top

courses

subpage

A learner's own course page (/me), above their progress (My learning)

careers-below-list

careers

index

The careers index, below the open roles

careers-row-after

careers

index, category

role.title, role.slug

Under every role row in a careers list (once per item)

careers-role-above-body

careers

item

role.title, role.slug

A role page, under its title and facts, above the description

careers-role-aside

careers

item

role.title, role.slug

A role page, in the side column beside the description

careers-role-after-body

careers

item

role.title, role.slug

A role page, after the description, above the application form

careers-apply-before

careers

item

role.title, role.slug

A role page, directly above the application form

careers-apply-after

careers

item

role.title, role.slug

A role page, directly below the application form

careers-apply-sent

careers

item

yes

role.title, role.slug

Wraps the "application sent" message. replace draws your own

legal-index-above-list

legal

index

An organization's legal register, above the list of documents

legal-index-below-list

legal

index

The legal register, below the list of documents

legal-doc-header

legal

item, subpage

document.title, document.kind, document.version

A legal document, in its header beside the title and dates

legal-doc-above-body

legal

item, subpage

document.title, document.kind, document.version

A legal document (and each of its past versions), under its title and dates, above the text

legal-doc-aside

legal

item, subpage

document.title, document.kind, document.version

A legal document, in the side column under the section navigation

legal-doc-after-body

legal

item, subpage

document.title, document.kind, document.version

A legal document, after the text, above the version history

contact-above-form

contact

index

The contact page, above the form (and its topic picker)

contact-below-form

contact

index

The contact page, below the form

contact-aside

contact

index

The contact page, in the side column beside the form (channels, hours, address)

contact-topic-after

contact

index, item

topic.label, topic.slug

Under every topic card of the topic picker, and above the form once a topic is chosen (once per item)

contact-sent

contact

index, item

yes

topic.label, topic.slug

Wraps the "message sent" confirmation. replace draws your own

Settings

type

Value

As var(--setting-x) in CSS

Extra keys

string

Text

A quoted CSS string (content: var(--setting-x))

maxLength

number

A number

The number (calc(var(--setting-x) * 1px))

min, max, step

boolean

true / false

1 / 0 (opacity: var(--setting-x))

enum

One of choices ([a-z0-9-])

The choice, as a bare word

choices (required)

color

A #hex colour

The colour

url

An https URL, an uploaded asset, or empty

url("…"), or none when empty

list

A list of short texts

One quoted string, items joined by ,

maxItems

Every setting may carry a label and a description for the console's form.

In HTML, {{settings.x}} prints the value HTML-escaped — a " in a setting cannot close the attribute it was put in. An enum is a safe word, so class="rb--{{settings.tone}}" is the way to make a choice change the look.

Where a value comes from, last one wins: the component's declared default → the value your organization set on the install → an override on the space or site that switches it on. A value that no longer fits (a component upgraded to a narrower range) falls back to the layer below rather than rendering.

Each component's settings are published under its own custom-property names, so two components that both call a setting color — or a component and the theme's own --setting-* — never read each other's.

Strings

strings is locale → key → text. {{strings.key}} prints the reader's language, then its base language (pt for pt-BR), then English, then whichever locale has the key.

What is sanitised, and scripts

  • HTML goes through the same sanitiser as the header's HTML panels and the footer's HTML slot: no inline <script>, <style>, form control or on… handler survives, on any host.

  • CSS is sanitised on the way in and scoped to the reader on the way out, so a component can restyle the reader and nothing outside it. It is emitted once per page, after the theme's CSS, so a component wins a tie with the theme.

  • An external <script src="https://…"> in a component's HTML is treated exactly like one in a header HTML panel: taken out of the markup, loaded only on your own domain, after consent, with the page nonce, and its origin added to the address's script policy. Only a component authored in your organization may carry one. A component that arrives in a theme bundle or from the marketplace is data, like a theme: its scripts are stripped and named, and the marketplace refuses a listing that has any.

Installing and switching on

Installing puts a component in your organization; nothing changes on any page until you switch it on for a space or a site. Switching on is a draft write, like applying a theme: preview it at the reader address with a preview token, then publish the draft. A site's components show on every space under it, before the space's own.

Call

What it does

GET /orgs/{orgId}/component-installs

Every installed component, its values, its scripts, and where it is switched on

POST /orgs/{orgId}/component-installs

Author or import one: { "component": {…file…}, "settings"?: {…}, "enabled"?: true }

GET /orgs/{orgId}/component-installs/{id}

One install

PATCH /orgs/{orgId}/component-installs/{id}

{ "component"?, "settings"?, "enabled"? } — enabled: false turns it off everywhere

DELETE /orgs/{orgId}/component-installs/{id}

Uninstall. A theme that still names it simply stops rendering it

GET /orgs/{orgId}/component-installs/{id}/package

The component file, your values as its defaults, scripts stripped and named

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

{ live, draft } — what readers see, and what the draft would change it to

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

{ "components": [{ "id": "…", "settings"?: {…} }] } in order, written to the draft

GET/PUT /orgs/{orgId}/sites/{siteId}/theme/components

The same for a site (its draft)

Installing and editing need themes.manage; switching components on for a space or site needs space.update there, because it changes what its readers see. The list lives in the theme as componentInstalls, which is why it drafts, previews and publishes with the rest of the look.

In theme bundles

A theme bundle can carry its components: format 2 adds a components array of whole component files (format 1 has it empty). Exporting a space or site whose theme switches components on puts them in the bundle, with that address's values as their defaults. Importing or installing a bundle installs its components in your organization (reusing one you already have from the same author) and switches them on in the draft after the ones you already wear — applying a theme never takes a component away.

The marketplace

Components have their own listings beside themes, reviewed the same way: a submission is visible to nobody until a platform administrator approves it, a revision of a live listing waits while the approved version keeps serving, and only a live listing can be featured. Components are free.

Call

What it does

GET /registry/theme-components

Browse without signing in: ?q=, ?tag=, ?outlet=, ?featured=true, ?sort=

GET /registry/theme-components/{slug}

One listing with its file

GET /registry/theme-components/{slug}/package

The component file

GET /orgs/{orgId}/component-marketplace

The same browse, marking what you have installed

POST /orgs/{orgId}/component-marketplace/components/{slug}/install

Install (or upgrade, keeping your values); { "spaceId" } / { "siteId" } also switches it on in that draft

GET/POST /orgs/{orgId}/component-marketplace/listings

Your listings / submit an install: { "installId": "…" }

DELETE /orgs/{orgId}/component-marketplace/listings/{slug}

Take one down. Organizations that installed it keep their copy

First-party components

Six, featured, free, and the worked examples for writing your own — none of them runs a line of script:

Slug

What it does

announcement-banner

A strip under the header with a message and a link; readers dismiss it (a native <details>)

topic-new-badge

Turns the topic list's "new" dot into a labelled badge and recolours the unread bubble

category-banners

A wide image banner above every category, with an overlay and the category's name

user-card-social-links

The member's website as a button under their card, then your community's social profiles

back-to-top

A floating "back to top" anchor that fades in as you scroll, where the browser supports it

sponsor-strip

Up to four sponsor logos at the top of the forum side column, hidden until one has a link

In the console

Themes → Components lists what you have installed — with an on/off switch, a settings form drawn from each component's own settings, where it is switched on, and edit, download, publish and uninstall — and lets you switch components on for a space or site in order, as a draft you preview and publish. New component opens an editor with a code area for the CSS and for the HTML of each outlet, the settings and strings, and a live preview. Components → Marketplace browses and installs listings.

Docs blocks (wave D)

Every key is optional; unset is today's look. All are theme.components.* and apply to every space type.

hint.variants — one look per callout style (info, success, warning, danger) or author's word (note, tip, caution, important, check; a word beats the style it folds onto):

Key

Meaning

background, backgroundDark

the wash

barColor, barColorDark

the leading bar and badge tone

edge

bar / hook / none, for this style only

ink, inkDark, titleInk, titleInkDark

body and title ink

icon

a glyph name drawn as this style's badge (turns the badge on for it)

size

body px, 12–20

A default title per style (label) is not offered: the renderer has no theme.

table — rules (grid today / rows / none), stripe (false turns the zebra off), density (compact), headerBackground(Dark), headerInk(Dark), size (11–18 px), cellPadding {x, y}, border(Dark).

stepper — marker (disc / number / outline / box-mono), line (true or a colour), color(Dark), zeroPad, rule, titleSize.

card — arrow: none / corner / end.

kbd — setting it at all (even {}) draws key caps: background(Dark), border(Dark), ink(Dark), radius, font (mono/body), shadow.

tabs — overflow: "scroll" keeps a long tab strip on one scrolling line for every group that did not choose its own.

expandable gains borderDark, backgroundDark, inkDark.

Round 4: block keys, and setting them per block

Every key below is optional; unset is today's look, except the rows marked fix, which change what an existing page shows. The theme keys are theme.components.* (every space type) and theme.docs.blocks.* (DOCS spaces); each has a block attribute of the same name, listed in Authoring › Round 4 block attributes, with its markdown spelling in Markdown in Forumulate › Round 4.

Theme, page or block: where a value comes from

A block's look is read, nearest first:

  1. the block — its own attribute ("border": "tile" on one cards block);

  2. the page — metadata.themePatch, a patch of theme keys for one page;

  3. the space theme — theme.components.*, theme.docs.*;

  4. the look — theme.docs.look / theme.forum.look is only a PATCH of these same keys (a look never draws anything a key cannot);

  5. the built-in default — today's block.

So two blocks on one page can differ, a page can differ from its space, and any key works under any look. A value (a size, a colour) is published as --x-theme on the reader root and --x-block on the block, and the stylesheet reads var(--x-block, var(--x-theme, today)); a word (a border style, a placement) is drawn by the theme only for blocks that did not choose their own. Which stylesheet loads first never decides the winner.

Scope

Where you set it

theme

PUT /spaces/:id/theme → components.*, docs.blocks.*

page

the page's metadata.themePatch (same shape as the theme)

block

the block's attrs (JSON, the editor, or markdown)

The new keys

Key (scope)

Shape

Default

Example

components.card.iconSize (theme, block)

px 12–64

the 1.35rem glyph (a 28px picture)

"iconSize": 24

components.card.tileSize (theme, block)

px 32–96

40

"tileSize": 48

components.card.titleWeight (theme, block)

400 500 600 700

600

"titleWeight": 500

components.card.titleSize (theme, block)

px 12–24

the body size

"titleSize": 15

components.card.descriptionSize (theme, block)

px 12–24

0.9em

"descriptionSize": 13

components.card.border / iconAlign / ctaPlacement / background(Dark) (theme, block)

as in round 3

today's card

now also attributes of a cards block

components.code.showLanguage (theme, block)

true / false / "auto"

unset: no chip

"showLanguage": "auto"

components.code.tabStyle (theme; codeGroup / tabs block)

underline / pill

underline

"tabStyle": "pill"

components.code.titleIcon (theme, block)

boolean

false

"titleIcon": true

components.code.titleFont (theme, block)

mono / ui

mono

"titleFont": "ui"

components.code.look (theme, block)

bordered / flat / inset

bordered

now also a code block's / group's attribute

components.table.wrap (theme, block)

auto / wrap / nowrap

auto

"wrap": "wrap"

components.table.more.background(Dark) (theme, block)

#hex

the outline / solid pill

"background": "#111111"

components.table.more.ink(Dark) (theme, block)

#hex

the pill's ink

"ink": "#ffffff"

components.table.more.font (theme, block)

ui / mono

ui

"font": "mono"

components.table.more.case (theme, block)

none / upper

none

"case": "upper"

components.tabs.active (theme, block)

{color?, colorDark?, underline?}

the brand ink and underline

"active": {"color": "#16a34a", "underline": false}

components.stepper.marker: "inline-number" (theme)

a new value of marker

disc

"marker": "inline-number"

components.stepper.badgePlacement (theme)

below / inline

below

"badgePlacement": "inline"

components.expandable.style (theme, block)

plain / row / card / bordered

plain (the DOCS layer may set bordered)

"style": "bordered"

docs.blocks.prompt.layout (theme, block)

inline / stacked

inline

"layout": "stacked"

docs.blocks.prompt.primary (theme, block)

copy / open

neither filled

"primary": "copy"

docs.blocks.prompt.copyStyle (theme, block)

button / icon

button

"copyStyle": "icon"

docs.blocks.prompt.phoneTargets (theme, block)

1–6

1, under compact

"phoneTargets": 2

What each one draws:

  • showLanguage — "auto" is the round-3 rule: a chip in the block's corner, but none inside a code group (its tab already names the sample) and none on a plain-text block. true draws it everywhere — TEXT on a plain block, and inside a group (in the sample's bottom corner, clear of the tab strip). A block with a title never draws one (fix): the filename already says what it is.

  • tabStyle: "pill" — the code group's tabs as grey pills in its header; code-only tabs draw as a code group, so they take it too. On a tabs block it also pills a plain tab strip.

  • titleIcon — a file glyph before a code block's (or group's) title; titleFont: "ui" sets that title in the interface face.

  • table.wrap — auto keeps headers on one line and words whole, the table scrolling in its frame; wrap lets headers wrap and drops the 6ch cell floor; nowrap keeps every cell on one line.

  • table.more — the collapsed table's "Show more": its fill, ink, face and case, beside round 3's style, align and fade.

  • tabs.active — the active tab's ink (the underline takes it too); underline: false keeps the ink and drops the underline.

  • stepper.marker: "inline-number" — the number before the step's title, at the title's size, muted, with no marker column, so the body runs flush. badgePlacement: "inline" puts a step's badges on its title's line.

  • prompt.phoneTargets — a compact prompt on a phone keeps Copy and this many "Open in …" links in its one row; the rest open from More (a keyboard opens it with Enter or Space; Escape closes it).

Fixes in this round (no key; every page): a one-line code block with no title puts its copy glyph inside the line, at its end, at every width; four described cards with no columns go four across only from a 1000px track (two by two below it); a wide table on a phone fades its end edge while it can still scroll, and a code chip cut by a line break keeps its corners; a picture icon in a border: "tile" card is tiled too, and iconAlign: "center" beside the icon centres the title on it.

Mix and match

None of these is a replica; each is a complete PUT /spaces/:id/theme body that validates as-is, built from the keys above and the ones before them.

A workbench docs site — tiled landing cards, inset code with pill tabs and a sans filename, hooked callouts, a black "SHOW MORE", green tabs with no underline, inline step numbers, bordered collapsibles:

{
  "components": {
    "card": {
      "border": "tile",
      "iconAlign": "center",
      "tileSize": 48,
      "iconSize": 22,
      "titleWeight": 500,
      "titleSize": 15,
      "descriptionSize": 13
    },
    "code": {
      "look": "inset",
      "tabStyle": "pill",
      "titleFont": "ui",
      "titleIcon": true,
      "showLanguage": "auto"
    },
    "hint": { "edge": "hook" },
    "table": {
      "wrap": "wrap",
      "more": {
        "style": "solid",
        "background": "#111111",
        "ink": "#ffffff",
        "font": "mono",
        "case": "upper"
      }
    },
    "tabs": { "active": { "color": "#16a34a", "colorDark": "#4ade80", "underline": false } },
    "stepper": { "marker": "inline-number", "badgePlacement": "inline", "titleSize": 18 },
    "expandable": { "style": "bordered" }
  }
}

A quiet reference under paper — filled borderless cards, a chip on every block, one-line table cells, compact prompts that keep two assistants on a phone with "Open in …" filled:

{
  "docs": {
    "look": "paper",
    "blocks": {
      "prompt": { "variant": "compact", "phoneTargets": 2, "copyStyle": "icon", "primary": "open" }
    }
  },
  "components": {
    "card": {
      "border": "none",
      "background": "#f4f4f5",
      "backgroundDark": "#18181b",
      "titleWeight": 600
    },
    "code": { "showLanguage": true, "titleFont": "mono" },
    "table": {
      "wrap": "nowrap",
      "rules": "rows",
      "more": { "align": "center", "fade": true, "font": "ui", "case": "none" }
    }
  }
}

The bold look, re-voiced — outlined cards with the icon at the start and the CTA at the row's end, flat code, badge callouts with a tight green tip, stacked prompts with a filled Copy:

{
  "docs": {
    "look": "bold",
    "blocks": { "prompt": { "variant": "card", "layout": "stacked", "primary": "copy" } }
  },
  "components": {
    "card": { "border": "card", "iconAlign": "start", "ctaPlacement": "end" },
    "code": { "look": "flat", "tabStyle": "underline", "showLanguage": false },
    "hint": { "icon": "badge", "variants": { "tip": { "barColor": "#16a34a", "titleGap": 4 } } }
  }
}

And on one page under any of them, two card grids that differ — the first takes the theme's card, the second names its own:

[
  {
    "type": "cards",
    "attrs": { "columns": 4 },
    "content": [
      { "type": "card", "attrs": { "title": "Database", "iconUrl": "https://example.com/db.svg" } }
    ]
  },
  {
    "type": "cards",
    "attrs": {
      "variant": "horizontal",
      "border": "card",
      "titleWeight": 600,
      "ctaPlacement": "end"
    },
    "content": [
      {
        "type": "card",
        "attrs": {
          "title": "Get started",
          "description": "Five minutes to a running app.",
          "cta": "Start"
        }
      }
    ]
  }
]

Round 5: block keys

The same three scopes as round 4 (block > page metadata.themePatch > space theme > look > today). Every key optional; unset is today's look, except the rows marked fix. "Layer" says whether the DOCS default layer sets it. The block attributes are listed in Authoring › Round 5 block attributes.

Key (scope)

Shape

Default

Layer

Example

components.card.minWidth (theme, block minWidth)

px 160–480

today's fixed column count

260

"minWidth": 260 — the grid shows min(columns, floor(width ÷ minWidth)) columns, so cards in a narrow panel drop to one column while full-width grids keep theirs. Pure CSS, nothing shifts

components.card.gap (theme; block gap, rowGap)

a space token (none xs sm md lg xl) or px, or {lg, md, sm} — the shape of a row of columns' gap

0.75rem

no

"gap": { "lg": 16, "sm": 12 }; on a block "rowGap": 12 sets the row pitch alone

components.card.divider (theme, block)

boolean

true (the hairline)

no

"divider": false — a panel card loses the rule between its icon band and its text

components.card.iconBand (theme, block)

px 0–120

the 16:9 band

no

"iconBand": 88 — the panel card's icon band at a fixed height

components.tabs.style (theme, block style)

underline / pill

underline

no

"style": "pill" — the strip as a bordered segmented group, the active segment filled from tabs.active.color (else the brand). A block's own style wins; a nested group keeps its own

components.table.code (theme)

chip / ink

chip

no

"code": "ink" — inline code in a cell drawn mono in the body ink, no fill and no padding; a column's codeInk still wins

components.preview (theme)

{border?, borderDark?, radius?: 0–32, background?, backgroundDark?}

today's preview surface

no

"preview": { "border": "#e5e7eb", "radius": 12, "background": "#ffffff", "backgroundDark": "#0b0f17" } — an HTML preview's surface, divider and attached-card frame (borderDark unset: the dark line); a block's preview.background(Dark) wins

codeBlock.preview.attach (block)

separate / code

separate

no

"preview": { "attach": "code" } — the preview and its source as ONE bordered card sharing the radius (components.preview.border / radius when set)

components.code.showLanguage: "always" (theme)

a new value

—

no

fix: a theme-wide true no longer draws the chip inside a code group (its tabs name the samples); always restores it there

components.apiRef.methodStyle: "text" (theme)

a new value

—

no

"methodStyle": "text" — the method as small tinted text, no fill, in the operation head and the sidebar's method badges

components.apiRef.urlSize (theme)

px 10–20

0.9rem (14.4px)

no

"urlSize": 12 — the operation's address (method + URL head), every urlStyle

components.apiRef.urlWeight (theme)

100–900

the code weight (500)

no

"urlWeight": 400 — the address's weight

components.apiRef.urlTone (theme)

ink / muted

ink

no

"urlTone": "muted" — the address in the muted grey. Example (a Vercel-style head): { "urlStyle": "plain" } under theme.docs.api with { "urlSize": 12, "urlWeight": 400, "urlTone": "muted" }

docs.blocks.prompt.openInDesktop (theme, block)

0–6

all

2

"openInDesktop": 2 — the "Open in …" links past this fold into a More menu from 641px

docs.blocks.prompt.icon (theme)

a glyph

none

no

"icon": "sparkles" — on every prompt that names none

docs.blocks.prompt.labelCase (theme, block)

upper / none

upper

no

"labelCase": "none"

docs.blocks.prompt.background(Dark) (theme, block)

#hex

today's surface

no

"background": "#f8fafc"

docs.blocks.prompt.collapseStyle (theme, block)

footer / pill

footer

no

"collapseStyle": "pill" — a floating "⌄ Show more" pill centred over the fade

Fixes in this round (no key): an inline code chip never splits open — each piece of a broken chip keeps its frame, and one of 24 characters or fewer never breaks; the code language chip never covers the first line; a header: "tabs" prompt alone in a variant: "card" tab panel drops its own border, radius and fill (one frame, not two).

Mix and match (round 5)

Not a replica — a complete PUT /spaces/:id/theme body:

A component gallery — dense segmented tabs, cards that fold to one column under 220px with a tight 12px row pitch and banded panels with no rule, flat ink code in tables, previews framed with their source, compact prompts that keep one assistant on the desktop and fold with a pill:

{
  "components": {
    "card": { "minWidth": 220, "gap": { "lg": "sm", "sm": 12 }, "divider": false, "iconBand": 72 },
    "tabs": { "style": "pill", "active": { "color": "#0f172a", "colorDark": "#e2e8f0" } },
    "table": { "code": "ink", "rules": "rows" },
    "preview": {
      "border": "#e2e8f0",
      "radius": 14,
      "background": "#ffffff",
      "backgroundDark": "#0f172a"
    },
    "code": { "showLanguage": "always" },
    "apiRef": { "methodStyle": "text" }
  },
  "docs": {
    "blocks": {
      "prompt": {
        "variant": "compact",
        "openInDesktop": 1,
        "collapseStyle": "pill",
        "labelCase": "none",
        "background": "#f8fafc",
        "backgroundDark": "#0b1220"
      }
    }
  }
}

And one page where two tab groups differ — the theme's underline, then one block's own pill:

[
  {
    "type": "tabs",
    "content": [{ "type": "tab", "attrs": { "title": "Overview" }, "content": [] }]
  },
  {
    "type": "tabs",
    "attrs": { "style": "pill" },
    "content": [
      { "type": "tab", "attrs": { "title": "Monthly" }, "content": [] },
      { "type": "tab", "attrs": { "title": "Yearly" }, "content": [] }
    ]
  }
]

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 Component Registry

Operation

Request

What it does

Shortcuts

PublicThemeComponentRegistry_browse

GET /registry/theme-components

List theme components

client.themes.publicComponentRegistry.browse()

PublicThemeComponentRegistry_detail

GET /registry/theme-components/{slug}

Get the theme component

client.themes.publicComponentRegistry.detail()

PublicThemeComponentRegistry_package

GET /registry/theme-components/{slug}/package

Get the package

client.themes.publicComponentRegistry.package()

Theme Component component installs

Operation

Request

What it does

Shortcuts

ThemeComponent_list

GET /orgs/{orgId}/component-installs

List component installs in an organization

client.themes.component.list()

ThemeComponent_create

POST /orgs/{orgId}/component-installs

Create a component install in an organization

client.themes.component.create()

ThemeComponent_get

GET /orgs/{orgId}/component-installs/{id}

Get the component install

client.themes.component.get()

ThemeComponent_update

PATCH /orgs/{orgId}/component-installs/{id}

Update the component install

client.themes.component.update()

ThemeComponent_remove_orgs_componentinstalls

DELETE /orgs/{orgId}/component-installs/{id}

Delete the component install

client.themes.component.removeOrgsComponentinstalls()

ThemeComponent_package

GET /orgs/{orgId}/component-installs/{id}/package

Get the package in an organization

client.themes.component.package()

Theme Component sites

Operation

Request

What it does

Shortcuts

ThemeComponent_siteComponents

GET /orgs/{orgId}/sites/{siteId}/theme/components

List components in an organization

client.themes.component.siteComponents()

ThemeComponent_setSiteComponents

PUT /orgs/{orgId}/sites/{siteId}/theme/components

Replace components in an organization

client.themes.component.setSiteComponents()

Theme Component theme

Operation

Request

What it does

Shortcuts

ThemeComponent_apply

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

Apply the theme

client.themes.component.apply()

ThemeComponent_spaceComponents

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

List components in a space

client.themes.component.spaceComponents()

ThemeComponent_setSpaceComponents

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

Replace components in a space

client.themes.component.setSpaceComponents()

Theme Component Market

Operation

Request

What it does

Shortcuts

ThemeComponentMarket_browse

GET /orgs/{orgId}/component-marketplace

Get the component marketplace in an organization

client.themes.componentMarket.browse()

ThemeComponentMarket_detail

GET /orgs/{orgId}/component-marketplace/components/{slug}

Get the component

client.themes.componentMarket.detail()

ThemeComponentMarket_install

POST /orgs/{orgId}/component-marketplace/components/{slug}/install

Install the component

client.themes.componentMarket.install()

ThemeComponentMarket_mine

GET /orgs/{orgId}/component-marketplace/listings

List listings in an organization (component marketplace)

client.themes.componentMarket.mine()

ThemeComponentMarket_submit

POST /orgs/{orgId}/component-marketplace/listings

Create a listing in an organization (component marketplace)

client.themes.componentMarket.submit()

ThemeComponentMarket_withdraw

DELETE /orgs/{orgId}/component-marketplace/listings/{slug}

Delete the listing (component marketplace)

client.themes.componentMarket.withdraw()

Theme Outlets

Operation

Request

What it does

Shortcuts

ThemeOutlets_list

GET /theme-outlets

List theme outlets

client.themes.outlets.list()

The Forumulate teamUpdated