CherryHQ/cherry-studio · error · Error

[theme-contract] ${declaration.name} is defined twice in ${s

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.

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)

Solutions

  1. 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).
  2. If both declarations are intentional and distinct, rename one to a unique name that matches the contract prefix for its layer.
  3. 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

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


AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12). Data as JSON: /api/errors/65d64366c1745e68. Report an issue: GitHub.