Theme Contract Versioning Across Brands
Part of Multi-Brand & White-Label Token Architecture. This page covers the practice of treating the set of required semantic tokens as an explicitly versioned “theme contract” — a canonical specification that every brand or tenant theme must satisfy — and the tooling, governance, and CI enforcement that makes that contract durable across the lifetime of a multi-brand design system.
What Is a Theme Contract?
A theme contract is the authoritative list of semantic tokens that a consuming application expects to find defined by any brand or tenant theme. It is not a list of primitive values; it is a list of semantic keys together with their expected CSS custom property type (color, dimension, number, etc.) and any metadata constraints such as mandatory WCAG-contrast roles. Think of it as the interface definition in a typed programming language — any theme that implements the interface is a conforming theme, regardless of its actual values, and any theme that omits a required key or introduces an unknown extra key violates the contract.
At enterprise scale, where tens of brands may share a single component library, the cost of an undetected contract violation is high: a component that references --ds-color-action-primary crashes silently to a browser-default when that token is absent, and the failure surfaces in production rather than in the design-ops pull request that introduced the new brand.
Problem Framing
The failure mode that motivates contract versioning is gradual interface drift. A component engineer adds a new semantic token — --ds-color-surface-overlay — to support a modal backdrop. They update the base theme and the two brand themes they know about. Six months later, a third brand onboarded by a different team deploys without that token, and the modal renders with a transparent background in production. No CI step caught the gap because there was no machine-readable definition of what a complete theme must contain.
The inverse problem is equally damaging: a brand theme introduces a non-standard token like --brand-x-hero-gradient that no component consumes. It proliferates into the brand’s CSS output, increases maintenance surface, and eventually confuses engineers who can’t tell which custom properties are contract-required versus brand-local.
Contract versioning makes both failure modes detectable at pull-request time.
Defining the Contract
The contract lives as a JSON file in the token repository, typically at contracts/theme-contract.v<N>.json. It is not a token values file — it carries no colors or sizes. It is a typed manifest:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contractVersion": "2.1.0",
"description": "Canonical semantic token interface all brand themes must implement.",
"tokens": {
"--ds-color-action-primary": {
"type": "color",
"role": "interactive-primary",
"contrastRequirement": "AA-on-surface-default"
},
"--ds-color-action-primary-hover": {
"type": "color",
"role": "interactive-primary-hover"
},
"--ds-color-surface-default": {
"type": "color",
"role": "background"
},
"--ds-color-surface-overlay": {
"type": "color",
"role": "modal-backdrop"
},
"--ds-color-text-primary": {
"type": "color",
"role": "body-text"
},
"--ds-color-text-secondary": {
"type": "color",
"role": "secondary-text"
},
"--ds-color-border-default": {
"type": "color",
"role": "border"
},
"--ds-color-feedback-error": {
"type": "color",
"role": "error-signal"
},
"--ds-color-feedback-success": {
"type": "color",
"role": "success-signal"
},
"--ds-space-inset-base": {
"type": "dimension",
"role": "component-padding"
},
"--ds-radius-base": {
"type": "dimension",
"role": "corner-radius"
},
"--ds-motion-duration-fast": {
"type": "duration",
"role": "micro-animation"
}
}
}
The type field maps directly to CSS @property syntax types — color, dimension, duration, number, percentage — so the same contract file can drive both the conformance checker and a Houdini @property registration generator. The role field is human-readable governance metadata, not a machine constraint, but it becomes the primary field in deprecation notices.
Semver Rules for the Contract
The contract itself is versioned with semantic versioning. The rules are stricter than for most software packages because a contract violation cascades to every brand simultaneously:
| Change Type | Version Bump | Rationale |
|---|---|---|
| Adding a new required token | minor | Additive; conforming themes still pass, but they must add the new token before the next major |
| Adding a new optional token | patch | No existing theme can fail |
| Renaming a token key | major | Every theme that used the old key now has an unknown extra |
| Removing a token | major | Components referencing the removed token silently receive no value |
Changing a token’s type |
major | A theme providing a dimension value for a color token is now type-invalid |
Changing a token’s role or description |
patch | Metadata only; no conformance impact |
| Weakening a contrast requirement | major | Accessibility guarantee changes; requires explicit opt-in from brands |
| Tightening a contrast requirement | minor | Stricter requirement; brands can fail CI until they update their values |
“Adding a new required token is a minor bump” deserves explanation: the conformance checker is run against a specific pinned contract version, not always the latest. A brand that has pinned to v2.0.0 will not fail CI when v2.1.0 adds --ds-motion-duration-fast. It will fail when it upgrades its pinned version. This makes token addition non-breaking in practice while still requiring active brand participation on upgrade.
The integration with semantic-release for tokens is direct: the commit-analyzer plugin is extended with a custom release-rules entry that upgrades any commit touching contracts/ and containing remove: or rename: in the commit body to a major release automatically.
Deprecation and Aliasing
When a token is scheduled for removal, it goes through a two-phase deprecation window:
Phase 1 (minor bump): The token is marked "deprecated": true in the contract JSON with a "deprecatedSince" version and a "replacedBy" field naming its successor. The base theme emits a CSS alias:
/* @contract-deprecated since v2.1.0 — use --ds-color-action-primary */
--ds-color-brand-cta: var(--ds-color-action-primary);
The conformance checker treats a deprecated token as optional but warns when a brand theme defines it with a literal value rather than the alias. This surfaces themes that have not yet migrated.
Phase 2 (major bump): The deprecated token is removed from the contract. Brands that still define it now have an “unknown extra” — the conformance checker can be configured to either warn or error on unknowns, depending on team policy. The CSS alias in the base theme is removed at the same time, severing the fallback.
This two-phase approach aligns with the aliasing pattern documented in JSON Schema Validation for Tokens and gives brand teams at least one full release cycle to migrate. For large enterprise customers with slower release cadences, the deprecation window can be extended contractually to two major versions.
Architectural Trade-offs
-
Strict contract vs. brand freedom: A fully required contract with no optional tokens gives maximum safety but makes it hard for brands to express genuinely unique design decisions. A contract with a large optional section allows brand creativity at the cost of component unpredictability. The practical balance is to require only tokens that components reference, and allow any additional brand-local tokens as long as they follow a namespaced convention (
--brand-<id>-*) that prevents clashes. -
Fast deprecation vs. migration cost: Removing tokens on a one-major-cycle schedule minimises zombie token accumulation but forces brands on long enterprise update cycles into emergency patches. Two-cycle deprecation windows reduce that friction but double the aliasing overhead and make the contract harder to reason about.
-
Additive minor bumps vs. waiting for compound releases: Batching new tokens into periodic minor releases reduces churn for brand teams who must respond to every minor. But batching also delays feature work — components that need the new token can’t ship until the minor drops. Teams that favour continuous delivery tend to use additive minor bumps immediately; teams that favour stability batch them on a four-week cadence.
-
Treat unknowns as errors vs. warnings: Erroring on extra tokens is the safest position — it prevents brands from accumulating private tokens that accidentally shadow contract names. Treating them as warnings is friendlier for gradual migration but erodes the boundary over time. A reasonable middle ground: error on keys that match the contract prefix (
--ds-*) but are not in the contract; warn on keys with other prefixes.
Governance Pipeline
The contract change process follows a numbered governance pipeline so that no token is added, renamed, or removed by a single engineer without cross-team sign-off:
- Proposal commit — An engineer opens a PR against
contracts/theme-contract.draft.jsonwith the proposed change, the semver bump type, and a justification comment. Commit message must follow the conventioncontract(tokens): [minor|major|patch] add/rename/remove <token-name>. - Automated impact analysis — A CI job diffs the draft contract against the current released contract and lists every brand theme that would fail conformance after the change, by counting missing required keys and new unknowns per brand. The output is posted as a PR comment.
- Design-ops review — At least one design-ops owner approves the PR. For major bumps, two approvals are required.
- Migration window notice — On major bumps, an automated GitHub issue is opened for each brand theme repository, with the failing token list, the target timeline, and a link to the migration guide. The notification uses the same webhook infrastructure as the Figma-to-CSS sync pipeline.
- Contract release — The PR merges, semantic-release publishes the new contract version to the npm registry, and the draft file is promoted to
contracts/theme-contract.v<N>.json. The previous version is retained in the repo for brands that have not yet upgraded. - Conformance gate activated — The CI configuration for each brand repository is updated (via an automated bot PR) to reference the new contract version. Teams may delay merging this bot PR within the agreed migration window.
- Verification sweep — After the migration window closes, a scheduled CI job runs conformance against all brand themes using the new version. Remaining failures are escalated to the engineering leads.
Conformance Test Implementation
The conformance checker is a small Node.js script that runs as a CI job. It takes a contract version and a glob of brand theme CSS files, parses the custom property declarations from each file, and compares them against the contract’s required token list.
// scripts/check-theme-contract.js
import { readFileSync, readdirSync } from 'fs';
import { glob } from 'glob';
import { parse } from 'postcss';
const CONTRACT_VERSION = process.env.CONTRACT_VERSION ?? '2.1.0';
const contract = JSON.parse(
readFileSync(`contracts/theme-contract.v${CONTRACT_VERSION}.json`, 'utf8')
);
const requiredTokens = new Set(
Object.entries(contract.tokens)
.filter(([, meta]) => !meta.deprecated)
.map(([key]) => key)
);
const dsPrefix = '--ds-';
async function checkThemeFile(filePath) {
const css = readFileSync(filePath, 'utf8');
const root = parse(css);
const defined = new Set();
const extras = [];
root.walkDecls(/^--/, (decl) => {
defined.add(decl.prop);
if (decl.prop.startsWith(dsPrefix) && !requiredTokens.has(decl.prop)) {
extras.push(decl.prop);
}
});
const missing = [...requiredTokens].filter((t) => !defined.has(t));
return { filePath, missing, extras };
}
const themeFiles = await glob('themes/**/*.css');
const results = await Promise.all(themeFiles.map(checkThemeFile));
let exitCode = 0;
for (const { filePath, missing, extras } of results) {
if (missing.length > 0) {
console.error(`FAIL ${filePath}: missing ${missing.length} required token(s):`);
missing.forEach((t) => console.error(` - ${t}`));
exitCode = 1;
}
if (extras.length > 0) {
// error on ds-prefix unknowns; warn on brand-prefixed extras
const dsExtras = extras.filter((t) => t.startsWith(dsPrefix));
if (dsExtras.length > 0) {
console.error(`FAIL ${filePath}: ${dsExtras.length} unknown --ds-* token(s):`);
dsExtras.forEach((t) => console.error(` - ${t}`));
exitCode = 1;
} else {
console.warn(`WARN ${filePath}: ${extras.length} brand-prefixed extra token(s) (not an error).`);
}
}
if (missing.length === 0 && extras.filter((t) => t.startsWith(dsPrefix)).length === 0) {
console.log(`PASS ${filePath}`);
}
}
process.exit(exitCode);
The key design decisions in this script: PostCSS parses the CSS rather than using regex on raw text, which handles multiline values, @layer blocks, and :root vs :host selectors correctly. The separator between ds-prefix unknowns (errors) and brand-prefix extras (warnings) enforces the namespace discipline without blocking teams that legitimately extend their themes with brand-local tokens.
CI Pipeline YAML
The conformance check runs on every PR that touches a theme file, and on a nightly schedule across all registered brand repositories:
# .github/workflows/theme-conformance.yml
name: Theme Contract Conformance
on:
pull_request:
paths:
- 'themes/**/*.css'
- 'contracts/**/*.json'
schedule:
- cron: '0 3 * * *'
jobs:
conformance:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
contract_version: ['2.1.0']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- name: Run conformance check
env:
CONTRACT_VERSION: ${{ matrix.contract_version }}
run: node scripts/check-theme-contract.js
- name: Upload conformance report
if: always()
uses: actions/upload-artifact@v4
with:
name: conformance-report-${{ matrix.contract_version }}
path: reports/conformance-*.json
retention-days: 30
- name: Annotate PR with failures
if: failure() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('reports/conformance-latest.json', 'utf8'));
const body = report.failures
.map(f => `**${f.file}**: missing \`${f.missing.join('`, `')}\``)
.join('\n');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Theme contract conformance failures (v${process.env.CONTRACT_VERSION})\n\n${body}`
});
The fail-fast: false strategy is deliberate: when multiple contract versions are in active migration, every version’s conformance results should be visible even if one fails. The nightly schedule catches drift introduced outside of PRs — for example, when a brand team cherry-picks a commit that removes a token without going through the governance pipeline.
Consumer Pinning
Each brand theme repository declares its pinned contract version in a package.json dev dependency and in a theme.config.json at the repo root:
{
"themeContract": {
"version": "2.1.0",
"registry": "https://npm.your-org.internal/@ds/theme-contracts"
}
}
The conformance check script reads this file first and pulls the matching contract from the registry if it is not already cached locally. This means a brand team upgrading from v2.0.0 to v2.1.0 can make the change in theme.config.json, run the conformance check locally, see exactly which tokens they need to add, add them, and then merge — all without coordinating with the design system team in real time.
Pinning also protects brands from surprise failures. When the design-system team publishes v2.2.0 with a new required token, brands pinned to v2.1.0 continue to pass CI. They only fail when they explicitly bump the pin, which is a deliberate developer action rather than a dependency resolution side effect.
Cross-Cluster Dependency Mapping
| Parent Pillar | Sibling Area | Integration Point | Validation Strategy |
|---|---|---|---|
| Multi-Brand & White-Label | Brand Theme Layering | Cascade layer order must respect contract token scope | Conformance check runs after layer compilation |
| Multi-Brand & White-Label | Per-Tenant Runtime Theming | Runtime-loaded tenant themes must also satisfy the contract | Contract check runs at tenant-theme-load time in JS |
| Token Scaling & CI | JSON Schema Validation | Schema validates token values; contract validates token presence | Two separate CI jobs; both must pass |
| Token Scaling & CI | Semantic Release | Contract version bumps drive the token package release train | contractVersion field read by release.config.js |
/* themes/brand-a.css */
/* @contract: 2.1.0 */
/* @depends: contracts/theme-contract.v2.1.0.json */
:root {
--ds-color-action-primary: #005fcc;
--ds-color-action-primary-hover: #004ba0;
--ds-color-surface-default: #ffffff;
--ds-color-surface-overlay: rgba(0, 0, 0, 0.48);
--ds-color-text-primary: #1a1a2e;
--ds-color-text-secondary: #5a5a7a;
--ds-color-border-default: #d0d5dd;
--ds-color-feedback-error: #d92d20;
--ds-color-feedback-success: #027a48;
--ds-space-inset-base: 1rem;
--ds-radius-base: 6px;
--ds-motion-duration-fast: 120ms;
/* Brand-local extension — not part of the contract */
--brand-a-hero-gradient: linear-gradient(135deg, #005fcc 0%, #003d99 100%);
}
The /* @contract: 2.1.0 */ comment is read by the conformance script as a secondary check: if the comment version differs from the contract version the CI job is currently enforcing, the brand team receives a warning that the annotation is stale.
Production Code Reference
Extending the contract for type checking with @property
The contract’s type field can drive automated @property registration in the base theme, catching type violations at CSS parse time in addition to conformance script time:
// scripts/generate-property-registrations.js
import { readFileSync, writeFileSync } from 'fs';
const CONTRACT_VERSION = process.env.CONTRACT_VERSION ?? '2.1.0';
const contract = JSON.parse(
readFileSync(`contracts/theme-contract.v${CONTRACT_VERSION}.json`, 'utf8')
);
const cssTypeMap = {
color: '<color>',
dimension: '<length>',
duration: '<time>',
number: '<number>',
percentage: '<percentage>',
};
const registrations = Object.entries(contract.tokens)
.filter(([, meta]) => !meta.deprecated)
.map(([key, meta]) => {
const syntax = cssTypeMap[meta.type] ?? '*';
return `@property ${key} {\n syntax: "${syntax}";\n inherits: true;\n initial-value: ${getInitialValue(meta.type)};\n}`;
})
.join('\n\n');
function getInitialValue(type) {
const defaults = { color: 'transparent', dimension: '0px', duration: '0s', number: '0', percentage: '0%' };
return defaults[type] ?? 'none';
}
writeFileSync('dist/property-registrations.css', registrations);
console.log(`Generated @property registrations for ${Object.keys(contract.tokens).length} tokens.`);
This generates a CSS file that is imported before any theme CSS. When a brand theme accidentally assigns a dimension value to a color token, the browser silently ignores the invalid value and the component falls back to the initial-value — making the error visible in DevTools even before the conformance CI job reports it.
Diagnostic Matrix
| Diagnostic Step | Execution Detail |
|---|---|
| Identify missing tokens | Run node scripts/check-theme-contract.js locally; failures list every missing key with its role description from the contract |
Identify unknown --ds-* tokens |
The same script outputs extras under the FAIL heading; cross-reference against the deprecation list in the contract JSON to distinguish removed vs. never-valid keys |
| Verify pinned contract version | Check theme.config.json; if the version field is behind the current released version by a major, the brand is running on an end-of-life contract |
| Debug silent CSS failures | Open DevTools computed styles; a custom property with an inherited value of transparent or 0px where a non-zero color is expected indicates a missing or type-invalid token |
| Trace nightly schedule failures | Pull the nightly workflow run artifacts from GitHub Actions; the conformance-report-*.json file lists every brand and its pass/fail status with timestamps |
Root causes and resolutions
Missing token after a contract minor bump: The brand theme was pinned to the previous version and has not been updated. Resolution: bump theme.config.json to the new version, add the missing tokens, confirm locally with the conformance script.
Unknown --ds-* token after a contract major bump: The brand theme still defines a token that was removed in the major release. Resolution: remove the literal definition; if the component still needs the visual effect, migrate to the replacement token named in the replacedBy field of the deprecated entry.
Type-invalid token caught by @property: A brand defined --ds-space-inset-base: #f00 (a color value for a dimension token). Resolution: correct the value; the @property registration in dist/property-registrations.css documents the required CSS type for each token.
Conformance passes but components still look wrong: The brand has defined all required tokens but with values that produce a semantically wrong result — for example, --ds-color-action-primary: transparent. Conformance only checks presence and prefix correctness, not visual intent. Resolution: pair conformance with visual regression testing (e.g., Percy or Chromatic) to catch value-level semantic drift.
Nightly check fails for a brand that passed all PR checks: A commit was merged to the brand repository outside the monitored themes/**/*.css path — for example, a build script that rewrites the theme file at runtime. Resolution: expand the CI path filter and add the nightly job to the brand repository’s own CI configuration.
Frequently Asked Questions
Q: Should optional tokens be in the contract at all?
Optional tokens occupy a grey zone. If a component uses a token only in a specific feature flag condition, making it required forces every brand to define it even if the feature is disabled for them. The pragmatic answer is to omit genuinely optional tokens from the contract and instead validate them in the component’s own Storybook or integration tests. This keeps the contract focused on the minimal set that every component assumes unconditionally.
Q: How do you handle a brand that legitimately needs a different token set?
The contract represents the intersection of what every component in the shared library requires. If a brand deploys only a subset of components, it might argue it does not need the full contract. In practice, enforcing the full contract is still safer: it means the brand is deployment-ready for any component in the library without a token-gap incident. If a specific brand’s use case is genuinely incompatible with a contract token — for example, a text-only brand that has no interactive states — that token should be marked optional in the contract with a formal exception process.
Q: Can the contract reference primitive tokens by alias rather than requiring hardcoded values?
Yes, and this is the recommended production pattern. A brand’s CSS file can satisfy the contract by using var() references to its own primitive tier rather than literal values:
:root {
--brand-a-blue-600: #005fcc;
--ds-color-action-primary: var(--brand-a-blue-600);
}
The conformance checker validates that --ds-color-action-primary is declared — it does not resolve the alias chain. Type checking via @property does resolve the chain at browser parse time, so this is the correct division of responsibilities: structural presence (conformance script) is separate from runtime value resolution (@property + DevTools).
Related
- Multi-Brand & White-Label Token Architecture — parent overview covering the full architecture of brand token systems
- Versioning Theme Contracts Across Brands — step-by-step implementation guide for applying these governance patterns in a monorepo
- Versioning & Semantic Release for Tokens — how to automate semantic version calculation and changelog generation for the token package that contains the contract
- JSON Schema Validation for Tokens — schema-level validation that complements contract conformance by enforcing token value types and structure