Four client sites shared a block theme that had been forked four times, because each client wanted different colours and a different type scale and there was no way to express that in one theme. Every bug fix was applied four times and by May two of the forks had diverged enough that the fixes no longer applied cleanly.
The symptom
$ ./bin/theme-last-commit
acme 2022-05-11
brightwork 2022-05-11
corvid 2021-11-02 ← 6 months behind
delta 2022-02-14
$ diff -r themes/acme themes/corvid | grep -c '^diff'
41
# 41 differing files, of which one is meaningful:
# theme.json — the colours and the type scale
# the other 40 — six months of driftTwo files should have differed and forty-one did. The drift is the cost of the fork and it is invisible until somebody tries to apply a fix, at which point the answer is either a merge or four separate implementations.
Why it happens
A theme was the unit of distribution and a theme had one theme.json, so two designs meant two themes. A child theme could override the file entirely and not merge with it, which meant a child theme was a copy with extra steps.
The fix
The styles directory
theme/
theme.json the base: layout, spacing, blocks
styles/ acme.json brightwork.json corvid.json
delta.json
every JSON file in styles/ is read as an alternative set of
settings and styles, merged over the base.
one theme. four designs. one place to fix a bug.{
"version": 2,
"title": "Corvid",
"settings": {
"color": {
"palette": [
{ "slug": "brand", "color": "#0f766e", "name": "Brand" },
{ "slug": "ink", "color": "#0c0a09", "name": "Ink" }
]
},
"typography": {
"fontFamilies": [{ "slug": "body", "name": "Söhne",
"fontFamily": "Söhne, sans-serif" }]
}
},
"styles": {
"typography": { "fontFamily": "var(--wp--preset--font-family--body)" }
}
}
The variation only declares what differs, so the file is sixty lines rather than four hundred and the base carries everything shared. That is the whole benefit: adding a spacing token to the base gives it to all four, and there is no merge to perform.
How the merge actually works
the merge is deep and per key. arrays are REPLACED;
objects are merged.
base defines palette [brand, ink, paper]
variation defines [brand, ink]
→ the result is [brand, ink]. NOT a union.
settings.color.palette replaced wholesale
settings.typography.* merged key by key
styles.blocks.core/button merged key by key
the palette rule catches people, and it is right — a
partial palette would be unorderable.Arrays being replaced rather than merged is correct and is the source of the most common mistake: a variation defining two colours silently removes the third from the palette. Every variation therefore has to declare the complete palette, which reintroduces a small amount of duplication and is unavoidable.
A build step generating the variations from a shared token file removes that duplication and adds a build step to a theme that had none, which is the trade. On four sites it was not worth it; on twelve it would be.
The title, and the translation nobody adds
// the title in styles/corvid.json is NOT translated by
// default. it has to be registered as a theme string:
add_action( 'after_setup_theme', function (): void {
__( 'Corvid', 'turkerdev' ); // a no-op, deliberately
} );
// its only purpose is to put the string in the .pot file.
// it reads as dead code and is removed by anybody tidying.
The no-op translation call is genuinely how this is done and it looks exactly like something to delete, which is why it needs a comment saying why it exists. On a single-language site none of it matters and on a multilingual one the variation names appear untranslated in the editor with no explanation.
Per-block styles in a variation
{
"title": "Delta",
"styles": { "blocks": {
"core/button": {
"border": { "radius": "0" },
"typography": { "fontWeight": "600", "textTransform": "uppercase" }
},
"core/quote": {
"border": { "left": { "width": "3px",
"color": "var(--wp--preset--color--brand)" } }
}
} }
}
Per-block styles are where a variation stops being a colour swap and becomes a design, and they merge key by key with the base — so a variation setting a button radius inherits the base’s padding. That is usually what is wanted and it means reading the effective style for a block requires reading two files.
Selecting one, and where the selection lives
# picking a variation writes the merged result to a post
$ wp post list --post_type=wp_global_styles --fields=ID,post_modified
| 41 | 2022-05-24 14:02:11 |
$ wp post get 41 --field=content | jq -r '.settings.color.palette[0].color'
"#0f766e"
# the selection is CONTENT, not configuration: it is not
# in the repository and does not travel with a deploy.The variation selection living in a post is the same operational surprise as customised templates: staging and production can be on different designs, and a deploy does not change either. Exporting the global styles post as part of the environment sync is the mitigation and it is a step somebody has to remember.
Verifying it worked
$ ls themes/
turkerdev-base
$ ls themes/turkerdev-base/styles/
acme.json brightwork.json corvid.json delta.json
# the generated CSS per variation, diffed against a
# baseline captured from the old forks
$ for v in acme brightwork corvid delta; do
> ./bin/render-global-styles "$v" > "/tmp/$v.css"
> diff "baseline/$v.css" "/tmp/$v.css" > /dev/null
> && echo "$v ok"
> done
acme ok
brightwork ok
delta ok
# corvid: 11 differences — six months of fixes it never hadThree of four producing identical CSS is the assertion that the consolidation preserved the designs. Corvid differing by eleven rules is the six months of drift, and every one of them was a fix the fork had missed rather than a design decision — which is the argument for the whole exercise in a single diff.
What this costs
A merge order that has to be held in somebody’s head: base, then variation, then whatever a user changed in the site editor, with arrays replaced and objects merged. Reading the effective value of a token means reading three sources, and the site editor shows the result without saying where any of it came from.
The complete-palette requirement also means the duplication is reduced rather than removed. Four files each listing seven colours is twenty-eight declarations where the ideal is seven plus twelve overrides, and a token-driven build step is the only way to get there — which trades a JSON file anybody can edit for a build nobody can skip.