CLAUDE.md · git:20260915.641c2e0 · 2026-09-15 · sha256 0b59be8e5f6f86e2
CLAUDE.md git:20260915.641c2e0A
Immutable. This exact content never changes and is served at /api/v1/blob/0b59be8e5f6f86e2.
# 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
- `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.
## 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.
### 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:
- **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.
- **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.
- **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.