Zero2Webmaster BLOG — all posts

Build notes: “The Terminal”

The journal family, build #2 of 2 — the Zero2Webmaster instance. Build #1 is the SAVE THE FROGS! Blog. This page records what build #2 did differently, what it could not do differently, and every decision made while reproducing a real WordPress post.

Why this build exists

It is not a portfolio piece. Under the parameterize-after-two rule, the shared journal layout may only be factored out once a second build exists, and only the things both builds agree on may be hard-coded. So build #2's job was to disagree with build #1 on all sixteen pre-declared axes on purpose — otherwise the eventual template would encode one page's taste and call it a family.

It is also the real future home of zero2webmaster.com/blog after WordPress. That is why the demo reproduces a live post verbatim rather than showing invented copy: anything this template cannot express is content that cannot migrate.

The 16 axes

Twelve inverted cleanly. Four could not invert at full strength, and in every one of those four cases the reason is the same rule: an axis may not be inverted by breaking a family invariant. Where that happened it is recorded here rather than quietly resolved one way or the other.

#Axis#1 — SAVE THE FROGS!#2 — Zero2WebmasterInversion
1groundwarm paper #FBF9F5true dark #0C0F0Cinverted
2bodyFacehumanist sans (Nunito Sans)reading serif (Literata)inverted
3displayFacegeometric slab (Rokkitt)neo-grotesk (Space Grotesk)inverted
4measure66ch → 832px / 88 real chars72ch → 800px / 83 real charsinverted, but see below
5railleft rail, 13.5remnone — centered measureinverted
6railContentsmeta + TOC + notes + captions— all nine items relocatedinverted
7heroModetitle-firstsplit (7/5)partial — see below
8figureSpanstext / wide / fullfull onlyinverted
9figureTreatmentplainplain + drop shadow — duotone RETIRED on evidenceretired — see below
10accentRoleunderline + markerfill + blockinverted
11linkStyleresting offset underlinerule-on-hoverinverted
12motionreading (restrained)expressive — furniture onlypartial — see below
13archiveModeindex rows + cursor imagedense sortable tableinverted
14endMatterfull stack (7 items)minimal (prev/next + byline)inverted
15toccriteria-gated, collapsed, in the railcriteria-gated, collapsed, inlinePLACEMENT only — default state retired as an axis
16defaultThemelight defaultdark defaultpartial — see below

Axis 7 — heroMode: partial, because overlay is anti-pattern #1

The pre-declared table offered “overlay or split”. Overlay — a headline in white type over a busy full-bleed photo — is the family's first anti-pattern, so it was never available. Split is a genuine inversion of title-first and breaks nothing: the headline still never sits on the photograph, and the largest contentful paint is still text.

Axis 12 — motion: partial, because prose never animates

“Expressive” is bounded to the furniture: the hero entrance, the H2 accent blocks drawing down, the figures and code block rising once, the dividers, and the archive table's FLIP reorder on sort. A body paragraph still never animates, and that is not a convention — scripts/verify-journal.mjs fails the build if any CSS rule pairs an .abody paragraph selector with opacity or transform.

Axis 15 — toc: partial, because the criteria are an invariant

The gate itself could not change. What inverted is placement and default state: build #1 put a collapsed <details> in its left rail; build #2 has no rail, so the TOC is expanded and inline, directly after the deck. The gate still ran as a build-time decision and logged the numbers it used:

CriterionThresholdMeasuredResult
Is a reading surfacetruetruepass
Qualifying headings≥ 415 H2pass
Word count> 1,5002,593pass
DecisionTOC RENDERS

15 H2 plus 18 H3 is 33 candidate entries — a flat expanded list that long is a wall, not a table of contents. So this build ships the thing build #1 never needed: H2 entries only at rest, with the active section revealing its own sub-sections as the reader passes through it. Tracking is IntersectionObserver, never a scroll handler, and the active entry carries aria-current="location".

The negative case is asserted too. The archive is not a reading surface, so it ships zero TOC markup and zero TOC JavaScript, and the verifier fails if any leaks onto it — that is how “no TOC” stays distinguishable from “TOC broken”.

Axis 4 — measure: the axis reads backwards, because ch is the wrong unit

The pre-declared axis is 66ch → 72ch, which reads as “build #2 is wider”. Measured on the rendered pages, it is not:

BuildAxis valueRendered measureReal characters per line
#1 — SAVE THE FROGS!66ch of Nunito Sans 21px832px88
#2 — Zero2Webmaster72ch of Literata 21px800px83

Build #2's nominally wider measure is narrower in both pixels and characters, because ch is the width of the digit zero in that element's own font — and Nunito Sans, a UI sans with a large x-height, has a much wider zero than a text serif. So the axis compares two different rulers.

This also produced a real bug. A custom property holding ch resolves at its use site, so width: min(72ch, 100%) computed 800px on a 21px serif paragraph and about 1,642px — collapsing to 100% — on a 38px Space Grotesk H2. The prose sat in the measure while every heading ran full-bleed and the H2 accent block hung off a different left edge than the text it labels. The measure is now resolved once, to 50rem, so every block shares it.

Consequence for the parameterization: the measure axis must be an absolute length (or a target characters-per-line), never ch. Two builds expressed in ch cannot be compared, and the shared engine would inherit a knob whose numbers point the wrong way. The verifier now fails if --measure is declared in ch.

Axis 16 — defaultTheme: partial, and this one is a judgement call worth stating plainly

Kerry ruled on 2026-08-06 that both blogs ship light and dark, so what used to be a theme count axis became a default axis, and dual-theme-with-a-persisted-toggle became a hard-coded family invariant instead — which is the parameterize-after-two rule working exactly as designed: agreement collapses into the engine.

Where this build narrows its own brief. The brief said dark is “the one every first-time visitor lands in”. It also said prefers-color-scheme is honoured. Those two cannot both be absolutely true, and this build chose to honour the reader's operating-system preference: defaultTheme governs the case where the reader has expressed no preference at all, and a light-OS visitor lands in light. Two reasons. Overriding a declared accessibility-adjacent preference on a 11-minute reading surface is a worse outcome than losing a brand impression; and this is the definition that stays parameterizable, which is the whole point of build #2. The dark identity is still what the page is designed on and still what most visitors and every no-preference visitor see.

Type

Three faces, all free, all self-hosted as latin-subset variable WOFF2 in /z2w-blog/fonts/. Zero font-CDN requests — same hard rule as build #1, asserted by the verifier.

RoleFaceWhy this one
Display — H1, H2, archive titles, prev/nextSpace Grotesk 300–700Zero2Webmaster's own established face — it is what /web-craft/ already uses, so a migrated Z2W post reads as the same company. The same continuity argument that decided build #1's body face, applied where Z2W actually has a face.
Body — all proseLiterata 200–900 + italicThis is axis 2 doing its job. Build #1 wanted a reading serif and could not have one (STF brand continuity won). Z2W has no body-face mandate, so the family finally gets to test the better long-form face. Optical size is pinned to the text end (opsz 14): letting a two-axis serif default to display optical sizing is how thin serifs bloom on a dark ground.
UI, captions, codeJetBrains Mono 400–700Deliberately not build #1's IBM Plex Mono. If both builds used the same mono, the eventual engine would hard-code a face neither build ever chose on the merits.

Body sets at 21px / 1.72 over a 72ch measure — wider than build #1's 66ch because a serif sets narrower per character and the code block wants the room. Nothing on any of these pages renders below 16px, captions and UI included; the verifier judges a clamp() by its minimum and rejects any em-based size below 1em, with no allowlist.

Licence. All three faces are licensed under the SIL Open Font License 1.1, and serving a .woff2 from a public page is redistribution — so the licence and the copyright notices ship alongside the fonts, at /z2w-blog/fonts/OFL.txt. The notices there were read out of the name table of the exact files this page loads, not from an upstream README.

Colour — two hand-built themes, and the trap this build was warned about

Neither theme is an inversion of the other; the light palette has its own ink values. Every ratio below is recomputed at build time from the shipped tokens, so this table cannot drift from the pages.

ThemeTieron --paperon --panel
darkbody ink #EDF1E816.84:1 ✓15.42:1 ✓
darkmuted ink (deck, captions, meta) #A3B0988.47:1 ✓7.75:1 ✓
darkaccent text (links) #A8E05312.35:1 ✓11.31:1 ✓
lightbody ink #171A1216.50:1 ✓14.95:1 ✓
lightmuted ink (deck, captions, meta) #4E54457.35:1 ✓6.66:1 ✓
lightaccent text (links) #436E0E5.67:1 ✓5.13:1 ✓

The per-surface trap caught a third victim. Zero2Webmaster's existing green text tier #4a7a10 — the value /portfolio/ and /web-craft/ use — measures 4.82:1 on this light paper but only 4.37:1 on the light panel. It passes a palette audit and fails a surface audit. The light theme therefore ships #436E0E, which clears AA on both. This is the same failure as the broadsheet's #ab5200 and build #1's #964800: contrast is a property of a pairing, not of a palette.

And the trap axis 10 creates. Making the accent role fill + block puts far more text on filled surfaces than build #1 had, and a fill is a ground the token audit does not automatically cover:

ThemeFill pairingRatioThe alternative that would have shipped
darklabel #0C0F0C on #82C92B (accent fill)9.49:1 ✓white on the same fill: 2.03:1 ✗
darklabel #0C0F0C on #FFD700 (gold fill)13.75:1 ✓white on the same fill: 1.40:1 ✗
lightlabel #171A12 on #82C92B (accent fill)8.67:1 ✓white on the same fill: 2.03:1 ✗
lightlabel #171A12 on #FFD700 (gold fill)12.55:1 ✓white on the same fill: 1.40:1 ✗

Every accent or gold fill on both themes takes near-black ink. White on #82C92B measures 2.03:1 and is now a permanent regression pin: the verifier feeds that exact pair through the gate on every build and fails if it is ever accepted. Gold #FFD700 is rationed to a single role and appears nowhere as text.

No rail — where the nine relocated things went

Removing the rail (axis 5) was the most consequential inversion in the set, because build #1 put nine things in it and “delete it” is not an answer for any of them.

Was in build #1's railWhere it lives in build #2
date / author / reading timea single meta line above the H1, JetBrains Mono, hairline above it
table of contentsexpanded and inline, after the deck
H2 section markersa 6px accent block flush to the measure's left edge, hanging into the bleed
figure captionsunder the figure, JetBrains Mono, muted ink
margin notesinline at every width — build #1's mobile treatment becomes the only treatment
footnote previewsnot exercised: the source post has no footnotes (see the block coverage note below)
scroll progressa 2px hairline at the very top of the viewport
pull-quote hangcentred in the measure with a 3px accent rule instead of a hang
blockquote attributionits own line under the quote, JetBrains Mono

The demo post, and what the harvest got wrong

The post is a verbatim reproduction of https://zero2webmaster.com/cursor/ — “Gain Coding Superpowers: How Non-Coders Build Real Software”, published 2025-12-15, by Dr. Kerry Kriger. Its canonical points at the original, because a reproduction must not compete with the real post in search. It was chosen on evidence: of the twelve longest posts on the blog it is the only one with a real code block, and it has by far the deepest heading nesting.

The harvest is committed and re-runnable (node scripts/harvest-z2w-blog.mjs --images), deliberately fixing a build-#1 limitation — build #1's harvest tooling lived in a session scratchpad and was thrown away, so nobody could re-check what the source said.

A fourth harvest finding, found during this build

The brief carried three findings to handle. A fourth turned up while converting: WordPress's content.rendered ends with a Kadence Post Grid — a “More Articles You May Enjoy” related-posts widget — inside the content field. It contributes six <h2> and six <img> that belong to other posts, and its titles land in the word count.

FigureCounting content.rendered rawCounting the post body
words2,6662,593
H22115
H31918
images2923

This is build #1's word-count lesson in a second costume — there, Kadence inline CSS text was being counted and the first measure read 3,098 words instead of 2,030. The harvest now reports both numbers on every run and names the difference, and the guide publishes both, because a single number is exactly how a related-posts widget gets mistaken for six extra sections of an article. Log the numbers the gate actually used, always.

The three findings the brief carried

Block coverage, corrected

The brief predicted this post would rehearse code and deep nesting but no quotes. It has two real quotations — one from Chris Davis of Automation Bridge, one from Claude — marked up in WordPress as Kadence “advanced heading” paragraphs rather than <blockquote>. The inventory counted tags; the content has quotes. So the union of the family's two demo posts covers the whole 16-block library on real content except table and footnote, and the table specimen comes from the real one on zero2webmaster.com/self-hosting/ rather than being invented.

BlockCount on this page
lists21 top-level (23 including 2 nested)
H2 / H3 / H415 / 18 / 4
figures (single)12
figure groups2
images total23
blockquotes2
code blocks1
facade embeds1
dividers14

Every per-element conversion decision

The body is verbatim, but “verbatim” still requires decisions about markup that has no equivalent in this template. Each one is listed so it can be argued with.

Source elementBecameWhy
Kadence Post Grid ("More Articles You May Enjoy", 6 articles)droppedTheme furniture inside content.rendered, not body copy — it carries 6 <h2> and 6 <img> that the harvest inventory counted as the post's. 16,781 chars removed. Axis 14 drops related posts regardless.
38 inline <style> blocks (Kadence per-block CSS)droppedWordPress theme plumbing; its text nodes are also what inflated build #1's first word count.
<p class="kt-adv-heading"> beginning with a curly quoteBlock 5 — blockquoteContent-shaped, not markup-shaped: these ARE quotations with attributions. The brief's "0 blockquotes" came from counting <blockquote> tags; the post has two real quotes. Rendering them as paragraphs would have lost the attribution semantics.
<figure class="alignright"> Cursor-Ai-Logo-PNG-SVG-Vector.pngBlock 7 — figure, full spanAxis 8 makes every figure FULL span, so the source's alignright is not reproducible. Recorded rather than silently dropped.
<figure class="aligncenter"> Cursor-Chat-Window-Looks-Like-Claude-1-1-2048x1708.jpgBlock 7 — figure, full spanAxis 8 makes every figure FULL span, so the source's aligncenter is not reproducible. Recorded rather than silently dropped.
<figure class="wp-block-embed is-provider-youtube"> (video bA-WmidVSGo)Block 14 — facade embedFIGURE-vs-FACADE, decided: the source IS a YouTube embed block; zero2webmaster.com merely renders it through its own poster facade, which is why the harvest counted 0 <iframe>. Reproducing it as a plain <figure> would have turned a video into a picture. It ships as the family facade: no iframe until click, real video title on the button, a <noscript> link out.
<img> Airtable-Results-Vimeo-Bunny-Migration-Cursor-AI.jpgBlock 7 — figureSource has alt="" — a BUILD FAILURE in this family. Alt written from what the screenshot plainly shows and DRAFT-flagged for Kerry.
<pre class="wp-block-code"> (the blog's only code block)Block 11 — codeLanguage read from the content (KEY=value, no comments) → labelled .env. Highlighted at BUILD TIME; no highlighter JS ships.
<p class="kt-adv-heading"> beginning with a curly quoteBlock 5 — blockquoteContent-shaped, not markup-shaped: these ARE quotations with attributions. The brief's "0 blockquotes" came from counting <blockquote> tags; the post has two real quotes. Rendering them as paragraphs would have lost the attribution semantics.
3 consecutive <figure> elementsBlock 8 — figure groupA run of adjacent images with one trailing caption is a gallery in the author's intent; stacking them as separate full-span figures would have made the page 7 screens of screenshots.
7 consecutive <figure> elementsBlock 8 — figure groupA run of adjacent images with one trailing caption is a gallery in the author's intent; stacking them as separate full-span figures would have made the page 7 screens of screenshots.

Two things this converter deliberately does not do. It does not turn the manually numbered roadmap paragraphs (“1. … 2. … 3. … 5.”) into an <ol>, because that would renumber them and quietly hide that 4 is missing in the source. And it does not strip the source's <strong> runs: “no bold scattered through body paragraphs” is a rule about copy we write, and this copy is the author's.

One honest note on imagery: the post's own art was created in Midjourney by Dr. Kerry Kriger and is credited as such in the source caption, which is reproduced. The family's no-AI-imagery rule is a SAVE THE FROGS! standing rule about images we generate for a page; it does not license us to alter an author's credited illustrations.

The archive — why a table

Axis 13 inverted to a dense sortable table, and the content argued for it rather than the axis dictating it. Of 22 posts, 20 are in Learn. Build #1's archive leaned on a six-category filter row; build #2 has no real taxonomy to filter on and must not fabricate one. 22 posts also fit on one page, so there is no paging.

Reading times are measured, not estimated. The harvest fetches every post's full content and counts real prose words; the table divides by 230 words per minute. An invented minute-count on a page reproducing someone's real work would be an invented statistic.

⚑ Awaiting Kerry's review

The decks are derived, not written — and that is the one thing worth checking. All 22 archive decks are taken verbatim from each post's own first paragraph, cut at a sentence boundary. Nothing was authored about Kerry's work. Build #1 needed 15 hand-written DRAFT decks; this build needed none, because the derivation replaced the authoring. What is still worth an eye: a first paragraph is not always a good standfirst, so any deck below that undersells its post should be replaced with one Kerry writes.

DRAFT — written by the build, 1 item:

  • alt text for Airtable-Results-Vimeo-Bunny-Migration-Cursor-AI.jpg — “Airtable grid showing migrated video records with Bunny.net video IDs, durations and file sizes filled in”

Written from what the screenshot plainly shows, asserting nothing beyond that. Marked DRAFT in the source.

PostDeck as shipped (verbatim from the post's opening paragraph)
daily-work-summaryI’m a builder. On any given day I might push a dozen commits across five different projects — adding a plugin feature here, fixing a bug there, refactoring an API integration somewhere else.
ai-costI just instantiated a new project: a WordPress plugin for online event management — Zoom webinars, recurring meetings, registrations, reminders, follow-ups, recordings, and calendar integration.
cloudflareHow Zero2Webmaster delivers $72,000/year Cloudflare Enterprise protection to every client — at no extra cost.
admin-suite-speedtestYou probably didn’t install 21 plugins on purpose.
investment-trackerNo Privacy Compromises. Just Smart Automation.
cursorMy annual Vimeo renewal was coming up, but I didn’t want to continue at $300 per year knowing I can host videos on Bunny.net at significantly lower cost.
spotify-ice-tidalThe recent controversy surrounding Spotify’s ICE recruitment advertisements has prompted many users to reconsider their streaming service choice.
vpn-payments-blockingIf you’ve ever tried to make an online purchase while using a VPN, you may have encountered a frustrating scenario: your payment gets rejected.
wordpressChoosing the right platform for your website is one of the most important decisions you’ll make for your business or nonprofit.
website-importanceIt’s a question we hear surprisingly often: “Does my business actually need a website?”
domain-name-salesIf you’ve ever bought a domain name and then let it sit unused, you’re not alone.
professional-email-addressIf you’re running a business, your email address plays a crucial role in how your customers perceive you.
cloudflare-developer-accessManaging DNS settings is an essential part of web development, but it can be complex and technical.
domain-redirect-cloudflareIn the video below, you will learn how to quickly set up a domain redirect in Cloudflare!
crmThe term CRM (Customer Relationship Management) means different things to different people. For some, it’s simply a tool to manage contacts and track interactions.
self-hostingThe growing availability of high-quality open-source software is transforming the technology landscape, offering powerful alternatives to traditional SaaS platforms.
qr-codesJoin Zero2Webmaster Founder Dr. Kerry Kriger as he takes you through an enlightening tutorial on how to create beautiful, branded QR codes for your printed materials.
successful-websitesIn today’s digital age, a website can make or break your business. It serves as a virtual storefront, a sales rep, a customer service agent, an educator and a marketing tool.
backupsIn today’s digital age, where our lives are increasingly intertwined with technology, the importance of backups cannot be overstated.
controlIn the fast-paced world of digital commerce, establishing a robust online presence is non-negotiable for businesses of all sizes.
social-media-versus-business-websiteIn today’s digital age, social media platforms have become powerful tools for businesses to connect with their audience, build brand awareness, and drive sales.
common-website-mistakesIn today’s digital age, having a website is essential for any business or individual looking to establish an online presence.

Verification

The verifier was extended, not forked. scripts/verify-journal.mjs now covers public/journal/ and public/z2w-blog/ from one definition of the family — a family with two verifiers has two definitions of itself. All existing groups apply unchanged: imagery (alt + explicit dimensions + the local file exists), the 16px floor, per-surface contrast in every theme, the TOC gate in both directions, prose-never-animates, facade-only embeds, self-hosted fonts, and the SEO shell on every page.

Three assertions are new for build #2, and each was canary-tested — broken on purpose, the build confirmed failing, then restored. An un-canaried assertion is an assertion nobody has shown to work.

  1. Fill contrast, in both themes, with white-on-#82C92B (2.03:1) fed through the gate on every run as the regression pin.
  2. The default theme — build #2's :root must carry the dark ground and build #1's the light one. The default is now the axis, and it is the part a later edit can silently flip.
  3. No client-side syntax highlighter — highlighting is done at build time; the pages must load zero highlighting JavaScript.

Four more were added on Kerry's first review (group 1c), all canary-tested the same way: every --nat must equal the width attribute of the image it governs; no CSS rule may leave an image unbounded; each page must declare the number of caps it is known to need — without which the group would pass on a page carrying no caps at all; and the page that declares --nat must also ship the rule that consumes it, which is the one that caught a bug introduced by this very fix. The verifier now stands at 542 assertions over both builds.

What the harness found — thirteen bugs that existed only in rendered pixels

Three Chrome DevTools passes over two themes and four breakpoints, screenshotting rather than reading code. Every item below passed code review, passed the verifier, and was wrong on screen. They are listed because the pattern matters more than the individual fixes: five of the thirteen are one CSS declaration silently cancelling another, and no amount of reading either declaration in isolation reveals it.

What was wrongWhy nothing caught it
The entire left column of the hero was invisible — headline, deck and byline at opacity 0, with the layout still reserving their space.The entrance rule restored .in .hw (a .hw inside an .in) while the script adds .in to the .hw. Descendant where it needed compound.
Body prose sat flush left while headings and the code block were centred..abody > * sets margin-inline: auto; a later margin: 0 0 X shorthand resets it to 0. Both rules are correct alone.
Every heading ran full-bleed instead of sitting in the measure, so the H2 accent block hung off a different left edge than the text it labels.ch is font-relative and a custom property resolves at its use site: min(72ch, 100%) computed 800px on a 21px serif and ~1,642px on a 38px grotesk. See the axis-4 note above.
On a phone, the H1, deck and byline were flush against the screen edge..ahead's padding: X 0 0 shorthand zeroed the inline padding .wrap supplies. Invisible on desktop, where the hero band was narrower than the viewport anyway.
The mobile header wrapped to two lines and pushed the theme toggle off-screen — a control not merely small but absent.Nothing measures whether a flex row still fits at 390px.
The archive's duotone silently did nothing, and its lead-post meta line rendered as one run-on string.The per-page stylesheet split followed where rules were WRITTEN, not which pages USE them. The markup was right and the CSS was right, on different pages.
The archive and these build notes set their H1 in the body serif at body line-height.Same split, but an element rule — invisible to a class-coverage check.
This page's own “failing ratio” badge was unreadable in dark mode (1.23:1) — the page documenting the fill trap had fallen into it.It is a label on a fill, and it was not in the fill list. It is now.
The four-column table was unusable on a phone: every row a tower of wrapped words, two rows filling the screen.It technically passed — the table scrolled inside its own container and the page never scrolled sideways. "No overflow" is not "readable".
The TOC's active tracking and sub-section reveal never fired at all.The active section was read from the intersecting entries inside a narrow band. A heading at the top of the viewport is already past it, and an instant jump crosses it between callbacks, so the list came back empty and nothing was ever marked.
Revealing sub-sections reflowed the whole article under the reader — and self-defeatingly, since the shift moved the next heading back across the reading line, so tracking oscillated and the text twitched at every boundary.A direct consequence of axis 15 moving the TOC inline, above every heading. Nothing about the axis says so.
Returning to the Date column after sorting by Read gave oldest-first while the column announced aria-sort="descending".The direction sign was applied one way for reading time and the opposite way for date. The label and the order disagreeing is worse than either alone.
The search field's only focus signal was a 1px border colour, weaker than every other control on the page..search:focus { outline: none } out-specified the page's focus ring, because a :where() rule has zero specificity.

And two that were measured rather than seen. The largest contentful paint was 1,201ms, and the cause was the page's own choreography: the H1 is the LCP element and the hero entrance faded it in, so first contentful paint landed at 588ms and the headline was not fully visible until 1,204ms. A split hero exists partly to keep LCP as text; fading the headline gives that away. Second, the hero image was the 1,232px original rendering into a ~500px box — 256KB for nothing. With the headline no longer withheld and the hero served at 768px, LCP is 386ms and CLS is 0.00. Lighthouse: 100 / 100 / 100 (accessibility, best practices, SEO) on all three pages, desktop and mobile, zero failed audits.

The verifier grew four guards out of this pass, each canary-tested: a page may not use ch for a length; every class in a page's markup must have a rule on that page; the H1 must be typed on every page that has one; and — after a generator crash let the verifier report "all assertions passed" against the previous build's HTML — every generated page must be newer than its generator and its input data. That last one is the most important thing in this file: a green check that cannot tell you whether it examined current output is not a green check.

What Kerry's review changed — four fixes, and why three of them are the same mistake

Kerry read the shipped pages on 2026-08-07 and sent four notes. They are worth recording together because three of the four are one failure repeated: a value that satisfied its rule while failing its job. Each is now a build assertion, so the rule and the job cannot drift apart again.

What Kerry sawWhat was actually wrongThe fix, and the gate
“Grey on black is a bit hard to read. Lighten it up a bit.” — the archive. Not a contrast failure. --ink-mut measured 8.47:1 on the dark ground, comfortably AAA. It was doing two jobs: dimming mono uppercase label furniture, where dimness is the design, and colouring every prose sentence that is not body copy — the standfirst, the lead deck, all 22 row decks. The same grey that reads as restraint on a 16px tracked-out label reads as washed-out on a 20px sentence, at an identical ratio. The tier was split rather than raised, because raising it would have flattened the label hierarchy the page depends on. A new --ink-read (12.36:1 dark, 12.13:1 light) takes all non-body prose; --ink-mut is now labels only. Contrast ratio is a floor, not a judgement about whether text is comfortable to read.
The post's standfirst “is not necessary… it looks like the first sentence of the article placed by itself as a subheader.” It was the first sentence. The post reused the machine-derived archive deck as a subtitle, so the page opened by saying the same sentence twice. A subtitle is authored or absent — never derived. subtitle is now an optional field; the demo post has none, so none renders. The archive keeps its decks, because an archive deck is an excerpt standing in for a page you have not opened, whereas a subtitle is part of the title — an editorial claim about what the piece is. Deriving one manufactures authorial intent nobody had. The build refuses a subtitle equal to the derived deck.
The three marks between sections “look like dots you click to move through a slideshow / carousel.” They were three small rotated squares, evenly spaced, centred. The shape was never the point — a short centred row of evenly-spaced small marks is the web's pagination idiom, and at that size the idiom wins over the intent. A ruled break with a single centred mark. A section break has to look like a break, which means it has to span something. Same three spans in the markup, so the block-15 contract did not move.
“This is way too big… Ideally images get shown at their actual pixel width.” Every figure rendered span-fullwidth: 100% with no ceiling — so each image stretched to the viewport whatever its real size. The one Kerry noticed was nowhere near the worst: the flagged banner is 1,448px and only soft above that, while the same rule upscaled a 1,024px screenshot 1.9× and the 300px Cursor logo 6.4× on a 1,920 screen. The band is now chosen from the image's own pixels, and every image carries a --nat cap at its natural width. Upscaling is not unlikely, it is unrepresentable — so there is no ratio to tune. Build #1 had the identical defect in one rule and was fixed in the same pass, because a family invariant that only half the family obeys is two definitions of the family.

The image work found its own bug, in the fix. The cap is a two-part mechanism — a --nat declaration in the markup and a rule that consumes it — and this family splits its stylesheet per page. The consuming rule was first written next to .fig, which is post-only, so the archive shipped --nat in its markup with nothing on that page to honour it: max-width computed to none and the lead image went on upscaling, while the new assertion passed because it was reading the markup half of a two-part mechanism. It is the same shape as the stylesheet-split bug in the table above, and the same shape as the Rank Math retitle that preceded this build — one true surface taken as proof of all surfaces. The verifier now asserts that the page declaring --nat also ships the rule consuming it.

Also this pass: a build was failing while its output was being read as current. A stray backtick in a CSS comment broke the stylesheet (the trap this file's own header warns about), the generator exited non-zero, and the previously-emitted HTML was still sitting on disk looking perfectly plausible. The freshness gate added in v1.16.0 is what makes that recoverable — and it is the reason it exists.

Round two of Kerry's review — and the axis that was wrong about its own content

A second pass on 2026-08-11. The headline item is not a bug at all: axis 9 gave this build a duotone, and it was working exactly as specified. The problem is that the axis was assigned to the FAMILY before anyone looked at what this build's images actually are.

Twenty-one of this post's twenty-three images are screenshots — a chat window, an Airtable grid, a terminal, a file tree. A duotone is an editorial treatment for photography, which is why build #1 (frog and event photography) wears it happily. Applied to a screenshot it paints every white UI panel a slab of #82C92B, which destroys the one thing a screenshot exists to convey. Kerry: “why does it have a green background? I think all the images got green backgrounds.” He was right, and no contrast gate or alt-text check could ever have caught it, because nothing was broken — the treatment was simply wrong about its subject. Same failure as the brief predicting a “code-heavy” blog that turned out to be prose: an axis value is a claim about the content, and it has to be measured against the content.

So the rule is now a tonal treatment may be applied to photography and must never be applied to a screenshot, and since a generator cannot reliably tell the two apart, the default is off. The treatment is kept as an opt-in for art, and the verifier asserts both halves: that nothing is toned by default, and that the opt-in still blends on the wrapper — because deleting the feature would have deleted the v1.14.0 lesson with it (an element blends against its backdrop, not its own background).

What Kerry asked forWhat shipped, and the reasoning
“Get rid of FIG.”Gone. A label that says only this is a figure, directly beneath a figure, is furniture — a word and no information. The group label survives (“3 figures”) because it tells the reader the caption covers several images rather than the nearest one.
“The table of contents should be closed by default… perhaps a toggle that is easy to identify.”Collapsed, on native <details>/<summary> so the disclosure semantics, keyboard behaviour and announced state are the browser's and survive with JavaScript off. “Easy to identify” is read as a word — SHOW / HIDE in a bordered control with a rotating chevron, not a bare caret. The verb is written by CSS from the open state, so the label and the state cannot drift apart. This also retires half an axis: Kerry has now asked for collapsed twice, twelve days apart, so under parameterize-after-two the default state collapses into the engine and only placement stays configurable.
“I usually like them with a bit of drop shadow so it seems they hover off the page a bit.”A three-layer cast shadow, tuned separately per theme — a shadow built for a light page is invisible on a dark one, so reusing one set of values would have shipped the feature to only half the readers. The hairline stays, because a shadow needs an edge to cast from.
The 7-image group “needs more gutter space between each image.”The gap was 2px, inherited from the broadsheet where abutting cuts are a newspaper convention and every image is an engraving on the same paper. Here they are separate screenshots that each need their own edge — now a fluid gutter. Removing the duotone then exposed a second bug: the grid was stretching all cells to a common height, so shorter screenshots grew a panel slab underneath. Each cut now keeps its own height.
“Will there ever be an option for thumbnails displaying for each post made from the featured image?”They are on. Three of the 22 posts have no featured image, and that is the whole design problem — an empty cell in an image column reads as a broken build, not as “no picture”. The absent case is drawn, carrying the post's initial: nothing to fetch, nothing to 404. In the parameterized layout this becomes an axis, and the two builds already bracket it — #1's archive is a grid of cards, #2's is a dense table, and a table carrying a small identifying image is the useful middle their disagreement points at.
“Please see the Z2W Admin Suite plugin's YouTube functionalities and ensure we have all of those available.”Read class-youtube-enhancer.php and matched its parameter set, so a post behaves the same after it migrates off WordPress: no-cookie host, rel=0, iv_load_policy=3, modestbranding=1, playsinline=1, and enablejsapi + origin for auto-pause on tab switch. playsinline was the consequential omission — without it iOS Safari hijacks the screen into the native fullscreen player the moment you tap, throwing the reader out of the article, and it is invisible on every desktop browser. Two features are deliberately not matched: server-side view tracking (there is no server; a play-count beacon is the opposite of what a no-cookie facade is for — it belongs in a Fathom event) and the custom-thumbnail admin UI (the data half works, but there is no admin here).

And one assertion that could not fail. The YouTube parity checks were first written as page.includes('playsinline=1'). The canary that deleted playsinline from the parameter string still passed — because the long comment above the facade explains the parity list and ships inline in the page, so the check was matching its own documentation. It now parses the parameter string out of the page and tests that. An assertion that cannot distinguish its two outcomes is decoration, and the only way to find out is to break the thing on purpose.

Verifier: 542 → 585 assertions. Six of the new ones were canary-tested; the seventh is the one described above, which was canary-tested, failed to fail, and was rewritten.

What this unblocks

With two builds shipped, the journal family passes the parameterize-after-two gate. Everything the two builds agree on gets hard-coded into the shared layout — the 16-block library, the vertical-rhythm system, the contrast gate (now including fills, over both themes), the alt/dimension gate, the SEO shape, the TOC criteria, the 16px floor, prose-never-animates, facade-only embeds, self-hosted-fonts-only, and dual light/dark with a persisted toggle, which stopped being an axis the moment both builds needed it. The sixteen axes become the config surface, with these two builds' values as the proven extremes.

That work is deliberately not started here. A build already thinking about the shared abstraction converges toward it, and then the parameterization is derived from one taste after all.