The .xttheme package format
This page describes the file format in detail. For how themes, tokens and layers behave
on a running site (installing vs switching, the theme and admin layers, the stylesheet we
serve), read style-system.md first.
A theme is data and a plugin is code; plugin-blocks.md covers
the plugin side. Nothing in a .xttheme runs, which is why installing one doesn’t need a
trust review: we validate it against a schema, and we skip unknown blocks instead of
running anything.
A package is a zip. Name it .xttheme, although nothing checks the extension.
manifest.json requiredtokens.json all design tokens, by scope (the complete copy)styles/tokens.css the global tokens as :root custom propertiesstyles/custom.css the theme's own stylesheet (optional)layouts/<template>.json one document per templateassets/ images and fonts the documents useOne look, one palette (package version 2)
Section titled “One look, one palette (package version 2)”A site has one look, and a theme is that look. There’s no light/dark toggle and no
prefers-color-scheme anywhere in how a site renders. You decide whether you’re building
a dark theme or a light one, say so in the manifest, and the site wears it. A reader who
wants the other kind picks a different theme from the footer.
So the paired files are gone. tokens.json is one scope map instead of
{ light, dark }, and each stylesheet is a single file:
| Before (package version 1) | Now |
|---|---|
tokens.json → { light, dark } |
tokens.json → the scope map itself |
styles/light.css + styles/dark.css |
styles/tokens.css |
styles/custom-light.css + styles/custom-dark.css |
styles/custom.css |
A version 1 package still installs. The importer reads all the old names and keeps
the light half, because :root was always the light palette and it’s what visitors
saw unless their OS asked for dark. We don’t drop the dark half silently: the import
result lists it under ignoredFiles, and the ACP shows that. Nothing writes the old names
anymore.
.xtstyle is the same format
Section titled “.xtstyle is the same format”A .xtstyle used to be a manifest plus a :root-only stylesheet: a theme without
layouts/ or assets/. They were never really two formats, so since 2026-08-15 they’re
one. The theme importer takes a .xtstyle as it is, and the separate upload endpoint,
its own validator and its ACP page are gone. Two ways to import untrusted CSS was one too
many.
The only real difference was the manifest. A theme manifest needs id, vendor,
version and minXentiumVersion; a style pack only had name. We fill those in on
import (the id from the name, vendor as community), but only for a package that’s
clearly a style pack: no layouts/, no tokens.json, and a stylesheet present. A real
theme with a broken manifest still gets a proper error instead of a made-up id.
To make one, export with Colours only (tokensOnly). That leaves out layouts/, and
with it assets/, since export only collects assets that layouts reference. You get a
.xttheme that changes nothing but the palette.
manifest.json
Section titled “manifest.json”{ "id": "midnight", // kebab-case; also the folder its assets unpack into "name": "Midnight", "version": "1.2.0", // semver "vendor": "Someone", "minXentiumVersion": "1.0", // refused on older versions "maxXentiumVersion": "2.0", // optional "description": "…", // optional, up to 500 characters
"schemaVersion": 1, // the layout schema you wrote against "packageVersion": 2, // this package format; 2 = one look
// Whether you built a DARK theme or a LIGHT one. It describes the theme; it's not // a mode the theme offers. Defaults to "light" if you leave it out. "appearance": "dark",
// For a marketplace listing. "license": "MIT", // optional "homepage": "https://…", // optional "authorUrl": "https://…", // optional "screenshot": "assets/preview.png" // optional, relative to the package}schemaVersion is informational, not enforced. A document written for a newer block
catalogue still imports; blocks this install can’t render are reported and left in
place.
appearance is something you declare; we never guess it. Measuring how bright
--c-bg is works until the palette is mid-grey, sepia or duotone, and a theme that only
sets its background in styles/custom.css has no background token at all. The
marketplace shows it on the listing card, and a wrong badge is worse than none, so a
package that leaves it out gets no badge.
Tokens
Section titled “Tokens”tokens.json is the file to write. It’s the full set of tokens by scope, and it’s one
map because there’s one look:
{ "global": { "--c-accent": "#5865f2" }, "chrome.header": { "--c-bg": "#111827" } // changed for one region only}styles/tokens.css can only hold global, so it’s mostly there for .xtstyle
compatibility. When both exist, tokens.json wins. Token names have to look like
custom properties, and values can’t contain { } < > ;, since we write them into the
stylesheet we serve and a stray brace would close the block.
styles/custom.css is for anything tokens can’t express. It isn’t limited to :root;
the site scopes it when it’s saved, exactly like the ACP’s own custom CSS. Like the rest
of a theme, it applies live (see Importing).
Layouts
Section titled “Layouts”One file per template, named after the template key:
layouts/home.json article.index article.detail page.detail profile.json members.json chrome.header.json chrome.footer.jsonEach file is a layout document, { templateKey, schemaVersion, regions: { … } }, where
each region is a tree of blocks. Ship every template. A package that only carries the
ones you changed quietly inherits whatever the target site had for the rest.
We don’t export per-page layout variants, on purpose. They’re keyed by a page slug, and slugs are a site’s content, not a theme’s. On another site they’d either match nothing or take over a page with the same name that you never saw.
“Variant” means two different things here. A layout variant is one instance of a template (the layout for one specific page) and it’s a row in
xcf_layouts. A style variant (below) is a named look a block opts into. They never show up in the same place: the first selects a layout, the second is a field on a block.
Style variants
Section titled “Style variants”A block can name a variant (surface, muted, accent, plain), and your theme
says what that variant looks like. Both halves are in the package:
- the layout document puts
"variant": "accent"on the block; tokens.jsonhas avariant.accentscope, which the site emits as:root .xt-v--accent.
// layouts/home.json: the block picks a look{ "id": "hero", "type": "section", "props": {}, "variant": "accent" }
// tokens.json: the theme says what that look is{ "variant.accent": { "--surface-bg": "#efe7ff", "--surface-border": "#d9c9ff" } }We deliberately don’t do per-block colours. A variant keeps every block that uses it in
sync and survives a palette change, because a variant’s default is written in palette
tokens (--surface-bg: var(--c-card)). It follows whatever site it lands on until a theme
overrides it.
Three things to know when you write one:
sectionis the block that paints. It reads--surface-bg,--surface-border,--surface-border-width,--surface-radiusand--surface-shadow, and none of those has a global value, so an unstyled section paints nothing. Card-style blocks (the profile card, a plugin panel) follow the same colours, because every variant points--card-bgand friends at the--surface-*values.- Variants cascade. They’re custom properties on a
display: contentswrapper, so a card inside anaccentsection is accented too. Useplainto reset. - An unknown variant is refused on import, unlike an unknown block. There’s no class for it and no way to render the look it was meant to have, so it fails validation instead of silently doing nothing.
CSS handles: className and linked images
Section titled “CSS handles: className and linked images”A variant picks one of the named looks. It can’t say “this one tile”. For that, a block
can have an optional className: your own handle that custom CSS can select.
// layouts/chrome.header.json: a navbar made of images{ "id": "t1", "type": "image", "className": "nav-tile", "props": { "source": "assets/forum.png", "ratio": "auto", "href": "/forum" } }.nav-tile > * { width: 120px; }.nav-tile img { border-radius: 8px; transition: filter .15s; }.nav-tile:hover img { filter: brightness(1.15); }Three things shape how you write that CSS:
- Style through the handle, not on it. It sits on the same
display: contentswrapper as the variant, so it has no box:.nav-tile { background: … }paints nothing, but.nav-tile > *,.nav-tile imgand.nav-tile .xt-imageall work. That’s the price of the handle being safe on every block, including plugin blocks that never opted in, and of never breaking the flex and grid containers that size the real element. Descendant and:hoverselectors work normally, because:hovermatches an ancestor of the hovered element whether or not that ancestor has a box. xt-is reserved. A handle is 1 to 4 CSS identifiers (letters, digits,-,_) and can’t start withxt-. That prefix is core’s own set of classes, and a document that could create one could pretend to be a core class or give itself a variant.image.hrefturns artwork into a link, which is what an image navbar needs.navLinksrenders text, so before this there was no way to say “this tile goes to /forum”. Only relative paths andhttp,httpsandmailtoare allowed; ajavascript:ordata:URL fails validation, so it can’t come in through a package either. There’s nohoverSourceon purpose: swapping the image on hover is CSS the handle can already do, and every block prop is forever.
Assets
Section titled “Assets”An asset path in a document points into the site’s uploads, which means nothing on
another machine. So export copies the files into the package and rewrites each path
to assets/<file>. Import unpacks them to uploads/themes/<manifest id>/ and rewrites the
paths again.
- Images and fonts only, checked by their magic bytes, not their extension. SVG is the
exception (it’s XML), so we serve
/uploads/themes/withnosniffand a sandboxing CSP: an SVG can’t run anything even if you open it directly. - File names are reduced to their base name, so an entry can’t escape its folder.
- Limits: 100 files, 5 MB each, 40 MB in total.
- Absolute
http(s)URLs are left alone, so a theme can point at a CDN.
Importing
Section titled “Importing”- Installing isn’t switching. A new theme goes into the library inactive, and the
admin is asked whether to switch to it. Only an update to the theme the site is already
wearing is applied on import. See
style-system.md→ Themes on a site. - Switching is live: layouts, tokens and the stylesheet together. Putting on a theme is one decision, not a batch of edits waiting to be published; draft and publish are for editing. We write the draft too, so pending Style Editor edits preview against the theme that’s actually on the site.
- The way back is the undo point:
POST /admin/themes/discardrestores exactly what the last import or switch replaced. It only goes back one step: a second import overwrites it, and after that the only way back to the earlier state is to activate the old theme from the library. - A switch replaces the theme layer; it doesn’t merge. A theme without tokens or
without
styles/custom.cssclears the previous theme’s, so its fonts and rules don’t keep painting a site that has moved on. (Layouts are different on purpose: a template the new theme doesn’t ship keeps what the site had.) - All or nothing: we validate every layout, token, stylesheet and asset before we write a single byte, so one bad file leaves the site untouched.
- Unknown blocks are kept. We report them to the admin, the renderer skips them, and they come back as soon as the plugin that provides them is enabled.
Inheritance: updating a theme without losing your work
Section titled “Inheritance: updating a theme without losing your work”A theme is a layer under the admin’s own changes, not something that overwrites them. That’s the difference between taking an update and having to choose between the update and your customizations, which is the choice XenForo and WoltLab leave you with.
Tokens inherit one by one. An import writes the theme layer, and the admin’s edits
live in their own layer on top. We serve theme + admin, so if you recoloured one accent
and the theme’s version 1.1 changes twenty tokens, you get the other nineteen and keep
yours. In the Style Editor, a token you haven’t touched shows the theme’s value as
Inherited, and Reset on a token you changed goes back to the theme’s value, not to
stock Xentium. Reset appearance clears your overrides and leaves the theme installed.
Layouts inherit per template. You can’t sensibly merge block trees (a three-way merge of moved blocks produces layouts nobody designed), so the unit is the whole template, and the rule is simple:
| Template | On import |
|---|---|
| Nothing stored, or the same as its baseline | Updated to the theme’s version |
| Changed from its baseline (you edited it) | Kept, and listed in the result |
The baseline is what the theme shipped last time, or the built-in default if no theme was ever imported. So a layout you built by hand before installing your first theme also counts as customized and is kept.
Every import records the new baseline either way, so:
- the next update compares against the version you actually received, and
- Revert to theme (per template, in the Layout Editor) can give you the theme’s
current version of a page you’d customized. It lands in the draft, like any other layout
change.
Resetis separate and still means the built-in default.
The import result lists all of it: templates (applied), kept (yours, left alone) and
replaced (the theme that was installed before).
The stylesheet is a layer too. styles/custom.css goes into the theme’s own layer and
is emitted before the admin’s own CSS, so when two rules have equal specificity, the
admin still wins. That’s the same relationship their tokens have with the theme’s.
Reset appearance clears the admin’s stylesheet and leaves the theme’s.
Until 2026-08-29, an import wrote into the admin’s own custom CSS, so installing a theme wiped whatever CSS the admin had written, live, with nothing to restore it from.
The class contract
Section titled “The class contract”A theme is CSS plus data. It can’t register a block or change a component, so all of its design control goes through class names. We promise three things, and they’re not equally strong, so it’s worth knowing which is which.
1. xt-b--<type> on every block. It’s always there, on every instance, for core
blocks, plugin blocks and blocks we add later:
.xt-b--boardStats .stat-value { font-family: "Bebas Neue"; }.xt-b--profileCard { --card-radius: 0; }This is the hook a downloaded theme relies on. className is per instance and only an
admin can set it, so a theme that doesn’t control the site’s layouts had nothing to aim
at, and about fifty blocks render into their parent’s markup without a class of their
own. A plugin’s dotted type is flattened for the selector: forum.recentThreads becomes
.xt-b--forum-recentThreads.
2. Root classes on the blocks that have their own root element:
| Block | Root class | Block | Root class | |
|---|---|---|---|---|
section |
.xt-section |
image |
.xt-image |
|
columns |
.xt-columns |
guestHero |
.ghero |
|
column |
.xt-column |
guestBody |
.gbody |
|
spacer |
.xt-spacer |
profileCard |
.profile-card |
|
heading |
.xt-heading |
richText |
.xt-richtext |
3. Everything else is how it works today, not a promise. The classes inside a block
(.ghero__title, .mp-list, .ac__excerpt) are real and stable in practice, but no test
guards them yet, and the prefixes are inconsistent because several blocks were split out
of existing pages and kept those pages’ names. Reach them through xt-b--<type> or your
own handle, so a rename can’t ripple through your whole stylesheet.
Never use
>,+or~against a block’s root class from outside that block. Every block renders inside adisplay: contentswrapper. It has no box, so it can’t disturb a flex or grid parent, but selectors match the DOM, not the boxes, so a combinator across a block boundary silently matches nothing. Use a descendant selector. (We shipped.xt-columns > .xt-columnin core once. It stopped applying to any column an admin had styled, and on phones a sticky rail stayed pinned over the content it should have stacked under.)
What a theme’s stylesheet may do
Section titled “What a theme’s stylesheet may do”Your own custom CSS is written by you, so we only check the syntax. A stylesheet inside a package is a stranger’s code on every page of someone else’s site, so it follows two extra rules, both enforced on import before we write anything:
- No
@import. It loads a stylesheet from another server on every page view. That’s a live dependency on someone else’s uptime, and a request that carries your visitors’ IP addresses. Put the rules in the file. - No remote
url(). Same reasons, plus a worse one: combined with an attribute selector (input[value^="a"] { background: url(…) }), a remote URL can leak what’s on the page, one character at a time. Put the file inassets/and reference it asassets/<file>.
What is allowed: assets/… references (we rewrite them on import to wherever the files
ended up, which is the only reason an image in a theme’s stylesheet works at all),
data: URLs, and absolute paths on the same site like /uploads/branding/logo.png, which
can’t reach anywhere else. A reference to a file that isn’t in the package is refused by
name.
Export does the reverse: it copies the files your stylesheet references into the package
and makes the paths package-relative, so a .xttheme exported from a site that’s running
a theme imports cleanly somewhere else.
This does not make a stranger’s CSS harmless. CSS can still put a fixed-position element over a button, or restyle a control into something it isn’t. The protections against that are the preview before you install (the marketplace renders a theme from its package) and the undo point afterwards.
Building one
Section titled “Building one”The Layout Editor (ACP → Appearance → Layout Editor → Theme package) exports what’s
currently published, assets included, and imports packages. There’s no separate CLI:
a package is a zip, so you can also write one by hand in a text editor and pack it with
zip -r.
If you build one by hand, start your layouts from the built-in defaults (DEFAULT_LAYOUTS
in @xentium/contracts) and run them through layoutDocumentSchema and
validateBlockTree before you zip, so you find mistakes before the importer does. That’s
how we built our Redline theme.