Skip to content
System nominalUTC --:--:--Articles 085
MODULE 03 Lesson 08 / 18 35 MIN

Building a theme by prompt

A Block Theme, From theme.json Outward

Build the block theme the scaffolder will not give you, starting at theme.json, and verify what WordPress actually resolved instead of trusting the file you wrote.

There is no wp scaffold command that produces a block theme. That sentence is the whole reason this lesson exists. The lesson before this one showed what the theme scaffolder gives you: twenty classic PHP templates and no theme.json. So the block path starts with a file you write yourself, and everything else in the theme follows from it.

The order matters. If you begin with templates and add theme.json at the end, you will have built your markup around hardcoded colours and pixel font sizes, and the tokens will arrive too late to replace them. Build the tokens first, then let the templates reference them.

theme.json is the design system, not a settings file

It is easy to read theme.json as configuration that changes some editor defaults. It is closer to a stylesheet with a schema: the palettes and font sizes you declare become CSS custom properties on the front end, and the styles block becomes real CSS rules.

That is checkable rather than a claim about intent. The theme in this lesson was activated in a WordPress 7.0.7 install and its front page fetched over HTTP with curl. The generated stylesheet contains this, among about 12 KB of other rules:

:root{--wp--preset--color--ink: #1a1a1a;--wp--preset--color--paper: #fdfdfb;
--wp--preset--color--signal: #b32d0f;}

Those three slugs are exactly the three I declared, and nothing in the theme's style.css produced them. WordPress read theme.json and wrote the custom properties. If an agent tells you it "added the CSS variables", check whether it wrote a stylesheet or a theme.json; the second is the one that survives a theme update.

Version 3 is current, and the schema is not versioned by URL

The first key in the file is the one most likely to be wrong, because older tutorials and an agent's training data both carry older numbers. In WordPress 7.0.7 the accepted value is 3, and the schema itself is served from a single unversioned URL:

{
	"$schema": "https://schemas.wp.org/trunk/theme.json",
	"version": 3,
	"settings": { }
}

Do not take my word for the version. Fetch the schema and read the constraint:

$ curl -sSL https://schemas.wp.org/trunk/theme.json | python3 -c \
  "import json,sys; print(json.load(sys.stdin)['properties']['version'])"
{'description': 'Version of theme.json to use.', 'type': 'integer', 'const': 3}

Two things follow. First, const means there is exactly one legal value in this release, so a file claiming version 2 is not merely old, it is out of schema. Second, the schema URL in the shipped Twenty Twenty-Five theme points at https://schemas.wp.org/wp/6.7/theme.json while its version key says 3. The versioned URL is a permalink for editor autocomplete; version is what WordPress acts on. They are allowed to disagree, which is why you check the key and not the URL.

An unknown key costs you nothing, which is the problem

Here is the failure I want you to internalise, because it is invisible. I took a working theme.json and added a nonsense key inside settings.layout, then loaded the theme:

$ wp eval '$s = wp_get_global_settings( array("layout") );
  echo "layout still reads: ", $s["contentSize"], "\n";'
layout still reads: 42rem

No warning, no notice, no entry in the debug log. WordPress merges what it recognises and drops the rest. A misspelled key is therefore indistinguishable from a key you forgot to write: both produce a theme that looks fine and ignores an instruction. This is exactly the class of mistake an agent makes most often in this file, because the file is prose-shaped and the model has seen thousands of theme.json files from several schema generations.

The only real test is the read-back

You cannot validate theme.json by reading it. You validate it by asking WordPress what it resolved. Every setting you care about has a path, and wp_get_global_settings() takes that path as an array. Before I trust a generated theme.json I make the agent print each setting back and paste the output, so the claim and the evidence appear in the same breath.

Two HTML templates decide whether WordPress sees a block theme

theme.json alone does not make a block theme. WordPress decides with WP_Theme::is_block_theme(), and that method is looking for templates/index.html. Add it and the Site Editor becomes available; leave it out and you have a classic theme that happens to carry a theme.json.

Four files are enough for a theme that renders a front page and a single post:

acme-block/
├── style.css              theme header only, no CSS
├── theme.json             tokens, as above
├── templates/
│   ├── index.html         required, and what makes the theme a block theme
│   └── single.html
└── parts/
    ├── header.html
    └── footer.html

The templates are block markup, which is HTML with JSON comments. This is a whole template, and it is smaller than the equivalent index.php because the query loop is a block rather than a PHP loop:

<!-- wp:template-part {"slug":"header","tagName":"header"} /-->

<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
	<!-- wp:query {"queryId":1,"query":{"perPage":10,"postType":"post","inherit":true},"layout":{"type":"default"}} -->
	<div class="wp-block-query">
		<!-- wp:post-template -->
			<!-- wp:post-title {"level":2,"isLink":true} /-->
			<!-- wp:post-date /-->
			<!-- wp:post-excerpt /-->
		<!-- /wp:post-template -->
		<!-- wp:query-no-results -->
			<!-- wp:paragraph -->
			<p>Nothing published yet.</p>
			<!-- /wp:paragraph -->
		<!-- /wp:query-no-results -->
	</div>
	<!-- /wp:query -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->

The slug in template-part is a file name: parts/header.html. Nothing else connects them, and a typo produces an empty space in the template rather than an error. That is worth a moment, because it is the first place in this course where a wrong string fails quietly in a way an agent will describe as done.

Activating the theme and asking WordPress the direct question is what settles it:

$ wp theme activate acme-block
Success: Switched to 'Acme Block' theme.
$ wp eval 'var_dump( wp_get_theme()->is_block_theme() );'
bool(true)

defaultPalette: false does not do what the name suggests

This is the trap I would put in front of anyone reading agent-written theme.json. Set settings.color.defaultPalette to false and the core colour palette disappears from the editor's colour picker. It does not disappear from the generated CSS.

I tested it rather than reasoning about it. With defaultPalette set to true, the front page was 27,698 bytes and its inline global styles block was 12,532 characters. With it set to false, 27,698 bytes and 12,532 characters. The same output, byte for byte, still containing the core preset variables and the .has-vivid-red-color utility class:

$ grep -c 'preset--color--vivid-red' page-with-true.html
1
$ grep -c 'preset--color--vivid-red' page-with-false.html
1

The reason is in the WordPress source, in the comment above the prevent_override entry for colour presets: the setting controls whether the defaults are shown to the user, and the theme's own presets take precedence over them. Anything on the front end that depends on those variables keeps working, and anything you assumed would shrink because of that key did not.

The practical consequence for an agent-driven build: "I set defaultPalette to false so the core palette is gone" is a sentence you should not accept. Ask for the output instead. curl the page and grep the inline styles, or read the resolved settings back:

$ wp eval '
$p = wp_get_global_settings( array("color","palette") );
foreach ( array_keys($p) as $origin ) { echo $origin, ": ", count($p[$origin]), " entries\n"; }
var_dump( wp_get_global_settings( array("color","defaultPalette") ) );'
default: 12 entries
theme: 3 entries
bool(false)

That output is the honest picture: the setting is false, the default origin still holds twelve entries, and both facts are true at once.

Read the resolved settings, then add a style variation

Once the tokens exist, the cheap win is a style variation: a second palette an editor can switch to from the Styles panel, in a file whose name becomes the variation's identity. Drop this at styles/night.json:

{
	"$schema": "https://schemas.wp.org/trunk/theme.json",
	"version": 3,
	"title": "Night",
	"settings": {
		"color": {
			"palette": [
				{ "slug": "ink", "color": "#eaeaea", "name": "Ink" },
				{ "slug": "paper", "color": "#111214", "name": "Paper" },
				{ "slug": "signal", "color": "#ff9d5c", "name": "Signal" }
			]
		}
	}
}

It reuses the same slugs with different values, which is the point: every template and every block that references --wp--preset--color--paper changes with the variation and no template is touched. Confirm WordPress found it by listing the variations, which is also the check I ask for after any generated variation:

$ wp eval '
$v = WP_Theme_JSON_Resolver::get_style_variations();
echo "count: ", count( $v ), "\n";
foreach ( $v as $key => $variation ) { echo "variation: ", $variation["title"], "\n"; }'
count: 1
variation: Night

The title key is what the Styles panel shows and the file name is the identifier, so an agent that writes styles/dark.json with "title": "Night" has produced a variation called Night in a file called dark. Harmless, and confusing six months later.

What I have not done anywhere in this lesson is open the Site Editor. I verified theme.json by reading settings back through WP-CLI, and the front end by fetching it over HTTP. I did not click through the Styles panel, so I cannot tell you that the Night variation looks right in the canvas, only that WordPress registered it. That is the boundary of what a command line can prove about a block theme, and it is where a browser session or the official wp-playground skill earns its place.

Lab 30 minutes

Build a block theme from theme.json outward, and prove each claim with a read-back.

Create wp-content/themes/<your-slug>/ with a style.css header, a theme.json declaring a three-colour palette, one font family and one font size, templates/index.html, templates/single.html and the two template parts.

Then make four checks and keep the output:

wp theme activate your-slug
wp eval 'var_dump( wp_get_theme()->is_block_theme() );'
wp eval '$s = wp_get_global_settings( array("color","palette","theme") );
  foreach ( $s as $p ) { echo $p["slug"], " = ", $p["color"], "\n"; }'
curl -s http://localhost:8080/ | grep -o -- "--wp--preset--color--[a-z-]*" | sort -u

Now break it deliberately. Rename the palette key to colours and run the checks again. Write down what changed and what did not. Then rename the template-part slug in templates/index.html to hedaer and fetch the page. Two silent failures, two different detection methods.

Show the solution and the reasoning

The first break is a schema failure and the second is a structural one, and they need different tools because they fail at different layers.

Renaming palette to colours removes your presets and produces no error. The read-back is the detection method, and the shape of the output is the evidence:

$ wp eval '$s = wp_get_global_settings( array("color","palette") );
  foreach ( array_keys($s) as $origin ) { echo $origin, ": ", count($s[$origin]), "\n"; }' \
  2>&1 | grep -v Deprecated
default: 12

$ wp eval 'foreach ( wp_get_global_settings( array("color","palette") ) as $o ) {
  foreach ( $o as $p ) { echo $p["slug"], "\n"; } }' 2>&1 | grep -c signal
0

The deprecation notice is not yours: WP-CLI 2.12.0's own phar prints one under PHP 8.5, and it arrives on standard output, so 2>/dev/null does not hide it. Filter it or the line you were looking for will be buried.

The theme origin is gone entirely, so the display falls back to the twelve core presets while the file still contains a perfectly readable list of three colours. That is why the check has to look for your slug rather than count entries: a query that cannot distinguish "my palette" from "WordPress's palette" reports a healthy twelve on a theme that has no palette of its own at all.

Renaming the template-part slug to hedaer fails in the markup instead. The page still returns HTTP 200, the query loop still renders, and the site title and tagline are simply missing from the top of the page. The generated HTML contains the comment for the part but no rendered output, which is what makes this detectable from curl:

$ curl -s http://localhost:8080/ | grep -c "wp-block-template-part"
1
$ curl -s http://localhost:8080/ | grep -c "wp-block-site-title"
0

The file parts/hedaer.html does not exist, so there is nothing to render. WordPress does not warn, because a template part is allowed to resolve to nothing. The detection method is therefore a positive assertion about content you expect to see, not a status code. Checking for 200 OK would have passed both broken versions.

Both failures also explain why the read-back habit is worth the two extra commands. An agent cannot see either problem by re-reading the file it wrote, and it will report both as complete. A command whose output would differ if the claim were false is the only kind of verification that helps here.

Progress

Mark this lesson done and it stays marked in this browser.