# Aura — the client

This is the WordPress plugin. It is the **body**: it builds, saves and draws the pages. The
**brain** — prompts, DSL, model choice and model calls — lives in `aura-server`.

## What the client does, what the server does

| | client (this plugin) | server (`aura-server`) |
|---|---|---|
| template engine, rendering, filters, CSS scoping | ✅ | |
| reading the model's answer, deriving the schema from the placeholders | ✅ | |
| applying the patches, reordering the CSS | ✅ | |
| Studio, fields panel, design system, contents | ✅ | |
| prompts, DSL, chapters, field reminders | | ✅ |
| model keys, model choice, token cost | | ✅ |

The boundary is not where it happened to be convenient, it is where the technical line runs:

- **The engine has to run here.** It draws every page on every visitor request, inside the WordPress
  loop, with `WP_Query`, ACF, shortcodes and menus in front of it. A network call to draw a page is
  not an option.
- **The parser stays with the engine.** `parse_result()` derives the schema from the placeholders:
  that is knowledge of the *interpreted* DSL, not of the *taught* one. Parser and engine have to
  change together.
- **The prompts stay on the server.** They are what gets improved every week, and on the server they
  improve for every site at once instead of site by site.

An important consequence to know, and to tell the customers: **pages already built do not depend on
the service.** They live in the site's meta and the local engine draws them. If the service is down,
or the licence expires, the site keeps working: only the generation of new things stops.

## What changed from the monolith

**Removed**

- `class-bridge.php` — the client no longer talks to any model. The bridge is still there as a
  development provider, but inside `aura-server`.
- from `class-ai-provider.php`: the DSL and the chapters, `system_prompt` and the split for the
  cache, the field reminders, the composition of the user message, the token import prompt and the
  four `generate_*` towards OpenAI / Gemini / Claude / bridge. From 1944 lines down to 916.
- from the settings: provider choice, API key, model name, bridge section. Two fields were left at
  the time — the service address and the licence key — and the address went too, shortly after: it
  is stamped into the zip at build time, so the page is down to **one field**. See *Configuration*.

**Added**

- `class-remote.php` — the HTTP client towards the service, with the negotiation over `/health`.
- `class-site-payload.php` — it packs the site's **data** (palette, fonts, tokens, direction,
  inventory, icons). It sends data, not text already composed: if it sent text, improving the way of
  saying it would mean updating the plugin on every installed site.
- `AIPB_Template_Engine::DSL` and `::DIRECTIVES` — the declaration of what this engine knows how to
  render. It is a promise made to the service: **whoever adds a directive to the tokenizer has to add
  it to `DIRECTIVES` too**, otherwise the service will never teach it. That is the right way round to
  get it wrong: a feature that never arrives, instead of markup that lands on the page as text in
  front of a visitor.
- `AIPB_Render::motion_css()` / `motion_js()` — the scroll reveal. The engine supplies the **trigger**
  (`data-aura-reveal` → `data-aura-in` when the element enters the viewport, plus `data-aura-stagger`
  for a sequence); the **animation** stays the section's own CSS. That split is the whole point: the
  boilerplate that was identical in every section — an observer, a fires-once flag, an initial state —
  is written once, while a bespoke entrance loses nothing, because with no value the attribute applies
  no style at all and only flips the state.
  Two invariants hold it up, and `tests/motion.test.php` guards them: **nothing is hidden by CSS
  alone** (the initial state lives behind `[data-aura-motion]`, which the JS writes only once it knows
  it can reveal — no script, nothing hidden), and **geometry has the last word** over the
  IntersectionObserver, because an element clipped by its own `clip-path` never intersects anything and
  would stay hidden for good. It is declared as the `reveal` capability, so an older engine is never
  taught an entrance it cannot fire.
- `AIPB_Render::swiper_js()` — **the one library that ships with the plugin** (`assets/vendor/swiper`,
  MIT, pinned at 11.2.10). It does not contradict `strip_external()`: that rule is about the DOMAIN,
  not about dependencies — the visitor's browser must not fetch code from somebody else's server —
  and here Swiper is served from the site, exactly as Google Fonts and the Lucide icons already are.
  It is there because the model **writes Swiper correctly**, while hand-rolled carousels come out
  different every time; for a plain scrollable row the prompts still teach `scroll-snap` and no
  library. It loads **lazily, from the runtime**: a section containing a `.swiper` gets the files
  first and its own scripts after, every other page pays nothing — one mechanism covering the front
  end, the `[ai_section]` shortcode, FSE templates and the Studio preview, which do not share a door.
  Capability `carousel`.

**Worth knowing**

- `inline_form()` exists **in both**, and that is deliberate: the server uses it to show the code to
  the model, the client to apply the patches on top of it. The model's `FIND` anchors refer to that
  text, so the two implementations have to stay identical. If you touch one, touch the other.
- The "mock" mode no longer exists. It is not a removal: `AIPB_AI_Provider::mock()` **had never been
  written**, so every call to it (the `mock` default, and every fallback after a network error) would
  have ended in a fatal error. Those paths now return a `WP_Error`, which the REST layer forwards to
  the Studio as a message.

## Creating content types while building

«Build me a slider with all the planets» used to end up as eight cards written by hand inside a
section's code: changing a planet meant opening the code, and those contents existed nowhere else on
the site. It was not the model's mistake — the section generator receives the inventory, `planet` was
not in it, and you cannot loop over a type that does not exist. The step was missing: somebody to
create that type, first.

There are **two roads** to it, and that matters more than how they work:

- **The request names it** («create the post_type planets and wire them to the slider»). The data
  model is ORTHOGONAL to the tier: the request holds whether a page is being built, a section refined
  or a button touched up. So the step lives in `routeInstruction()`, BEFORE the tier is decided, and
  from there it goes to `/model/propose` (`mode: model`). Hung off the «whole page» path alone, as it
  was the first time, it never fired: with a block selected the instruction ends up as a touch-up.
  The recognition (`RE_CONTENT_TYPE`) is deliberately narrow — creating a post type is an effect that
  stays behind, not something to do because a sentence looked a bit like it.
- **The whole page requires it without naming it.** There the plan decides, being the only one that
  sees the page *before* the sections exist.

The two roads produce two shapes of JSON — the fields inside the type in the plan, in a separate map
in the model proposal — but from `plan_model_taxonomies()` and `plan_model_items()` downwards the
validation is the same, and they both converge on `/model/apply`.

The round trip, along the plan road:

1. `mode: plan` returns, besides `sections`, an optional `model` key: the types the page requires and
   the site does not have, with their fields, their taxonomies and real **initial contents**.
2. `AIPB_AI_Provider::parse_plan_model()` cleans it up with the same severity as the content model —
   and discards a type that brings no initial content, because an empty type makes the page come out
   blank, which is worse than the eight cards we started from.
3. The actual creation, **one round trip per object**: `POST /model/apply` for the type (with its
   fields), again for the taxonomy (with its groups), then `POST /model/contents` for the contents. It
   only ever adds: a type or a taxonomy that exists is skipped, a content is recognised by its title
   and a term is not duplicated, so repeating doubles nothing.

   A single call would have worked just the same — and that is how it started — but it produced a
   mute wait followed by a summary, and whoever is watching cannot tell whether it is stuck or
   working. Every line of the chat matches a round trip that really finished: it costs N saves
   instead of one, and gladly, because there are at most four types and the real cost of the whole
   affair is the model call, not these writes. The lines do not double up: the same line is born as
   «Adding the Products content type…» and becomes «Products content type added — 4 fields: …»
   (`stepMsg` in `studio.js`).
4. Only then do the sections start. The type is by now in the inventory that travels with the prompt,
   with its fields (`field.acf_…`) and its taxonomies, and the section builds a
   `{{#loop source="post" type="planet"}}` on top of it — or a `{{#filter}}` with the group chips.

**The objects Aura knows how to create, and why each one.** Content types, content fields,
taxonomies, groups, contents, **group fields** and the archive's **menu item**. The list is not
ambition: it is exactly what the engine already knows how to read. Anything the engine prints and
nobody knows how to create is a placeholder that comes out empty without raising an error — and
anything creatable the engine does not read would be useless matter in the database.

The **group fields** (`AIPB_Fields::TERM_OPTION`, `term.acf_…`) close the oldest hole: the DSL has
taught `{{i:term.acf_cover}}` from the start and `resolve()` knows how to read it, but with nowhere to
declare it and fill it in that placeholder only worked where there was a real ACF. It is an option
separate from the content fields one because a taxonomy slug and a post type slug can have the same
name — «category» is perfectly ordinary — and in a single map one would eat the other.

The **menu item** (`AIPB_Nav::add_item()`) closes the one we opened ourselves: a type with `archive:
true` has a page that lists them all, and before that no link on the site led there. It is written in
the shape that site uses — a `core/navigation-link` inside a `wp_navigation` on block themes, a
`nav_menu_item` on classic ones — and on a site with no menu it does not manufacture one: the theme
shows the page list, and inventing a menu with a single item in it would change its header as a side
effect.

**Media have a source**, and it is a product decision rather than a technical one:
`AIPB_Images::attach()` runs as each content is created and looks, in this order, at what the
content already has, then at the site's **Media Library** — the customer's own photos are worth more
than any stock and are already theirs — and only then at **Openverse**, filtered to the licences
that ask for no attribution. Stock does not enter the Library: the address lives in a meta, so the
Library stays a place where everything was chosen. For contents born before all this,
`/images/fill` goes back over a whole type from the «Content model» page.

The same search serves **the images inside a section**, and there it repairs something visible.
The DSL teaches the image placeholder by example, and the example carries an address:
`{{i:photo|https://picsum.photos/1200/700}}`. Left alone, a section holding a photograph goes live
pointing at a random picture on somebody else's server. `AIPB_Images::resolve_placeholders()` runs
inside `parse_raw_section()` — the one gate every generated section passes through, a plan's, a
single one's and a touch-up's alike — and swaps the known stand-in services for a real photograph,
searching with the alt text the model was required to write. An address the model chose on purpose
is never touched, a row of cards is searched one row at a time so three cards do not come back as
the same picture, and there is a ceiling (`RESOLVE_MAX`) because each one can be a network call in
the request somebody is waiting in front of. When nothing is found the stand-in stays: an
unfinished page beats a hole. `aipb_fill_placeholder_images` turns the whole thing off.

Left out, and for good reasons: **shortcodes** are registered by other plugins, and **users** and
**site options** are not the engine's business.

**Creating the taxonomy is not enough: the terms have to be created and assigned.** Those are the two
ways this piece fails silently. A taxonomy with no terms produces a filter bar with no buttons; one
with terms but no contents inside them produces buttons that select nothing — worse than a page with
no filters, because it looks broken rather than absent. That is why a content's terms are validated
against those DECLARED by the taxonomy: a name invented by the item would create one more group with
a single content inside it.

**No confirmation is asked.** It would be consistent with the «Content model» page, which proposes
and lets you save by hand — but there you are designing the data model, which is the task; here you
are asking for a page, and the content type is the means of having it. A question in the middle of a
generation stops the work to confirm something the user has already asked for by writing «all the
planets». And it is reversible enough: removing the type from «Content model» stops registering the
type, the contents stay in the database and are deleted from the trash.

If the creation does NOT go through — no permission, a network error — the page is made all the same,
with the lists inside the sections: `modelNote()` in `studio.js` says so to the model, because the
brief had already been written taking for granted that the type would be there, and a loop over a
type that does not exist raises no error — it prints nothing. About the types that were created,
nothing is said: they are already in the inventory, and repeating it would be saying the same thing
twice.

The route asks for `manage_options` and not for the permission to build: writing a section and
changing the structure of the site are not the same decision.

## Configuration

One value has to be set, and it lives in **Aura → Settings**: the **activation key**, encrypted at
rest (`AIPB_Secret`). It is the same road any paid plugin takes — a customer who has just bought is
not asked to open a PHP file.

Beside it, one that already has an answer: the **interface language** (`AIPB_I18n`), which follows
`determine_locale()` — the PERSON's language, not the site's — until somebody chooses otherwise.
The override is deliberately narrow: two filters, `plugin_locale` for the `.mo` PHP reads and
`load_script_translation_file` for the `.json` the Studio reads, both scoped to the `aipb` domain.
It is not `switch_to_locale()`, which would take dates, number formats and every neighbouring
plugin's strings with it — a side effect nobody setting «show me Aura in English» asked for.

THE SERVICE ADDRESS IS NOT A SETTING. The service is always ours, so `AIPB_SERVER_URL` is stamped
into the plugin at build time: `bin/build-plugin.php`, on the server, writes it into the main file
taking it from `AURA_PUBLIC_URL` — the same variable that puts the links in the emails. One source,
one truth, and no field in which to mistype a character. What the sources carry is the DEVELOPMENT
address, and the build overwrites it whatever it says, so it never reaches a customer.

`AURA_SERVER_URL` in `wp-config.php` still overrides everything, but it is no longer the way to work:
the address belongs to the plugin, which is what gets moved between sites.

The *Verify* button tries the key in the field — not the saved one, because the natural gesture is to
paste and verify — and tells "I cannot reach it" apart from "I reach it but the key is no good". It
does so with `/v1/prompt/preview`, which authenticates like a generation but costs no token.

## Two languages: the interface, and the page

They are different problems with different answers, and confusing them is how a plugin ends up
writing a second multilingual system nobody asked for.

**The interface** — the Studio, the settings, the labels — is gettext, in `languages/`, chosen by
`AIPB_I18n` (see *Configuration* above). Nothing surprising.

**The content** — the words on the published page — is not ours to manage, and it is nearly all
ours to *reach*. The distinction is the whole design:

- managing LANGUAGES (URLs, hreflang, the switcher, queries filtered per language, menus,
  taxonomies, media, the sitemap) is a plugin's worth of work, already solved by WPML and Polylang,
  and anybody who solves it again spends months to arrive at something that argues with what the
  customer already has installed;
- reaching the TEXTS inside a section is something only we can do, because they live in a JSON
  string inside a post meta. WPML in front of `_aipb_blocks` sees a blob and gives up — correctly:
  it has no way of telling which of those characters are words and which are markup.

So `AIPB_Strings` is the shape, told to whoever asks. Two functions over the block tree: hand back
every translatable string with a stable name, a human title and an editor kind; and write a set of
translations back into a copy of the tree, leaving everything that is not text where it was. It
knows nothing about WPML, deliberately — the same list is the input of a Polylang screen, an XLIFF
export, a translation paid for in credits, or a global search and replace.

What counts as a text is decided by the SCHEMA, not by looking at the values: `derive_schema()`
already recorded, field by field, whether a value is a `text`, a `richtext`, a `url`, an `image` or
a `bool`. That is why a colour, a switch or a `hero_img_srcset` is never offered for translation —
and offering one is not a cosmetic defect, because automatic translation does not skip rows it does
not understand, and a translated image URL is a hole in the page.

`AIPB_WPML` is the thin part on top. It arrives through WPML's page builders API — the same one
Elementor, Beaver Builder and Bricks use — which is three hooks: `wpml_page_builder_support_required`
to declare ourselves, `wpml_page_builder_register_strings` to answer with the list, and
`wpml_page_builder_string_translated` to write the finished translation into the other post's meta.
Everything else is WPML's and already paid for: the editor, the automatic translation, the memory,
the "needs updating" state, the jobs. `wpml-config.xml` completes it by saying what to do with our
four meta fields — the structure is copied, the texts are `ignore`d because we write them.

It goes both ways, and it has to. The translations live in WPML, but the Studio opens on a
translated page like on any other — and rewriting an Italian paragraph is a thing to do while
looking at the Italian page, not in a table of rows. So a save there hands the words back to WPML as
its translations (`push_translation()`, through `wpml_string_id_from_package` and
`wpml_add_string_translation`), marked finished, and only for the strings that actually differ from
the original: a field left as the source is not a translation, it is a field nobody has read.

Underneath there is a safety net, and it is what separates *it works* from *compatible*. A
translated page can arrive with none of our meta: WPML's duplicate mode never runs a job, and an FSE
template is not a translatable post type at all, so nobody ever asks it for its strings. In those
cases the page draws from the original and the words are swapped ON THE WAY OUT, reading the same
strings from the same package (`AIPB_WPML::translate`, applied only when `$expand` is true — the
static snapshot in `post_content` must stay in the source language).

That last step reads the package **only over words still in the source language**. It is the one
place where this design can eat its own work: over a translation it would be laying WPML's memory
across the top of what somebody had just written into the page, on every single view — the meta
holding the new text, the visitor reading the old one, and saving appearing to do nothing.

The last piece is the IDs. A shortcode `[ai_section id="12"]`, a `ref` block, a `ref` to a
`wp_navigation`: each was written when the page was built, in one language, and each is resolved
through `AIPB_WPML::object_id()` at the point of use — which hands back the ID unchanged wherever
there is no translation plugin, so forgetting it is impossible.

Two limits, stated rather than discovered. Translations of a repeater are filed by ROW POSITION,
because a row has nothing else to be identified by: reordering the rows of a repeater moves the
translations with the positions. And a text the model wrote as a literal in the markup instead of a
`{{t:}}` placeholder — the body of an `{{#empty}}`, an `aria-label` — is not a field, so it is not
offered; the fix belongs in the DSL prompt, not in a parser of text nodes.

## The migration is closed

Both the things this page used to list as outstanding are done, and it is worth writing down where
they ended up rather than leaving the reader to find out:

- **The prompts are guarded by golden files**, on the server: `tests/parity/{cases,golden}`, run by
  `tests/run.php`. It is the parity test in the form that survives — the old monolith is not in this
  repository's history, so a diff against it was never going to be runnable, whereas a snapshot of
  what each request composes today catches the same class of regression tomorrow. Next to it,
  `bin/contract.php` checks the rule the whole fleet stands on: the service must never teach a
  directive `AIPB_Template_Engine::DIRECTIVES` does not declare.
- **`context_prompt` is gone from the client.** The inventory travels as DATA
  (`AIPB_Site_Payload::site()`, key `inventory`) and the prose is composed by the service
  (`Aura\Prompt\Inventory`). The plugin no longer carries a copy of that formatting.

## Tests

`php bin/test.php` runs everything: it lints every PHP source with the interpreter it is running on,
then runs each file in `tests/`. Each test file is a plain script with no framework — it exits
non-zero when it is unhappy — so there is nothing to install.

The CI (`.github/workflows/tests.yml`) runs that on a current PHP, and separately lints the sources
under the **oldest PHP the header promises** (`Requires PHP: 7.2`, read from the header itself). That
second job is the only thing standing behind that promise: a modern idiom written by habit parses on
any laptop and takes the site down at activation, with a white page and no message.
