A design system in theme.json and a stylesheet that agrees

The theme had a spacing scale in theme.json, a different spacing scale in the stylesheet, and a third in a Figma file. They agreed on four values out of nine, and nobody could say which was authoritative — which meant every new component picked whichever the developer happened to look at.

The symptom

$ jq -r '.settings.spacing.spacingSizes[] | "(.slug) (.size)"' theme.json
small     1rem
medium    1.5rem
large     2.5rem
x-large   4rem

$ grep -oP '^s*--space-w+:s*K[^;]+' assets/css/tokens.css
0.5rem
1rem
1.5rem
2rem      ← not 2.5
3rem      ← not in theme.json at all
4rem

# 9 values across two files, 4 of which agree.

Two sources of truth for the same scale is a design system that exists twice, and the divergence is not visible in either file. It appears in the rendered page as spacing that is nearly consistent, which is worse than obviously inconsistent because nobody files a ticket about it.

Why it happens

theme.json controls what the editor offers and the stylesheet controls everything the editor does not — a custom block, a template part, a layout wrapper. Both need the scale, and there is no mechanism that makes one derive from the other.

The fix

Generating theme.json from tokens

// tokens/design-tokens.json — the single source
{
  "space": {
    "2xs": "0.25rem", "xs": "0.5rem", "s": "1rem",
    "m": "1.5rem", "l": "2.5rem", "xl": "4rem"
  },
  "color": {
    "brand": "#1f6feb", "ink": "#101418", "paper": "#ffffff"
  },
  "type": {
    "body":  { "min": "1rem",    "max": "1.125rem" },
    "large": { "min": "1.25rem", "max": "1.5rem" }
  }
}
// build/tokens.mjs — one script, two outputs
const t = JSON.parse(await readFile('tokens/design-tokens.json'))

await writeFile('theme.json', JSON.stringify({
  version: 2,
  settings: {
    spacing: { spacingSizes: entries(t.space, 'size') },
    color:   { custom: false, palette: entries(t.color, 'color') },
  },
}, null, 2))

// and the CSS, from the same object, with the SAME names
// WordPress generates: --wp--preset--spacing--s

Generating both from one file is the whole fix and the important detail is that the CSS uses the names WordPress generates rather than its own — a custom property called --space-s alongside --wp--preset--spacing--s is the divergence with extra steps.

Fluid typography, and the clamp nobody writes

{
  "settings": {
    "typography": {
      "fluid": true,
      "fontSizes": [{
        "slug": "large", "size": "1.5rem", "name": "Large",
        "fluid": { "min": "1.25rem", "max": "1.5rem" }
      }]
    }
  }
}
/* what 6.1 generates */
--wp--preset--font-size--large:
  clamp(1.25rem, 1.25rem + ((1vw - 0.2rem) * 0.417), 1.5rem);

/* which is not a formula to read or override. a theme
   with its own type scale declares min and max per size
   rather than accepting the derived ones. */

Turning fluid on globally applies a computed range to every size that does not declare one, which is a site-wide typographic change delivered by a boolean. Declaring explicit minimums and maximums per size is what keeps the scale a design decision rather than an emergent one.

The generated clamp() is also unreadable, which matters when somebody is debugging why a heading is the wrong size at 900 pixels. Recording the intended min and max in the token file, next to the value, is the documentation that makes it tractable.

A lint rule forbidding raw values

// .stylelintrc.mjs
export default {
  rules: {
    'declaration-property-value-disallowed-list': {
      '/^(margin|padding|gap)/': [/^d/],
      '/color$/': [/^#/, /^rgb/],
      'font-size': [/^d/],
    },
  },
}

// which fails on:
//   padding: 1.5rem            → var(--wp--preset--spacing--m)
//   color: #1f6feb             → var(--wp--preset--color--brand)
// and permits var(), calc() and the keywords.

A lint rule is what makes the single source enforceable rather than aspirational, and it is the piece that is usually missing. It also produces a large number of failures on an existing stylesheet — two hundred and eleven here — which is a baseline-and-ratchet exercise exactly like the static analysis one.

The values that legitimately are not tokens

the exceptions, which need a documented escape:
  1px borders and hairlines, optical adjustments (-2px on
  an icon to align it with a cap height), third-party
  component internals, aspect ratios and percentages

the rule that worked: a disable comment WITH a reason.

  /* stylelint-disable-next-line -- optical alignment */
  margin-top: -2px;

17 across the stylesheet, all genuine.

Requiring a reason on the disable comment is what keeps the exception list readable, and seventeen genuine ones out of two hundred and eleven initial failures is a good ratio. The remaining hundred and ninety-four were values that should have been tokens and had been typed by hand.

The build step, and what it breaks

theme.json is now generated, so: it is committed anyway
(WordPress reads it from disk), it must not be edited by
hand, and CI regenerates and fails on a diff.

$ node build/tokens.mjs
$ git diff --exit-code theme.json assets/css/tokens.css 
  || { echo 'run node build/tokens.mjs'; exit 1; }

A generated file under version control is a compromise that needs the regeneration check, or somebody will edit it directly and the change will be silently overwritten on the next build. The check is two commands and is the thing that makes the arrangement hold.

Verifying it worked

$ node build/tokens.mjs && git diff --exit-code
# (no output)

$ npx stylelint 'assets/css/**/*.css'
# 17 disables, all with reasons

$ diff 
  <(jq -r '.settings.spacing.spacingSizes[].size' theme.json | sort) 
  <(jq -r '.space | to_entries[] | .value' tokens/design-tokens.json | sort)
# (no output)

$ npx backstop test
  Passed: 41   Failed: 2
  # both: a 2rem that became 2.5rem. the intended value.

The two visual differences are the divergence being corrected — a stylesheet value that had been 2rem where the design said 2.5rem, in two components. That is the bug this exercise existed to find and it had been invisible for a year.

What this costs

A build step for a file that used to be hand-written, and a generated artefact committed to the repository. That is uncomfortable and there is no alternative — WordPress reads theme.json from disk and a theme cannot run a build on the server, so the output has to be committed and the regeneration check is what keeps it honest.

The lint rule is also friction on every stylesheet change, and the seventeen exceptions will become thirty if nobody reviews them. A quarterly pass over the disable comments is the maintenance, and it is the kind of maintenance that lapses — at which point the rule is enforcing a scale that half the stylesheet has opted out of.