This guide covers how to set up a new brand environment from scratch: the manifest, the theme tokens, the logos, and what the generator does with them.
On logins. A brand instance used to be something a user logged into — auth resolved their brand and the theme followed. Auth was removed in August 2026 and the site is public but unlisted, so a brand's pages are built with its theme at generation time instead. The per-user half of this guide is on hold rather than deleted; it returns with whatever gating comes back. See ROADMAP.md → Auth.
Scope. This walkthrough is for brand instances hosted inside BrandOS — auth-driven theming on the docs site. A standalone consumer site (like the studio site) doesn't use brand instances: it loads one theme.css of its own after the engine CSS. See Setup for that model.
A brand instance is two files plus assets:
| File | Holds | Owner |
|---|---|---|
cms/brands/<brand>/brand.json |
Admin settings: name, description, Google Fonts URL, footer text, optional favicon and OG image paths | Set at onboarding, rarely touched |
cms/brands/<brand>/assets/theme.css |
Designer settings: font slots and roles, palette, semantic colours, dark mode | Iterated during design |
The generator reads every manifest and builds assets/js/theme-config.js from them on each build. That file is generated output. Never edit it by hand.
By Default itself runs on the same model: instance 0's manifest and theme live at cms/brands/bydefault/, and the engine (design-system.css) ships neutral defaults.
What a Brand's Pages Carry
A brand's generated pages differ from the root site in these ways:
| Element | Root site | Brand pages |
|---|---|---|
| Logo | By Default | Brand logo from brand-name/assets/ |
| Home link | index.html |
brand-name/index.html |
| Theme | By Default (instance 0's theme.css) |
Brand theme.css |
| Fonts | Zalando Sans + ES Face (instance 0's theme.css) |
Per the brand's theme.css and brand.json font sources |
All of it is decided at build time from the brand's folder — the generator emits each brand's chrome into its own pages.
Quick Checklist
- Copy
cms/brands/brand-template/tocms/brands/your-brand/ - Edit
cms/brands/your-brand/brand.json: name, description, fonts URL, footer - Update
cms/brands/your-brand/_defaults.mdwithaccess: "brand:your-brand" - Replace the demo logos in
cms/brands/your-brand/assets/with the brand's - Customise
cms/brands/your-brand/assets/theme.css - Grant tool access: add
your-brandtoactionAccessin relevantcms/*.mdfiles - Run
cd cms/generator && npm run docgenand check the brand's pages
There is no separate registration step. A folder with a brand.json and an assets/theme.css is a live brand: the generator adds it to the runtime registry and the build.
Step 1: Create the Brand Folder
Copy the cms/brands/brand-template/ folder and rename it to the brand name. Use lowercase, hyphenated names.
cp -r cms/brands/brand-template/ cms/brands/your-brand/
The template ships dressed as a demo brand (Brand Template), so every file you copy is a working example to replace, not an empty scaffold. The folder contains:
cms/brands/your-brand/
brand.json : Admin settings (the manifest)
_defaults.md : Access control defaults
assets/
theme.css : Designer token overrides
logo.svg : Brand header logo (desktop)
logo-small.svg : Compact logo variant (mobile)
logos/ : Brand book logo set (light/dark variants)
welcome.md : Welcome page (optional)
brand-book.md : Brand book intro copy (optional)
On build, the generator copies assets/ to the output, generates the brand's pages, and builds the brand book from the logos/ folder. Nothing is hand-copied.
Set up access defaults
Update cms/brands/your-brand/_defaults.md:
---
access: "brand:your-brand"
---
This ensures all pages generated for this brand are scoped to their access level.
Step 2: Fill in the Manifest
Edit cms/brands/your-brand/brand.json:
{
"name": "Your Brand",
"description": "One line shown on the brand's home page.",
"googleFontsUrl": "https://fonts.googleapis.com/css2?family=YourFont:wght@400..700&display=swap",
"footerText": "© 2026 Your Brand"
}
| Key | Required | Description |
|---|---|---|
name |
Yes | Display name: the switcher label, page titles, and the brand home hero |
description |
No | Hero copy on the brand's index page |
googleFontsUrl |
No | Google Fonts URL, injected on brand pages and at runtime. Use null for self-hosted fonts |
typekitId |
No | Adobe Typekit kit ID (e.g. wgr3lwl). Emits the kit stylesheet link into the brand's page heads |
fontPreload |
No | Array of site-relative font file paths to preload (prevents FOUC on the primary face) |
footerText |
No | Brand footer. Falls back to the site footer when omitted |
faviconSvg / faviconIco |
No | Absolute site paths to a brand favicon. Fall back to the site set |
ogImage |
No | Absolute site path to the brand's share image. Falls back to the site default |
Font sources are per brand: a page head only carries the Typekit kit, preloads, and Google Fonts its own manifest declares. Self-hosted fonts pair a fontPreload entry with @font-face declarations in the brand's theme.css (see Step 4); the runtime preview switcher loads Google Fonts only, so Typekit-served faces show on the brand's own pages, not in a preview from another space.
The folder name is the brand key: it names the manifest, the theme, the logo path and the brand's output folder.
Note: Tool access is not configured here. It is controlled via actionAccess in cms/*.md frontmatter, which decides whether a tool's action button appears on a brand's pages.
Step 3: Add Brand Logos
Logo files live in the brand source folder with fixed names:
cms/brands/your-brand/assets/logo.svg : Full header logo (desktop)
cms/brands/your-brand/assets/logo-small.svg : Compact variant (mobile, future use)
cms/brands/your-brand/assets/logos/ : Brand book set, logo_brand-<type>-<light|dark>.svg
The generator copies these to your-brand/assets/ on build and builds the brand book page from the logos/ set.
Logo requirements
- Format: SVG
- Sizing: The container is 50px tall (desktop) / 45px tall (mobile). Width scales automatically. A wide aspect ratio (roughly 2:1) works best.
- Colour: Use fixed colours, or
currentColorif you want the logo to adapt to the theme's text colour and dark mode. - Cleanup: Run logos through the SVG Cleaner before adding them:
npx svg-cleaner --strip-comments --size -o cms/brands/your-brand/assets/logo.svg <<'SVGEOF'
<svg>...paste logo SVG here...</svg>
SVGEOF
How it works
When a brand logs in, theme-loader.js fetches the logo from your-brand/assets/logo.svg and injects it into the header. When an admin previews a theme, the logo swaps too. When the theme is unloaded, the By Default logo is restored.
Step 4: Customise the Theme
Open cms/brands/your-brand/assets/theme.css and update the token values. The copied file is the Brand Template demo theme, a complete working example of the override surface.
What to override
| Category | Tokens | Purpose |
|---|---|---|
| Typography | … , plus the roles , , , | Brand fonts. Add @font-face in the theme file if self-hosting |
| Neutral scale | through | The grey ramp behind surfaces and text |
| Text | , , , , | Core text colours |
| Backgrounds | , | Surface colours |
| Borders | , , | Border and input outline colours |
| Accent | , , , --selection-* |
Accent colour across UI |
Buttons are not a theme surface: they derive from --text-primary and --text-inverted at component scope and re-skin through those tokens.
Tokens you usually don't need to override
--text-faded,--background-faded,--border-faded: alpha-based, work across any theme--status-*-bg: in light mode, tints of the accent primitives; in dark mode, derived from the page ground pulled toward the status colour- Spacing, radius, type scale, breakpoints: engine rhythm, not brand expression
Dark mode
Theme the [data-theme="dark"] block, then mirror it exactly inside the @media (prefers-color-scheme: dark) fallback for users without JavaScript. Drift between the two blocks is a known failure mode; keep them identical.
The fallback must stay scoped to :root:not([data-theme]) — it applies only while no explicit choice is set. Any data-theme value, including "light", defeats it; that is what lets a visitor's stored light choice win over a dark OS. Toggles must always set an explicit "light" or "dark" value, never remove the attribute (see the Dark Mode section in the Colour doc).
How it works
Brand pages link the theme statically after the design system CSS; at runtime theme-loader.js injects the same file when a brand user logs in or an admin previews the brand. Later token values win the cascade, so the theme overrides the engine's neutral defaults and the whole site follows.
Step 5: Build and Check
cd cms/generator && npm run docgen
Open the brand's own pages and confirm: the logo is the brand's, the home link points at your-brand/index.html, the theme's colours and faces have taken, and dark mode holds.
The folder name is the brand key, and it is the single identifier tying everything together:
- Manifest:
cms/brands/your-brand/brand.json - Theme CSS:
your-brand/assets/theme.css - Logo:
your-brand/assets/logo.svg - Home link:
your-brand/index.html
Parked: per-user brand resolution
Until August 2026 a brand user logged in, and their brandFolder role tag decided which theme, logo and home link they got — plus a Theme Preview switcher for admins and a dev bar for testing roles locally. That machinery is dormant, not deleted: theme-loader.js still loads and unloads themes, but nothing calls it. If gating returns, this is the step that comes back with it. See ROADMAP.md → Auth.
Creating from Template: Full Walkthrough
Here is the complete workflow for adding a brand, start to finish:
# 1. Copy the brand source template
cp -r cms/brands/brand-template/ cms/brands/your-brand/
# 2. Clean and add logos
npx svg-cleaner --strip-comments --size \
-o cms/brands/your-brand/assets/logo.svg <<'SVGEOF'
<svg>...paste full logo...</svg>
SVGEOF
npx svg-cleaner --strip-comments --size \
-o cms/brands/your-brand/assets/logo-small.svg <<'SVGEOF'
<svg>...paste small logo...</svg>
SVGEOF
Then manually:
- Edit
cms/brands/your-brand/brand.json: name, description, font sources, footer - Edit
cms/brands/your-brand/assets/theme.css: replace the demo values with the brand's fonts and colours - Replace the demo SVGs in
cms/brands/your-brand/assets/logos/with the brand's light/dark set - Update
cms/brands/your-brand/_defaults.md:--- access: "brand:your-brand" --- - Edit
cms/brands/your-brand/welcome.mdandbrand-book.mdif needed - Grant tool access by adding the brand to
actionAccessin the relevant tool markdown, e.g. incms/display-ad-preview-docs.mdchangeactionAccess: "brand:brand-template"toactionAccess: "brand:brand-template,your-brand" - Run
cd cms/generator && npm run docgen - Open the brand's pages and check the logo, home link, theme and dark mode
Architecture Reference
| File | Purpose |
|---|---|
cms/brands/<brand>/brand.json |
The manifest: admin settings. With assets/theme.css alongside it, the brand is live |
cms/brands/<brand>/assets/theme.css |
Designer token overrides |
assets/js/theme-config.js |
GENERATED runtime registry, built from the manifests on every docgen. Never hand-edit |
assets/js/theme-loader.js |
Loads/unloads theme CSS, fonts, logo, and home link dynamically. Dormant — nothing calls it since auth was removed |
scripts/theme-regression/check.py |
Instance-0 token regression check: run after editing the engine's tokens or By Default's theme |
Instance 0 is the exception that proves the model: cms/brands/bydefault/ holds By Default's manifest and theme, and the root site loads that theme as a static link on every page. By Default gets its identity the same way a client brand does, which is what keeps the engine neutral.
Data flow
Build
→ generator reads every cms/brands/*/brand.json
→ brands with assets/theme.css enter the runtime registry
→ theme-config.js is emitted in one write
→ brand pages are generated with the brand's chrome
· theme.css linked last, after design-system.css and docs-site.css
· logo, home link, title, footer, favicon and OG image from the manifest
There is no second, runtime half to this flow today. Everything a brand's pages
carry is decided here, at build time.