CherryHQ/cherry-studio · error · Error
[theme-contract] is defined twice in : and
Error message
[theme-contract] ${declaration.name} is defined twice in ${selector}: ${existing.source} and ${sourceName} What it means
Thrown by buildDeclarationMap while aggregating CSS custom properties from the ordered source list (foundation, theme-input, shadcn, product) for a single mode block (:root or .dark). The contract requires each variable name to be owned by exactly one source file within a mode; declaring the same name in two files is an ambiguous ownership violation. The message names both conflicting sources so you can see which two files claim the variable.
Solutions
- Read both source files named in the message and delete the duplicate declaration from the layer that should NOT own it (ownership follows the layering: --cs-* in foundation, --cs-theme-* in theme-input, official roles in shadcn, product roles in product).
- If both declarations are intentional and distinct, rename one to a unique name that matches the contract prefix for its layer.
- Re-run `pnpm --filter @cherrystudio/ui theme:check` to confirm the duplicate is gone.
Example fix
// before (product.css)
:root { --cs-background: hotpink; }
// after — remove it; --cs-background is owned by tokens/colors/primitive.css Defensive patterns
Strategy: validation
Validate before calling
// Before calling validateThemeContractSources, scan each mode block for duplicate names across sources.
import { loadThemeContractSources } from './validate-theme-contract'
function findDuplicateDeclarations(entries: ReadonlyArray<readonly [string, string]>, selector: string) {
const seen = new Map<string, string>()
const dupes: string[] = []
const block = new RegExp(`${selector}\\s*\\{([\\s\\S]*?)\\}`, 'g')
for (const [sourceName, source] of entries) {
for (const blockMatch of source.matchAll(block)) {
for (const [, name] of blockMatch[1].matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/g)) {
if (seen.has(name[1])) dupes.push(`${name[1]} in ${seen.get(name[1])} and ${sourceName}`)
else seen.set(name[1], sourceName)
}
}
}
return dupes
} Try / catch
try {
validateThemeContractSources(sources)
} catch (error) {
if (error instanceof Error && error.message.startsWith('[theme-contract]')) {
console.error(`Theme contract check failed: ${error.message}`)
process.exitCode = 1
return
}
throw error
} Prevention
- Run `pnpm --filter @cherrystudio/ui theme:check` (or the repo's pnpm build:check / lint gate) before every commit touching packages/ui/src/styles.
- When moving a declaration between token files, delete the original in the same commit.
- Keep one owner per layer prefix: --cs-* in foundation, --cs-theme-* in theme-input, official roles in shadcn, product roles in product.
When it happens
Trigger: Adding `:root { --cs-background: hotpink; }` to product.css when --cs-background is already declared in primitive.css; copying a declaration from one token file into another instead of moving it; appending a stray `:root` block to the wrong layer file.
Common situations: Refactoring token files and leaving the original declaration behind; introducing a new product variable that collides with an existing foundation --cs-* name; merging branches that both touched the same selector.
Related errors
- [theme-contract] compatibility color
- [theme-contract] foundation cannot declare runtime input
- [theme-contract] foundation
- [theme-contract] is missing root declarations
- [theme-contract] references missing foundation variables
AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12).
Data as JSON: /api/errors/65d64366c1745e68.
Report an issue: GitHub.
Appendix: source
Thrown at packages/ui/scripts/validate-theme-contract.ts:145
}
}
function assertExactImports(label: string, source: string, expected: readonly string[]): void {
const actual = extractImports(source)
if (actual.length !== expected.length || actual.some((entry, index) => entry !== expected[index])) {
throw new Error(`[theme-contract] ${label} imports must be exactly: ${expected.join(' -> ')}`)
}
}
function buildDeclarationMap(entries: SourceEntry[], selector: ':root' | '.dark'): Map<string, Declaration> {
const declarations = new Map<string, Declaration>()
for (const [sourceName, source] of entries) {
for (const declaration of extractModeDeclarations(source, sourceName, selector)) {
const existing = declarations.get(declaration.name)
if (existing) {
throw new Error(
`[theme-contract] ${declaration.name} is defined twice in ${selector}: ${existing.source} and ${sourceName}`
)
}
declarations.set(declaration.name, declaration)
}
}
return declarations
}
function assertRequiredDeclarations(
label: string,
declarations: Map<string, Declaration>,
variableNames: readonly string[],
prefix: string
): void {
const missing = variableNames.map((name) => `${prefix}${name}`).filter((name) => !declarations.has(name))
View on GitHub (pinned to 726446b54c)