There is one question I never asked before touching anything: who actually owns this color value. I went straight to theme.json, because that is where colors are configured, changed the accent hex there, and considered the color work finished.
Nine pages disagreed. The button on every one of them was still #8B1538, a hex that was supposed to be retired. I blamed caching first, so I purged the object cache, purged the page cache, hard refreshed, opened incognito. Then I blamed specificity, so I opened DevTools ready to write a heavier selector. The Styles panel did not point at any stylesheet at all.
<a class="wp-block-button__link has-background"
style="background-color:#8B1538">
View details
</a>The color was coming from a style attribute on the element itself. There was no CSS file I could edit to remove it, because that color had never lived in CSS in the first place.
So this is not a story about a change that failed to propagate. It is a story about spending hours editing the wrong layer, because I had guessed wrong about who owned the value.
theme.json governs the theme, this color sits in the content
The Gutenberg button block stores custom colors directly in the block markup, and that block markup is stored verbatim in the post_content column. So what the database actually held for those pages looked roughly like this:
<!-- wp:button {"style":{"color":{"background":"#8B1538"}}} -->
<div class="wp-block-button">
<a class="wp-block-button__link has-background"
style="background-color:#8B1538">View details</a>
</div>
<!-- /wp:button -->theme.json defines the palette and the defaults. Its authority stops there. The moment an editor, or a script, picks a custom color on a block, that value stops being a reference to the palette and becomes a literal frozen into a row of content. The theme and the content are two different owners. The theme will never reach a value that already sits in the content, not because I read its files carelessly, but because structurally there is no path from one to the other.
Fortunately that ownership question has a fast test, and I should have run it in the first minute. Open DevTools and look at where the value is coming from. If it arrives as a rule from a stylesheet, the theme layer owns it and theme files are the right place to fix it. If it arrives as a style attribute on the element, the content owns it, and no theme file is ever going to touch it.
The layer the theme really does own
The pass over the theme files was still necessary and still correct, its scope was just far narrower than I assumed. One sed pass over theme.css, theme.json, the loading screen CSS, and the hero pattern CSS. That part was tedious and went fine.
What I nearly missed is that the theme layer itself has two stores, and theme.json is only the first. The moment somebody sets a color through the Site Editor, WordPress saves the result in the database as a post of type wp_global_styles, and those values stack on top of theme.json rather than replacing it. So I can get the theme files spotless, and if the accent was ever saved from the Site Editor the database copy still wins. You can read it over REST at /wp-json/wp/v2/global-styles/<id>, and clear it from the same Styles panel that wrote it.
Once both of those were genuinely clean, the button on those nine pages was still #8B1538. Exactly what I should have expected from the start, because this layer never owned it.
The content layer: content.raw, not content.rendered
What I needed was to read the raw post_content, swap the hex, and write it back, without opening nine pages in the editor one at a time and clicking a color picker on every button.
WordPress core's REST API can do that, with one requirement that is easy to miss. By default the post endpoints return content.rendered, which is rendered HTML. In that form the block comments carrying the attributes have already been stripped, so saving it back would replace the block markup with flat HTML and destroy the block structure. What I needed was content.raw, the original form that still carries the block comments and their JSON attributes.
And raw only appears when I ask for context=edit, while context=edit is only allowed for a user who can edit that post. The simplest way to satisfy both at once is to run it from an admin tab that is already logged in, so the auth cookie is there and I just have to pass along the nonce.
// run in the browser console, inside wp-admin, already logged in
const OLD = "#8B1538";
const NEW = "#9E1B21";
const IDS = [12, 18, 23, 31, 44, 52, 57, 63, 71];
for (const id of IDS) {
const res = await fetch(`/wp-json/wp/v2/pages/${id}?context=edit`, {
credentials: "same-origin",
headers: { "X-WP-Nonce": wpApiSettings.nonce },
});
const page = await res.json();
const raw = page.content.raw;
if (!raw.includes(OLD)) {
console.log(id, "clean, skipped");
continue;
}
await fetch(`/wp-json/wp/v2/pages/${id}`, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-WP-Nonce": wpApiSettings.nonce,
},
body: JSON.stringify({ content: raw.split(OLD).join(NEW) }),
});
console.log(id, "replaced");
}A few notes from doing this. wpApiSettings.nonce is available on admin screens that enqueue the wp-api-request script; if it comes back undefined on the screen you happen to be on, wp.apiFetch is also present in admin and handles the nonce itself, so use that instead. I deliberately used split().join() rather than a regex so I never had to think about escaping, and I deliberately skip pages that are already clean so their modified timestamp does not move for no reason. On a larger site, run a version that only does the console.log with no POST at all, and read the output before writing anything.
The third owner: the script that printed those pages
Once the database was clean and the site finally looked right, one question was still unanswered. Why did these nine pages have custom colors at all, when I never set them one by one in the editor?
The answer was in seed-content.php, the script I had used to generate those pages. It builds block markup as strings, hex values included, and the hex it was writing was still the old one. Which means the database rows I had just fixed were not the origin of that value. Those rows were only printed output, and the press was still holding the old value.
So I opened the script and replaced every old hex in the block markup strings it emits. This is the step with no visible payoff on the day you do it, which is exactly why it is the easiest one to forget. As long as the seeder still holds the old hex, one re-seed command is enough to bring the entire problem back, and two weeks later I am debugging the exact same thing while questioning my sanity.
What I took away
Before you change a value, ask which layer owns it. In WordPress the order is clear enough: theme.json holds the palette and the defaults, wp_global_styles holds whatever was ever saved from the Site Editor, and post_content holds any value already written into a block. The last one wins, and it is the only one with no path back to a theme file. DevTools tells you which one you are looking at: a rule from a stylesheet means theme, a style attribute on the block means content. For the content case the route is REST with context=edit, so what you rewrite is content.raw and not rendered output that has already lost its block comments.
Then that ownership question has one more level that is far easier to miss. Content generated by a script always has two owners: the database rows that exist right now, and the generator that printed them. Fixing only the rows feels like finishing, because the site immediately looks correct, but all you fixed was the output. Before calling it done, ask what happens if this content gets seeded again tomorrow morning. If the answer is that the old value comes back, you have not fixed anything yet, you have only postponed it.