Create a theme
Copy a theme, rename it, restyle it. This page walks through it, then covers theme.json, styling, publishing, and common mistakes.
Step by step
- Copy a theme. Copy
themes/static/basictothemes/static/my-theme. Starting from a working theme is much faster than starting from nothing. - Rename it. In
my-theme/theme.json, set"id": "my-theme"and a new"name". The id must equal the folder name or the theme won't load. - Fix the asset paths. Search the copy for
/theme-assets/basic/and replace it with/theme-assets/my-theme/. Otherwise your copy keeps loading the original's logo and icons. - Load it. Admin → Themes → Rescan, then Activate on your theme.
- Restyle it. Change colors in
tokens.jsonfirst (see Styling). Then editcss/theme.css. - Edit the header and footer in
render/partials/nav.htmlandfooter.html. Every page picks up the change. - Add a page. Copy
render/pages/about.htmltorender/pages/team.htmland edit it. It is served at/team. - Check it. If you run Tovu from source:
tovu theme validate themes/static/my-theme.
Every page file must keep this exact stylesheet line in its <head>. Tovu injects your tokens right before it, and matches it character for character:
<link rel="stylesheet" href="../css/theme.css" />
A minimal page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{title}}</title>
<link rel="stylesheet" href="../css/theme.css" />
</head>
<body>
<div data-embed-config='{"type":"partial","id":"nav","current":"team"}'></div>
<main>
<h1>Our team</h1>
</main>
<div data-embed-config='{"type":"partial","id":"footer"}'></div>
<script src="../scripts/main.js"></script>
</body>
</html>
{{title}} is replaced with the page's own title. It is the only placeholder of its kind.
theme.json
The fields that do something:
{
"apiVersion": 2,
"id": "my-theme",
"name": "My Theme",
"version": "0.1.0",
"tier": "static",
"description": "One sentence about the theme.",
"modes": ["dark", "light"],
"defaultMode": "dark",
"templates": ["posts-default.html", "posts-sidebar.html", "pages-default.html"],
"slots": {
"nav": { "source": "nav.html", "honorsCurrentPage": true },
"footer": { "source": "footer.html", "variants": { "minimal": "footer-minimal.html" } }
}
}
| Field | Does |
|---|---|
apiVersion | 2 means the render/, css/theme.css, scripts/ layout. Always use 2. |
id | Must equal the folder name. |
tier | static, templated, declarative, or handlebars. Missing means declarative. An unknown value stops the theme loading. |
modes, defaultMode | Color modes. defaultMode must be in modes, or the theme fails to load. |
templates | Page files your pages and posts can choose in the editor's template picker. The first is the default. Each must exist and contain a content marker. |
slots | Maps partial ids to files. honorsCurrentPage turns on the current key. variants names variant files. |
fonts | Google Fonts specs, loaded for you. |
pages appears in the built-in themes but does nothing. Tovu finds pages by scanning render/pages/.
Templates vs pages. A page file not listed in templates is served at its own URL (pricing.html → /pricing). A file listed in templates is a layout your content picks. In the page editor, the template picker lists them, and View Template opens the file.
Styling
Tokens first. tokens.json is a flat list of CSS variables. Tovu writes them onto :root in every page:
{ "--bg": "#020203", "--fg": "#f4f4f5", "--accent": "#f5b83d" }
Use them in CSS, with a fallback:
body { background: var(--bg, #020203); color: var(--fg, #f4f4f5); }
Change a token and every rule that uses it follows.
Light and dark. tokens.light.json holds only the values that differ in light mode. They apply under :root[data-theme="light"]. Tovu puts data-theme="<defaultMode>" on <html>. A toggle button flips it:
<button data-theme-toggle>Toggle</button>
<script>
document.querySelector('[data-theme-toggle]').addEventListener('click', () => {
const el = document.documentElement;
el.dataset.theme = el.dataset.theme === 'light' ? 'dark' : 'light';
});
</script>
Paths. In a page file, ../css/… and ../scripts/… are rewritten to the theme's public URL. For images and fonts, use the full path: /theme-assets/my-theme/assets/logo.png. Links to other page files, like href="about.html", become /about.
Publishing
To your live site. Theme edits are part of your site. The app's Publish sends changed theme files along with pages and menus. Nothing reaches the live site until you publish.
Sharing a theme with others. Run tovu theme validate <folder> --profile publish. It requires a license, a description, and assets/previews/card.webp. Credit bundled fonts and libraries in NOTICE.md.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| Colors and fonts missing on one page | The stylesheet line was changed | Restore <link rel="stylesheet" href="../css/theme.css" /> exactly |
| Theme doesn't appear in the list | id doesn't match the folder name, or no Rescan | Fix the id, click Rescan |
| A dropdown's child links vanished | The menu marker lost "variant":"tree" | Add it back |
| Nav collapsed into one lump after adding tree | Tree wraps links in <ul><li> | Style .menu-list / .menu-item |
| A marker shows its fallback forever | Wrong id, or the menu is empty | Check the slug in Admin → Menus |
| A marker does nothing at all | Double quotes around the JSON, invalid JSON, no type, or self-closing tag | Use data-embed-config='{"type":"…"}' with a closing tag |
| Copied theme shows the old logo | Hardcoded /theme-assets/<old-id>/ paths | Replace with your id |
| A template doesn't show in the picker | Not in templates, or no content marker | Add both |
| Theme fails to load after setting a mode | defaultMode not in modes | Add it to modes |
| Page title shows the literal placeholder | {{title}} used somewhere other than <title> | Only use it in <title> |
See also: How themes work and Theme markers.