Migration
Keep themes current across versions
Themvora ships a built-in migration engine that walks registered steps to bring any theme definition up to date — from simple token renames to full structural rewrites.
1When to migrate
- Version bumps — every
ThemeDefinitioncarries ameta.version. When you ship a new schema, bump the version so the migration engine knows which steps to apply. - Breaking changes — renamed or removed tokens, restructured nesting, or changed semantics all need a migration step to keep existing themes working.
- API evolution — as Themvora grows, new token conventions emerge. A migration step lets you adopt them without manually editing every theme file.
import { migrateTheme } from "@themvora/core";
// A theme authored for an older version
const legacyTheme = {
meta: { version: "0.1" },
tokens: {
colors: {
primary: "#6b21a8",
bg: "#ffffff",
},
},
};
// Migrate to the current version (or a specific target)
const migrated = migrateTheme(legacyTheme);
// migrated.meta.version === "0.2"2Schema versions & breaking changes
A theme declares the schema it was authored for in meta.version. The table below is the authoritative record of every breaking schema change and the target version it lands in. If your theme is several versions behind, the engine walks every intermediate hop.
| From → To | Breaking change | Auto-migratable | Notes |
|---|---|---|---|
0.1 → 0.2 | `bg` token renamed to `background` | Yes | Pure rename — handled by remapColors. |
0.2 → 0.3 | Nested color tokens flattened into a semantic palette | Yes | Structural — needs a custom migrate function. |
0.3 → 0.4 | `text` token renamed to `foreground`; `muted` derived token added | Yes | Rename plus a derived default. |
1.0 → 1.3 | None — all releases since 1.0.0 are backward compatible | N/A | No migration step required. |
3migrateTheme(theme, options)
Pass a theme and an optional targetVersion. The function finds every registered migration from the theme's current version toward the target, applies them in order, and updates meta.version on the result.
targetVersion— version to migrate toward (defaults to the latest)
- A new
ThemeDefinitionwith updated tokens and version - If already at the target, the original object is returned
4registerMigration({ from, to, remapColors?, migrate? })
Each call registers a single hop — from one version to the next. The engine walks hops in order when migrateTheme is called. Register steps at module load so they are available everywhere.
import { registerMigration } from "@themvora/core";
// Simple token rename: "bg" was renamed to "background" in v2
registerMigration({
from: "0.1",
to: "0.2",
description: "Rename 'bg' token to 'background'",
remapColors: [
{ from: "bg", to: "background" },
{ from: "surface", to: "surface" },
],
});
// With a custom migrate function for structural changes
registerMigration({
from: "0.2",
to: "0.3",
description: "Restructure nested color tokens into a flat palette",
migrate: (theme) => ({
...theme,
tokens: {
...theme.tokens,
colors: {
background: theme.tokens?.colors?.background,
foreground: theme.tokens?.colors?.foreground,
primary: theme.tokens?.colors?.primary,
},
},
}),
});5Multi-step migration
Register migrations for each consecutive version pair. When migrateTheme runs, it builds a chain from the theme's current version to the target and applies each step in sequence.
import { registerMigration, migrateTheme } from "@themvora/core";
// Register a chain of migrations
registerMigration({
from: "0.1",
to: "0.2",
remapColors: [{ from: "bg", to: "background" }],
});
registerMigration({
from: "0.2",
to: "0.3",
remapColors: [{ from: "text", to: "foreground" }],
});
registerMigration({
from: "0.3",
to: "0.4",
migrate: (theme) => ({
...theme,
tokens: {
...theme.tokens,
colors: {
...theme.tokens?.colors,
muted: "var(--theme-color-foreground, 50%)",
},
},
}),
});
// migrateTheme walks the chain automatically:
// v0.1 → v0.2 → v0.3 → v0.4
const theme = {
meta: { version: "0.1" },
tokens: { colors: { bg: "#fff", text: "#000" } },
};
const result = migrateTheme(theme);
// result.meta.version === "0.4"
// result.tokens.colors.background === "#fff"
// result.tokens.colors.foreground === "#000"6remapColors vs migrate
- Token is renamed, not restructured
- One-to-one key mapping
- Declarative and safe
- No access to other tokens
- Structure changes (nesting, splitting, merging)
- You need to read other tokens
- You need conditional logic
- Full access to the ThemeDefinition
// remapColors: declarative, safe, automatic
// Use when a token is just renamed
registerMigration({
from: "0.1",
to: "0.2",
remapColors: [
{ from: "bg", to: "background" }, // simple rename
{ from: "border", to: "borderColor" }, // simple rename
],
});
// migrate: imperative, full control
// Use when the structure changes or you need logic
registerMigration({
from: "0.2",
to: "0.3",
migrate: (theme) => {
const old = theme.tokens?.colors ?? {};
return {
...theme,
tokens: {
...theme.tokens,
colors: {
// Derive a new token from existing values
surface: old.background ?? old.bg ?? "#fff",
onSurface: old.foreground ?? old.text ?? "#000",
primary: old.primary ?? old.accent ?? "#6b21a8",
},
},
};
},
});remapColors and migrate. The migrate function runs first, then remapColors is applied to the result.7CI integration
Add a lightweight CI step that loads every theme in your repo, runs migrateTheme, and exits non-zero if any theme's version doesn't match the result. This prevents shipping themes that haven't been updated for breaking changes.
name: Theme Migration Check
on: [push, pull_request]
jobs:
migrate-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Check themes for outdated versions
run: |
node -e "
const { migrateTheme } = require('@themvora/core');
const themes = require('./themes.json');
let failed = false;
for (const theme of themes) {
const migrated = migrateTheme(theme);
if (migrated.meta.version !== theme.meta.version) {
console.log('Outdated theme:', theme.meta.name,
theme.meta.version, '->', migrated.meta.version);
failed = true;
}
}
if (failed) {
console.error('Run migrateTheme on all themes before pushing.');
process.exit(1);
}
console.log('All themes are up to date.');
"- The script compares each theme's
meta.versionagainst the migrated result — if they differ, the theme is outdated. - Works with both local theme files and programmatically generated themes.
- Pair with
clearMigrationsin tests to reset state between test cases.
8Migration checklist
Work through this in order. Steps 1–3 are read-only; nothing in your project changes until step 4.
| # | Step | Verify it passed |
|---|---|---|
| 1 | Back up the theme files (or commit them) before touching anything. | A clean revert point exists in git. |
| 2 | Read meta.version on every theme and compare it against the table above. | You know which themes are behind and by how many hops. |
| 3 | Register a migration step for each consecutive version pair you need to bridge. | Every from → to hop in the chain is covered. |
| 4 | Run migrateTheme against one theme and diff the result against the input. | Only the intended tokens changed; meta.version advanced to the target. |
| 5 | Validate the migrated theme with validateTheme (and validateThemeContrast if colors moved). | The migrated theme still satisfies the required token set and contrast rules. |
| 6 | Apply the output to your project and exercise theme switching in the app. | Light/dark switching and CSS variables render as before. |
| 7 | Add the CI check so this theme can never silently fall behind again. | The pipeline fails on an outdated meta.version. |
migrateTheme walk them. Hand-editing one hop breaks the assumption the next hop starts from a known shape.