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

## 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 `@container` lint under-reports in one known way.** `containerAudit()`
  in `bin/demo-verify.mjs` iterates only each stylesheet's top-level `cssRules`,
  so an `@container` block nested inside `@media`, `@supports` or `@layer` is
  never linted — and `proof-row` already nests `@media` inside `@supports`, so
  generated demos plausibly will nest container queries too. That direction is
  under-reporting, never a false positive; widening the lint's scope is open
  work, recorded in `references/verify.md` rather than done. The other limit
  once recorded here — judging a selector by `document.querySelector(sel)`, its
  first match only — is retired: it was a *false positive*, since a selector
  matching several elements applies as soon as one of them sits inside a
  container, and `container-noop` blocks. The lint reads every match now.
- **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 size to its own container; its fluid ramps do not.**
  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 still keys off the
  viewport, though — 40 occurrences across 12 of the 13 compositions — so a section in a
  narrow column still takes desktop-maximum spacing. `compositions/README.md` states the
  gap; converting those ramps to container-relative units is open work, not done in this
  pass.
- **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.
- **`var(--container-max, 1280px)` only guards an absent token, not a malformed one.**
  The fallback covers a project whose `:root` never defines the variable. It does not
  cover a project that defines it badly — `wide`, an empty string: `calc()` treats the
  whole expression as invalid at computed-value time and `padding-inline` resets to its
  initial `0`, gutter and all, the same failure the fallback exists to prevent. Measured,
  not theoretical.
- **`unobserved` is a confession, not a measurement.** 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.
- **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.
