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.
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.