CLAUDE.md · diff

git:20260917.dc5a726 to git:20260919.f7e8231

181 added, 11 removed. Audit A to A.

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
A **Claude Code plugin**, not an application. Almost everything here is markdown that instructs
Claude at runtime — commands, agents and skills. There is no build step, no package manager, no
runtime for the plugin itself. The only executable code shipped is:
- - `bin/*.sh` — env setup / ffmpeg wrappers, invoked by commands
+ - `bin/*.sh` — env setup, ffmpeg wrappers and gate scripts, invoked by commands
+ (`wp-env-setup`, `wp-cinematic-encode`, `composition-gate`, `doc-sync-check`,
+ `geo-scan`, `tailwind-native-check`, `tailwind-rebuild`, `design-md-index`,
+ `domains-import`)
+ - `bin/*.mjs` — Node tools a command runs and reads the exit code of. These carry
+ real logic, and several are load-bearing gates rather than helpers:
+ `wp-config.mjs` (the single definition of a valid `.wp-create.json` — see
+ *Config contract* below), `demo-verify.mjs` (the browser walk behind `/wp-demo-verify`
+ and craft mode's probe), `tailwindify-parity.mjs` (computed-style comparison during
+ CSS conversion), `image-gen.mjs` (the image generator and its cost/key handling),
+ `composition-preview.mjs` (renders `skills/wp-demo-craft/compositions/` previews).
+ `bin/lib/` holds what they share.
- `skills/wp-polylang/scripts/*.php` — run inside a real WordPress via `wp eval-file`
- `starter-theme/**` — PHP copied into user projects (never executed here)
Editing a "feature" therefore usually means editing prose contracts, and the tests assert on
those contracts (grep for required tokens in the markdown), not on program behavior.
## Testing
Each check is a standalone bash script that prints `PASS` or exits non-zero. No runner, no framework.
```bash
bash tests/checks/wp-yolo-gate.sh # one check
for f in tests/checks/*.sh; do bash "$f"; done # all checks
```
`tests/checks/wp-polylang-live.sh` is the only test that touches a real site; it exits 0 with
`SKIP` unless `PLL_TEST_SITE` points at a WordPress root with Polylang active
(`PLL_TEST_SRC`/`PLL_TEST_DST`/`PLL_TEST_THIRD` override the languages; the third language is
created and deleted by the suite and is guarded by a pre-existence probe).
- There is no CI. Run the checks yourself before claiming a change works.
+ `.github/workflows/ci.yml` runs all of it on every pull request: the checks above (all of
+ them, aggregated — it does not stop at the first red), `node --check` on `bin/*.mjs`,
+ `php -l` on every PHP file, and `bin/doc-sync-check.sh`. Run the checks yourself before
+ claiming a change works; CI is the net under that, not a replacement for it.
+ **The PHP lint is version-pinned, and that is the point.** It lints at **7.4** — the floor
+ `skills/wp-polylang/scripts/*.php` carry, because they run via `wp eval-file` inside
+ whatever WordPress the client has — with `starter-theme/__cinematic__/` held to **8.0**,
+ the floor it declares in its own `style.css`. The runner's default PHP is 8.x, and `php -l`
+ under 8.x accepts `match`, union types and nullsafe calls, every one of which is a fatal
+ parse error on 7.4; linting at the default would be green and wrong, which is worse than
+ not linting, because it reads as proof. Nothing in the tree uses 8.0-only syntax today, so
+ adding some fails CI and raising a floor stays a deliberate act.
+
+ **CI now stands up a WordPress, for one thing.** `tests/fixtures/wp/provision.sh` builds a
+ disposable install — pinned WordPress, Polylang and SCF, against a database from a CI
+ service container or a throwaway docker container locally — and
+ `tests/checks/wp-polylang-integration.sh` runs the ACF translation path inside it, then
+ tears the whole thing down. It skips unless `WP_FIXTURE=1`, because provisioning downloads
+ WordPress and making that the price of running the suite would mean people stop running it.
+
+ That job is the only place here that reaches the network on purpose: a WordPress fixture
+ cannot exist without a WordPress. Every version is pinned so an upstream release cannot
+ change a result, and nothing it installs is asserted against — every assertion runs against
+ the local install.
+
+ It pays for itself. Its first two runs found two defects no contract grep and no pure test
+ could reach: `get_field_object()` returns false on a post with no value for the field, so
+ the translation writer skipped every write to a brand-new counterpart; and
+ `PLL()->model->add_language()` is defined only on `PLL_Admin_Model`, which Polylang
+ instantiates only when `is_admin()` is true — never under `wp eval-file`, which is how
+ every script in `skills/wp-polylang/scripts/` is documented to run.
+
+ Still not covered: a generated site end to end, the broken-site corpus with expected audit
+ findings, and browser artifacts for failed functional scenarios. A green CI means the
+ contracts still say what they should, the code still parses, and the ACF translation path
+ still agrees with a real WordPress.
+
## Architecture
Four layers, strictly ordered:
```
commands/*.md user-facing slash commands — orchestration, argument parsing, step-by-step flow
↓ dispatches
agents/*.md specialized subagents (wp-template, wp-css, wp-acf, wp-audit-*, wp-normalize…)
↓ reads
skills/*/SKILL.md knowledge libraries — methodology, never actions. user-invocable: false
↓ produces
starter-theme/ + templates/ PHP theme scaffolds and env/plugin-profile configs copied per project
```
Key consequences when changing things:
- A command must not reimplement a builder. `/wp-yolo` is the model: it normalizes the demo once,
then drives the existing commands/agents in dependency order.
- Every agent's first mandatory step is to read the **project's** `.claude/CLAUDE.md` (generated by
`/wp-init` in the user's WordPress project — a different file from this one) to pick up the
function prefix, languages and theme slug. `prefix_` in agent/skill docs is a placeholder.
- Plugin-relative paths in commands are always `${CLAUDE_PLUGIN_ROOT}/…`, never relative.
- The `.wp-create.json` manifest in a user project is the shared source of truth for the WP-CLI
wrapper, environment type and language config — commands read it instead of re-asking.
### Two independent i18n systems — do not mix them
| | `wp-bilingual` skill | `wp-polylang` skill |
|---|---|---|
| Model | one page, ACF field `_<lang>` suffixes (`hero_title_es`) | one post per language, joined by translation groups |
| Helpers | `prefix_get_field()`, `prefix_t()`, `prefix_e()` resolve the suffix transparently | `pll_*` API via `wp eval-file` scripts |
| Used by | `/wp-init` when `i18n strategy: suffix`, `/wp-section`, `/wp-seed` | `/wp-init` when `i18n strategy: polylang` (**the default**), and `/wp-polylang` to retrofit an existing site |
**Which one a project uses is a recorded decision, not a guess.** `/wp-init`
asks (Step 0.7) and writes the answer as `i18n strategy` into the project's
`.claude/CLAUDE.md`. Every downstream command and agent branches on that line —
`/wp-seed`, `/wp-header`, `wp-acf`, `/wp-yolo` — so read it before assuming the
suffix model. When the line is absent, the project predates the choice and is
`suffix`.
**New scaffolds default to `polylang`, and the reason is SEO.** The suffix model
serves both languages from one URL off `?lang=`/cookie/`Accept-Language`, so a
crawler only ever sees the primary language; `?lang=es` canonicalizes back to the
primary URL, there is no hreflang pair because there is only one post, and per-post
meta leaves Rank Math nowhere to store a translated title or description. `suffix`
is therefore offered as what it is — a language toggle for a site whose second
language does not need to be found — not as a way to build a bilingual site that
has to rank. Existing projects are untouched; the absent-line fallback stays
`suffix`.
The seam is a single file: `inc/i18n.php`. Templates call `prefix_get_field()`
and friends and never `get_field()` directly, so swapping that file switches the
whole theme's model with no template changes. The Polylang variants live in
`starter-theme/_i18n-variants/<template>.php` and are copied over `inc/i18n.php`
by `/wp-init`. They are kept OUT of the theme directories on purpose: a starter
carrying two definitions of the same function fatals the moment anything globs
`inc/*.php`, and `tests/checks/tailwind-starter.sh` refuses that state. The two
starters have different contracts (nine helpers vs three), so each variant
mirrors its own, and `tests/checks/wp-polylang.sh` asserts the pairing per
starter.
One deliberate crossover: under Polylang, ACF **options-page** fields keep their
`_<lang>` suffixes. Options are global — one set of values per site, not per
language — so Polylang's one-post-per-language model does not reach them and the
free plugin does not translate them. `wp-acf` therefore drops `_<lang>`
duplicates everywhere except the settings group.
The Polylang path is real code with real failure modes: translations join through the
`post_translations` taxonomy, so anything that writes translations must go through
`pll_save_post_translations` / `pll_save_term_translations`. `pll-lib.php` bridges `wp eval-file`'s
local `$args` into `$GLOBALS` — the scripts do not work without that require. PHP 7.4 floor:
no `match`, no union types.
+ `pll-lib.php` is also the one place in this repo where a test runs real shipped logic
+ instead of grepping a contract. Its top-level code is all `if (!defined(...))` guards that
+ call nothing from WordPress, and the ACF path walker and resolver inside it are pure array
+ manipulation, so `tests/checks/wp-polylang-nesting.sh` requires the file under bare PHP and
+ asserts on what the walker emits and where the resolver places a value. Keep those two
+ functions free of WordPress calls: `pllx_acf_write()` is the wrapper that reaches
+ `get_field()`/`update_field()`, and it stays in `pll-import.php`, which cannot be required
+ at all because it runs a whole import at top level.
+
### Craft vs plain is a recorded decision
`/wp-demo` (and `/wp-yolo`) choose craft or plain mode from the project's docs and write the
answer as `demo mode` into `.wp-create.json`. Every downstream command branches on that line
(`/wp-init`, `/wp-section`, `/wp-finalize`), so read it instead of re-deriving it. Craft builds
read `skills/wp-demo-craft/` and ship GSAP plus `motion.js` in the theme bundle; plain builds
ship neither and use the `wp-aos-animator` skill instead. When the line is absent, the project
predates the choice and is `plain`.
Craft mode has a hard prerequisite: a browser. `/wp-demo` probes with
`bin/demo-verify.mjs --probe` and stops on exit 2; it never builds blind and never
falls back to plain. A craft build writes two files: `demo/BRIEF.md` (the story) and
`demo/DESIGN.md` (tokens, in the open Stitch DESIGN.md format). `/wp-init` reads
`demo/DESIGN.md` before scraping `:root`, and copies it into the theme. Sections are
built from `skills/wp-demo-craft/compositions/` (previews rendered against the
neutral `references/design-md/_preview.md`), and verified by `npx impeccable detect`
plus the seven-line rubric in `references/verify.md`, at most three rounds.
**The two token vocabularies are joined by aliases, not by a rename.** The
compositions' CSS speaks `--color-canvas` / `--color-ink` / `--font-display`; the
starter's speaks `--color-primary` / `--color-dark` / `--font-primary`; only
`--color-accent` is in both. `/wp-init` Step D4 writes the craft set into the
Tailwind `@theme` block from `demo/DESIGN.md` and then defines the starter's six
colour and two font tokens as `var()` aliases onto it, mapped **by role, not by
lightness** — on a dark craft palette `--color-light` correctly resolves to a
near-black canvas, because what the starter's CSS depends on is the contrast pair,
not the name. The table is appended to the theme's copied `DESIGN.md` under
`## Token aliases` so later agents read the mapping instead of guessing it.
## Authoring conventions
**Command** (`commands/<name>.md`) — frontmatter with `description`, `allowed-tools`,
`argument-hint`. Add `Agent` to `allowed-tools` only if it dispatches subagents.
**Agent** (`agents/<name>.md`) — frontmatter `name`, `description`, `tools`, `model` (`opus`|`sonnet`|`haiku` — cost tier, see `tests/checks/model-routing.sh`) (order:
`Read, Write, Edit, Grep, Glob, Bash`). Must open with the "First Action (MANDATORY)" block.
**Skill** (`skills/<name>/SKILL.md`) — frontmatter `name`, `description`, `user-invocable: false`.
Skills inform; they never act.
**Starter theme edits** — use the placeholder tokens (`__starter__`, `__STARTER__`,
`__STARTER_NAME__`), replaced by `/wp-init`.
**New behavior needs a check.** The house style is a grep-for-required-tokens script under
`tests/checks/` that fails if the contract wording disappears from the command/skill/agent.
## Generated-theme conventions (what the agents must emit)
- Fields: `<section>_<element>` / repeaters `<section>_<plural>` / keys `field_<section>_<element>`,
`group_<section>`; bilingual duplicates append `_<lang>`.
- Templates: always `prefix_get_field()` (never raw `get_field()`), `?:` fallbacks, everything
escaped (`esc_html`, `esc_url`, `esc_attr`).
- CSS: custom properties for all tokens, BEM, mobile-first `min-width` queries, section delimiters
`/* ====== Section: Hero ====== */`. In transcription mode (`--transcribe`, `/wp-yolo`) agents copy
the demo's exact declared values instead of re-authoring them, and scope every selector under a
unique BEM block so parallel section builds cannot collide.
- Prefer WP-CLI over generating PHP (`wp-cli-patterns` skill).
## Release chores
Version lives in `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` (twice) and the
README badge — bump all of them together. Add an `[Unreleased]` entry to `CHANGELOG.md`; update
`README.md` when adding a command.
**Cutting a release never renames `## [Unreleased]`.** Insert the new
`## [x.y.z] - YYYY-MM-DD` heading *below* it and move the entries down, leaving an
empty `## [Unreleased]` at the top. Renaming it deletes the anchor every open branch
is editing, so a release turns every open PR into a `CHANGELOG.md` conflict at once.
`.gitattributes` marks the file `merge=union` so two branches appending under the same
heading merge instead of conflicting; the cost is that union never reports a conflict,
so a genuine same-line edit keeps both sides silently — check the top of the file after
a release.
## Known ceilings
These are deliberate, documented limits — not bugs to "fix" on sight:
+ - **A clone carries data, never code.** `/wp-clone` transfers the database and
+ `wp-content/uploads/`; plugin and theme **files** are never transferred. That is
+ deliberate — a dev clone should get its code from the same place the project does, not
+ from a production server's disk — but it means the imported database activates plugins
+ that are not present, so `/wp-clone` inventories them from the source while the SSH
+ session is open and splits them into recoverable (on WordPress.org, with a version-pinned
+ install command), unobtainable (custom, licensed or premium — the clone stays incomplete),
+ and unclassified. The third group exists because the WordPress.org lookup is a network
+ call: guessing it into either of the others sends the operator to install a different
+ plugin that shares a slug, or to ask a client for a file they could have downloaded.
+ Path B has no source to ask and derives a weaker list from the database alone, which it
+ says.
+ - **The clone's destination backup is a database export, and nothing else.** `/wp-clone`
+ Step 1.5 refuses an occupied destination without `--force` and exports its database to
+ `~/.wp-clone-backups/` first — outside the project, because a backup under `wp-content/`
+ is reached by the next clone's `rsync` and by an `rm -rf` of the project. What it does not
+ copy is the destination's **uploads**: `rsync` overwrites matching paths, and files it
+ overwrote are not in the `.sql`. Restoring from one of these backups restores the content
+ and the field values, not the media. Deliberate — duplicating an uploads tree on every
+ clone costs more than the case is worth — but it means "a backup was taken" is a narrower
+ promise than it sounds, which is why it is written down here.
+ - **A clone is isolated at the seams a copy shares with its original, not at every one.**
+ `/wp-clone` Step 5.5 captures mail at `pre_wp_mail` (a must-use plugin, so it survives a
+ plugin being reactivated and cannot be undone by an option write), disables `WP_CRON`, and
+ sets `blog_public` to `0` — before Step 6, which is the first thing that loads the site.
+ What it does **not** do is change live payment credentials, webhook URLs or API keys: it
+ reports them. Flipping a gateway to test mode would change the behaviour under test, and a
+ store clone often exists because a payment bug needs reproducing. The cost of that choice is
+ real and is the operator's to carry — a clone can still reach a production endpoint through
+ any integration that does not use `wp_mail()` or cron, and the database holds live customer
+ records from the moment the import finishes. `/wp-anonymize` is the opt-in remedy, and
+ the report names it at the moment the operator is told the records are there.
+ - **`/wp-anonymize` replaces a catalog, not a database, and says so in every report.** It
+ rewrites `wp_users`, `wp_usermeta` billing and shipping keys, comment author rows
+ (including the IP, which is personal data on its own) and WooCommerce order addresses on
+ both the postmeta and HPOS layouts. Everything else is listed by name and row count under
+ *Not examined*, because an explicit catalog is only honest if what it excludes is printed:
+ a plugin holding customers in its own table is invisible to the catalog and appears in
+ that list. Three narrower limits follow from the same shape — serialized blobs in
+ `wp_options` and `wp_postmeta` outside the WooCommerce keys are not rewritten;
+ `user_login` is deliberately kept, because it is the join key half the ecosystem
+ recognises an account by and it is far less identifying than an address; and the run
+ cannot undo itself, which is why the backup to `~/.wp-clone-backups/` is unconditional and
+ a failed export stops the command. The gate — the isolation mu-plugin must be present —
+ takes no `--force`, the only refusal in this plugin that does not, because the cost of the
+ check being wrong is an annoyance and the cost of the operator being wrong is a production
+ site with its customers overwritten.
+ - **`/wp-seed` owns records and values separately, and the second is the weaker claim.**
+ Phase 1.5's marker says who created a *record*; `_<prefix>_seeded_digest` says what this
+ command last wrote into each *field* of it, so an editor's edit is distinguishable from a
+ source change. A field whose current value no longer matches that digest is the client's,
+ and is left alone and reported; `--force-fields` is the only way past it, and is not
+ implied by `--force` anywhere else. A field with **no** recorded digest is treated as a
+ conflict rather than as ours, which makes the first re-seed of a project created before
+ this existed noisy — correctly, since nothing on disk can say whose value it holds.
+ Two things it cannot do: a client edit that happens to reproduce the demo's value reads as
+ no edit, and the digest records a value, not a history, so it cannot say *when* the edit
+ happened or how many times.
+ - **Seed media deduplicates by source, not by content.** `/wp-seed` records
+ `_<prefix>_seeded_source` — the URL a remote image came from, or the repository-relative
+ path of a local one — and skips an import whose source it has already seen. The same
+ image served from two different URLs therefore imports twice. Hashing every import on
+ every run costs more than that case is worth, and a duplicate attachment is a tidiness
+ problem where a duplicate *page* splits a site's navigation. Generated plates are
+ unaffected: `assets/img/gen-<hash>.jpg` already carries a content hash in its path, so a
+ regenerated plate is a different source and correctly imports again.
+ - **Seeded menu items are rebuilt, not reconciled.** Every other record `/wp-seed` owns is
+ updated in place; menu items are deleted and re-added, because a demo whose navigation
+ dropped a page would otherwise leave that item behind forever, and matching an existing
+ item to a demo link is guesswork the moment a title is edited. Items the client added are
+ kept. The cost is that a seeded item's own ID changes on every run, so nothing may hold a
+ reference to one.
+ - **A resumed `/wp-yolo` trusts that a present artifact is a correct artifact.**
+ `--resume` skips a unit when `demo/.yolo-progress.json` records it *and* its file is
+ still on disk and non-empty. A template part that a failed agent left syntactically
+ valid but wrong is indistinguishable from a good one at that granularity, and is
+ skipped. The Step 5.5 parity gate is the net under it, and it measures geometry at
+ rest — not behaviour, and not whether a section says what the demo said. Deliberate:
+ the alternative is re-verifying every completed unit on every resume, which costs
+ what the resume was meant to save.
+ - **`settings` is recorded, not verified.** `/wp-settings` writes nine or so WordPress
+ options and produces no single file, so its ledger entry carries `artifact: null` and
+ is trusted on the ledger alone. A probe asserting one option out of nine would read
+ as verification while proving almost nothing, which is worse than the stated gap.
+ - **Drift is detected per file, not per section.** The ledger digests the whole
+ manifest, so any edit to it invalidates every section and `--resume` refuses until
+ `--accept-drift` is passed. Sub-document digests would let a resume continue across an
+ unrelated edit, but they would have to model which manifest key feeds which unit, and
+ a wrong model there silently reuses stale input — the exact failure the ledger exists
+ to prevent. Refusing too often is the cheaper error.
- **Media is not translated.** The Polylang importer copies an image or file id to the counterpart
as-is rather than swapping it for that attachment's own translation. Mapping media is a separate
decision; `pll-import.php` and the live suite both state the ceiling.
- - **ACF reference re-pointing is one level deep.** `link`, `page_link`, `post_object` and
- `relationship` are re-pointed only as top-level fields, matching `pllx_acf_walk()`'s own ceiling.
- The same field nested inside a repeater or flexible-content row keeps pointing at the source.
+ - **ACF reference re-pointing recurses, and records ownership per path.** `link`,
+ `page_link`, `post_object` and `relationship` are re-pointed wherever they sit, including
+ inside groups, repeater rows and flexible-content layouts, through the same
+ `pllx_acf_zip()` traversal and the same layout-by-name rule the text walk uses — so a
+ structure one can see is a structure the other can see. `_pll_ref_` ownership meta is
+ keyed by the dotted path, so two references differing only by row keep separate records
+ of what the importer last wrote and an editor's change to one is not read as a change to
+ the other. What is **not** carried is the ceiling that used to sit here: for one release
+ the text walk recursed while this did not, which gave a nested link a translated title on
+ a source-language URL — translated-looking and wrong, worse than the untranslated link it
+ replaced. `tests/fixtures/wp/acf-refs.php` exists to keep that from coming back, and
+ fails if the collector stops recursing or if ownership is keyed by field name again.
- **Parent structure mirrors the source.** Both parent fixup passes rewrite the counterpart's parent
on every run, so a post or term an editor deliberately re-parented in the target language is put
back. Only the ACF reference pass records ownership; parents do not.
- **The site tagline is written in the primary language only.** `/wp-init` Step 9 sets
`blogname` and `blogdescription` from the confirmed tagline; under `i18n strategy: polylang`
the secondary language is left to `/wp-polylang`, which exports both as registered strings.
Writing them is still the prerequisite — Polylang omits an empty option from its string table.
- **Fonts are carried once, at scaffold time.** `/wp-init` Step 4.5 self-hosts the demo's
families; `/wp-section`, `/wp-header` and `wp-css` do not detect fonts, on purpose — fonts
are a site-wide asset and per-section detection would repeat the download N times and race
under `/wp-yolo`'s concurrent section builds. The cost is that a demo swapped in *after*
`/wp-init` has run brings no new fonts with it: re-run the step, or carry the woff2 by hand.
- **`/wp-init`'s Polylang branching is verified by grep, not by an automated end-to-end run.**
The path has been run against a real project and works — that is why it is now the default —
but its scripts are covered automatically only by `tests/checks/wp-polylang-live.sh` (which
needs `PLL_TEST_SITE`), and the command's own prose branching only by the grep checks in
`tests/checks/wp-polylang.sh` and `wp-init-templates.sh`.
- **Compositions are ports, not copies: thirteen of them, across ten roles.** Effects
from Magic UI and Aceternity are rewritten in CSS and GSAP inside the `data-motion`
contract; the React libraries are never dependencies. A feeling curve that needs an
eleventh role builds it by hand under the same contract with a reason in
`demo/BRIEF.md`.
- **The evaluator reads headless sheets.** Real-device feel is still unproven, and
Landing Gallery screenshots are inspiration only.
- **`designlang` extracts what a site declares.** A site built on inline styles or
canvas yields thin tokens; the catalogue fallback fills the gap and is cited.
- **A craft demo emits no bilingual `<!-- i18n: … -->` hint comments.** The craft build
path takes only the header, footer and responsive requirements from the plain path,
so nothing in the markup marks a translatable string. The bilingual pipeline reads
the recorded `i18n strategy` instead — which it should anyway; markers in a demo were
never the source of truth.
- **A composition's breakpoints and its fluid ramps both size to its own container now,
except where the screen is the point.** Every composition's size-based breakpoints are
`@container` queries against the block's own `container-type: inline-size`, so a section
dropped into a narrow column lays out for the column. The `vw` in `clamp()` gaps and type
scales was the one exception — 40 occurrences across 12 of the 13 compositions still keyed
off the viewport — and is converted: 37 now read `cqi` and track the container. Three
stay `vw`, each with a comment. Two are the display headline of a full-bleed hero
(`hero-bleed`, `hero-type`), sized against the viewport on purpose because
a hero in a narrow column is not a scenario those compositions serve. `hero-split`
used to be counted a third: its title sits in a `1.1fr 0.9fr` split column, so the
borrowed "full-bleed" justification was false for it, and since its nearest container
is the section root the conversion is identical at full bleed (86.4px at 1440,
measured) and correct in a narrow column (38.4px at 420px) — so it converted. The third is
`feature-zigzag`'s root `gap`, which *cannot* be `cqi`: that rule is the element
declaring `container-type`, and an element never matches a container query against the
container it establishes itself, so `cqi` there would resolve against the viewport while
reading as if it tracked the block. `compositions/README.md` states the rule; `tests/checks/wp-craft-compositions.sh`
asserts every remaining `vw` carries its justification on that line or the line above it.
- **The two `reveal` paths differ above the fold.** The CSS path's range is
`entry 0% entry 40%`, so an element already fully in the viewport at load is past
its entry range and `animation-fill-mode: both` lands it on the end state with no
animation; the JS path animates it in. A hero fades in on a browser without
scroll-driven animation and is already there on one that has it. Deliberate — the
CSS behaviour is the better one for the "First paint complete" rubric line — and
recorded in `references/devices.md` rather than reconciled.
- **Composition previews need the network to regenerate.** They render against the
neutral `references/design-md/_preview.md` and their fill copy lives in
`compositions/fills.json`, which points at remote placeholder images. The committed
`preview-1440.png` / `preview-390.png` are the artifact; treat them as such rather
than assuming a rebuild is always available.
- **`@property --container-max` needs Chrome/Edge 85+, Safari 16.4+, Firefox 128+.**
A malformed value (`wide`, an empty string) used to make `calc()` invalid at
computed-value time and unset `padding-inline` to `0` at every viewport, the same
failure the `var(--container-max, 1280px)` fallback only ever guarded against for
an *absent* token. `commands/wp-demo.md` and `bin/composition-preview.mjs` now emit
`@property --container-max { syntax: "<length>"; inherits: true; initial-value:
1280px; }` alongside `:root`, so an invalid value falls back to `initial-value`
instead. Where `@property` is unsupported, the `1280px` `var()` fallback remains
the only guard, and it still covers only the absent case.
- **`unobserved` is a confession, not a measurement.** It is now a per-section
judgment — `probe()` counts devices inside the section's own subtree, so a section
carrying only pointer devices (`tilt`, `magnet`, `spotlight`) reports it instead of
falling to a blocking `dead-scroll` — but a section that genuinely does not move and
a section the harness cannot read are still not distinguished by the harness, only
by which finding it emits and what the operator does next. `no-engine` stays
document-wide on purpose: scoping it too would fire on every ordinary static section.
- **The harness changes were measured on a composition corpus, not on a client
build.** The v1.15.0 baseline demo named in the plan no longer exists on disk — that
project is now a WordPress install and its demo was consumed into the theme — so the
before/after walk was run on a five-page corpus assembled from the thirteen in-repo
compositions instead, with the release commit's `demo-verify.mjs` and with HEAD's.
That proves the harness changed behaviour as intended; it does not prove what a
messy real build now scores, and a composition corpus is cleaner than one.
- **Two verification paths are still not covered.** Item F's last preview-token bypass
— a reassignment at `composition-preview.mjs`'s render call site, which happens after
`--tokens` has exited and which no assertion on that output can see — needs the render
itself to close. And `motion.js`'s GSAP `reveal` branch is never walked, because
nothing in the suite runs a browser without `animation-timeline`.
- **`motion.js` sets `overflowX` on the rail, not on the frame — and it is not inert.**
Under reduced motion the stylesheet resets the rail to `width: auto`, and a scroll
container is sized by its box rather than by its content, so the assignment makes the
*rail* the scroller and the frame stops overflowing: measured at 1280, rail 2042/1280,
frame 1280/1280. The frame's own `overflow-x: auto` is then only the no-JS fallback.
Which box scrolls therefore depends on whether the engine ran, which is why the
keyboard affordance in the pan device picks the scroller by measurement, not by name.
- **Verification serves over HTTP; the delivered demo is a `file://` artifact.**
A defect that only appears when the file is double-clicked can pass a green walk.
`external-module` findings name the one case known to matter.
- **The same-client rule reads the plan, which is prose.** It constrains a build
that argues honestly and does not stop one that does not.
- **The image generator's HTTP call and response parsing are not covered.** No
check can drive a real adapter request without a live key and a real bill,
and a mock would only ever assert the mock. What the suite does cover is
everything around the call — slot discovery, crop and size resolution, the
cache, the ambiguity refusal, and the missing-key stop.
- **The key-leak guard catches the leaks a careless author writes, not every
leak possible.** `say()`/`warn()` route every write through `scrub()`, and
the pin greps for `console.log`, `.error`, `.warn`, `.info`, `.debug`,
`.trace`, `process.stdout.write` and `process.stderr.write` appearing
outside those two functions. An aliased stream (`const w = process.stderr; w.write(...)`),
computed member access (`process['stderr'].write(...)`), `fs.writeSync(2,
...)` and `process._rawDebug` all pass that grep untouched — a grep cannot
follow aliasing, and pinning a seventh spelling would mostly teach the next
reader that the sixth one was the ceiling. The threat model is an honest
mistake by a future author, not an attacker with commit access, who could
delete the pin anyway.
- **A thrown error that embeds the key is not a leak, and that is by design.**
`scrub()` runs on every value `say()`/`warn()` print, and again on the
caught error in `cmdRun`'s own catch block, so `throw new Error('... ' +
key)` is redacted before it reaches a stream. Worth stating because it
reads like a gap next to the entry above and is not one — the grep pins
correctly leave it unflagged.
- **Image cost figures are estimates and will drift.** Google publishes no
per-image price in its own documentation — the figures in `bin/image-gen.mjs`
come from third-party trackers — and OpenAI bills tokens rather than images,
so its per-image number is an approximation by construction. The plan
output prints the total as `~$0.10`, followed by its own line, "Costs are
estimates, not a bill." — never a number presented as a bill.
- **Aspect fidelity is provider-dependent.** Google fills all three of the
library's crops — 4:5, 3:2, 4:3 — exactly. OpenAI offers only three fixed
sizes, so every one of those crops lands inexact and is cropped by
`object-fit: cover` after the pixels have already been paid for. This is
why `google/...` is the recommended default, ahead of price.
- **A generated plate is detectably AI-generated.** Every Google plate
carries an invisible SynthID watermark. `demo/BRIEF.md` records this for
the client, not only the build log, because whoever ships these to
production is entitled to know.
- **The audit's checks are contract greps, so no test can fail because an audit
*missed a defect*.** `tests/checks/*.sh` assert that the wording defining a check is
present in the command, agent or skill that owns it — the house style, and the reason
documentation and behaviour stay in step. What it cannot do is run an audit against a
WordPress site and notice that a real defect went unreported: that would need a fixture
site with seeded defects and an expected-findings file, which is a test harness rather
than a check. So a coverage gap — a defect class nothing looks for — is invisible to the
suite by construction, and is found the way the site-name and schema `@id` gaps were
found: by auditing a real site by hand and asking which contract should have caught it.
Deliberate; recorded here rather than papered over with a check that would only assert
its own mock.
- **The manifest reconciliation measures, but only what WP-CLI can reach.** `/wp-audit`
Step 2.5 re-measures plugins, PHP version, web server and Tier 3 availability on every
run instead of trusting the manifest. Without Tier 2 it can measure none of them, and
reports the whole block `UNMEASURED` — which is honest, and still means a Tier 1 run on
a stale project cannot tell you the manifest is wrong.
- - **The category coverage matrix answers "has this ever run", not "is it still true".**
- Step 2.5d diffs `categories_run` against the categories this plugin version offers, so a
- category that shipped after a project was built is surfaced the next time that project is
- audited. It cannot notice that a category which *did* run has since gained checks the
- project has never seen — code-level versioning per category would be needed for that, and
- the honest signal there is the audit-record age, which Step 2.5e already reports.
+ - **Coverage is tracked per check, but findings are still counted, not tracked.** Step 2.5d
+ diffs `categories_run` against the categories this version offers *and* diffs each run
+ category's `audit.checks_run` ids against that category's own agent catalog, so a check
+ added to a category a project already ran is reported by name as never measured there.
+ Catalogs are read from the six agent files rather than from a stored list, because a
+ stored list is a second copy and a stale one would report green coverage for checks
+ nobody ran — the defect the diff exists to prevent. A project with no `checks_run` yet
+ reports its per-check history as unknown rather than reporting all 261 checks as
+ never-measured.
+ Findings now carry identity too: Step 7.5 keys each one by **check id + resource** in
+ `.wp-audit-findings.json`, beside the manifest rather than inside it — the manifest is
+ configuration every command parses on every run, and a ledger grows without bound (one
+ check found 70 orphan ids on one site). `audit.findings_ledger` is a pointer, and
+ `bin/wp-config.mjs` refuses an inlined one. `issues_found` / `issues_fixed` survive as
+ derived values because a summary still needs a number.
+ **`resolved` requires a measurement.** A check that did not run yields `unmeasured`, never
+ `resolved` — otherwise a Tier 1 run would mark every Tier 2 finding fixed and the report
+ would show a site cleaning itself up by being audited with less access than before. What
+ the ledger still cannot do is notice a finding whose *resource* is only a `file:line`
+ moving because someone added an import above it: that one reports resolved-and-new. Rules
+ identify by record, option or element wherever one exists, and the ones that cannot churn.
- **A dev host is now distinguishable from a missing report, and both still block.**
`bin/geo-scan.sh` exits `3` for a host it cannot reach publicly and `2` for a host with no
report yet, so the audit can tell a configuration problem from an absence. Neither is a
pass, and `--host` is the fix for the first. What neither exit code proves is that the
public URL someone passes actually serves the same site as the one WP-CLI is talking to.
- **Prompt quality is judgment, not machinery.** Nothing stops a weak prompt
from producing a plate that fills its slot while reading as generic.
`demo/BRIEF.md` is the only defence there is.
- **The network half of client research is covered by no check.** No assertion
can drive a real search or fetch without live network, and a mock would only
assert the mock. `tests/checks/wp-research.sh` covers everything around the
call: the branch order, the `"research": "none"` contract in both directions,
the degradation lines, and the six citation rules.
- **The Firecrawl and DataForSEO rungs are never exercised by the suite.** Both
need a connected MCP server. Only the `WebSearch`/`WebFetch` baseline is
reachable from a test, so the two upgrade paths are verified by reading, not
by running.
- **`unconfirmed` is a confession, not a measurement.** A business the agent
could not confirm and a business with no web presence are not distinguished by
the artifact — only by the candidates it lists. What the flag does guarantee is
narrower and real: no `research.site` is recorded, so `designlang` is never
pointed at a guess.
- **Research quality is judgment, not machinery.** A weak competitor set yields a
weak differentiation line, and nothing catches it. The six citation rules force
the artifact to be read; they cannot force it to be good. `demo/RESEARCH.md` is
the only defence there is.
- **Competitor design signal is read from markup, not from rendering.** The agent
reads HTML and CSS, never a screenshot, so a site whose identity lives in
motion or in canvas yields a thin row — the same ceiling `designlang` already
carries.
- **`research.at`, `research.tier`, `research.location` and
`research.competitors` are recorded, not wired up.** `agents/wp-research.md`
writes all four into `.wp-create.json`, for the operator and for future use,
but no command reads them today — only `research.site`, `confidence` and the
scalar `"research": "none"` are read. Reuse keys off `demo/RESEARCH.md`'s
existence, not off `research.at`'s date.
- **An MCP server's instructions reach every session, not just the command that wants
them.** Measured: `inspo-mcp@0.1.16` returns 1,478 characters of instructions on
`initialize`; `wp-design-library` returns none. Registering a reference server puts
its guidance in front of every task in that project, which is why inspo is opt-in
rather than shipped. Inspo's own text currently defers to the project, but the
precedence line in `commands/wp-demo.md` does not rely on that surviving their next
release.
- **The inspo exclusions are prose, and prose binds an agent that reads it.** The
greps in `tests/checks/wp-library.sh` pin the wording; nothing can assert that a
build actually declined to read a colour table. Same ceiling the transcription and
fidelity mandates already carry.
- **Inspo's corpus is a runtime dependency on a third party's storage.** Nothing is
vendored. If it goes away, builds degrade through the `References: inspo
unavailable` path, which is why that line exists in both modes.
- **Opt-in means most builds will not have it.** Registration is a deliberate act, so the
plain path's reference gap is closed only for operators who take that act. That is the
cost of not shipping a third-party network dependency in the default path.
- **The validator binds the commands that call it.** `bin/wp-config.mjs` is the single
definition of a valid `.wp-create.json`, but a command whose gate line is deleted simply
stops validating, and only `tests/checks/wp-config-gate.sh` notices. Same ceiling every
prose contract here carries.
- **The gate substitutes its own path.** `${PROJECT_PATH}` is prose, not an environment
variable the way `${CLAUDE_PLUGIN_ROOT}` beside it in the same `bash -c` is — a Claude
that treats both alike runs `validate ''`, gets the usage line and exit `1`, and the
table reads that as "stop". The block now says where the path comes from, and
`tests/checks/wp-config-gate.sh` diffs all thirteen sites against one canonical copy so
the sentence cannot be lost from one of them; nothing can check that the directory a
Claude substituted is the right one, and the tool still answers a missing argument and an
invalid manifest with the same exit code.
- **A tested compatibility range is a claim someone has to keep honest.** An absent range
is reported as untested, which makes the gap visible; nothing keeps a declared range true
as plugins release.
- **Writing the generated block is what retires the legacy prose lines.** `render-context`
comments them out in the same pass that writes the block, so any caller that takes
ownership also retires the old record and a project states each decision exactly once
instead of contradicting itself with `validate` exiting 0. It happens only there, and
only downstream of `render-context`'s refusal to render an incomplete manifest — a block
with a blank where a decision belongs is not worth the record it would retire, which is
how migration used to leave a project permanently invalid. `contextDrift` still compares
only inside the markers, so a decision line an operator writes outside them afterwards is
invisible, and `/wp-init` Step 7 still writes those lines into a fresh project that has no
generated block at all.
- **Generated credentials are only as private as the local file.** Splitting them into
`.wp-create.local.json` keeps them out of the committed manifest; it encrypts nothing, and
a password already committed needs rotating rather than migrating.