In this section

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

  1. Copy a theme. Copy themes/static/basic to themes/static/my-theme. Starting from a working theme is much faster than starting from nothing.
  2. 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.
  3. 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.
  4. Load it. Admin → Themes → Rescan, then Activate on your theme.
  5. Restyle it. Change colors in tokens.json first (see Styling). Then edit css/theme.css.
  6. Edit the header and footer in render/partials/nav.html and footer.html. Every page picks up the change.
  7. Add a page. Copy render/pages/about.html to render/pages/team.html and edit it. It is served at /team.
  8. 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" } }
  }
}
FieldDoes
apiVersion2 means the render/, css/theme.css, scripts/ layout. Always use 2.
idMust equal the folder name.
tierstatic, templated, declarative, or handlebars. Missing means declarative. An unknown value stops the theme loading.
modes, defaultModeColor modes. defaultMode must be in modes, or the theme fails to load.
templatesPage 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.
slotsMaps partial ids to files. honorsCurrentPage turns on the current key. variants names variant files.
fontsGoogle 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

SymptomCauseFix
Colors and fonts missing on one pageThe stylesheet line was changedRestore <link rel="stylesheet" href="../css/theme.css" /> exactly
Theme doesn't appear in the listid doesn't match the folder name, or no RescanFix the id, click Rescan
A dropdown's child links vanishedThe menu marker lost "variant":"tree"Add it back
Nav collapsed into one lump after adding treeTree wraps links in <ul><li>Style .menu-list / .menu-item
A marker shows its fallback foreverWrong id, or the menu is emptyCheck the slug in Admin → Menus
A marker does nothing at allDouble quotes around the JSON, invalid JSON, no type, or self-closing tagUse data-embed-config='{"type":"…"}' with a closing tag
Copied theme shows the old logoHardcoded /theme-assets/<old-id>/ pathsReplace with your id
A template doesn't show in the pickerNot in templates, or no content markerAdd both
Theme fails to load after setting a modedefaultMode not in modesAdd 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.