# Aura (scaffold v0)

The AI generates WordPress sections (free-form HTML/CSS/JS) with real dynamic data (WP loops,
repeaters for tabs/faq) and makes them **editable by props** from an interface. It lives alongside
Gutenberg: every content belongs to exactly one of the two editors (see below).

## Living alongside Gutenberg

Gutenberg stays active. The choice is **per content**, recorded in the `_aipb_editor` meta
(`class-ownership.php`):

- a new page opens in the WordPress editor as always; in the **Aura** side box there is
  **"Build with Aura"**;
- from the first save in the Studio the page belongs to Aura: the block editor is switched off for it
  and on `post.php` the content area disappears (the layout lives in the meta, `post_content` is only
  its snapshot). The screen stays the classic one though — title, ACF, Yoast, taxonomies, featured
  image — and from there, the **Aura** box or the row action, you enter the Studio with
  **"Edit with Aura"**;
- a save with block content on a page that is still free assigns it to Gutenberg, and the builder
  disappears from its actions;
- an empty page stays **free**: the choice is still open;
- the choice is reversible from the row action in the lists (**"Back to Gutenberg"** /
  **"Build with Aura"**, with a confirmation). Aura's meta are not deleted, and `post_content` keeps
  the HTML snapshot: switching to Gutenberg does not empty the page.

The same holds for **patterns** (`wp_block`) and **FSE templates**: the ones generated by the Studio
open in the Studio, the ones born in Gutenberg stay in the Site Editor. Those — and the CPT's
sections — are the only entities `post.php` redirects to the Studio: they have no fields to fill in,
their only screen is the builder.

For contents created before this mechanism the owner is inferred: Aura meta present → Aura,
otherwise a non-empty `post_content` → Gutenberg.

## The architecture in 3 artefacts

Every section saves into the post meta:

| Artefact | What it is | Who touches it |
|---|---|---|
| **template** | HTML/CSS/JS with annotations (`{{t:…}}`, `{{#loop}}`, `{{#if}}`) | the AI (free design) |
| **schema** | the list of editable fields (type, label, subfields) | the AI |
| **values** | the fields' values | the user, from the interface |

The final render = `template + values` passed through the safe engine
(`class-template-engine.php`), which sanitises every value by type. **No arbitrary PHP** is ever
executed.

## The fields are for the data, the Style panel is for the style

The boundary runs here: **the Fields panel exposes the DATA** — texts, images, links, show/hide
switches — and **the style lives in the CSS**, where the Style panel edits it visually. The prompt
says so to the AI: no fields for colours, spacing, radii, shadows, type sizes.

It is not just tidiness. A colour exposed twice — as a field *and* as a CSS rule — sooner or later
says two different things, and which of the two wins is not up to whoever uses the panel. It was also
a contradiction inside the prompt, which for style changes already demands a CSS rule and forbids
find/replace.

**No exceptions, not even in the repeaters.** When one entry has to be different from the others —
the recommended plan, the featured card — the data says **which one**, the CSS says **what it
consists of**:

```html
{{#loop source="prop" name="plans"}}
  <div class="plan{{#if field.featured}} plan--top{{/if}}">{{t:field.name}}</div>
{{/loop}}
```
```css
.plan--top { border-color: var(--aipb-color-accent); }
```

`{{#if field.key}}` inside a loop becomes **a checkbox on every entry** of the repeater, off by
default (an exception switched on for every entry is no longer an exception). The key does not become
a switch if it is already a subfield: a `{{#if field.photo}}` guarding an `{{i:field.photo}}` is
asking about that image, not about a separate flag.

The alternative road — a rule on `:nth-of-type(2)` from the Style panel — looks equivalent and is
not: that rule is tied to the **position**, and the fields panel has «Remove» and «+ Add». Take the
first entry away and the emphasis stays on the second position, that is, on the wrong entry. The
switch, instead, travels with the entry.

Sections already built with colour fields **keep working**: the `{{c:…}}` placeholder stays in the
engine and in the panel, the AI simply no longer generates them. What changes is what is born from
here on, not what is already there.

## The content model

**Aura → Contents** is where the site's content types, taxonomies and fields are declared: a card
each, add and delete, and **every option WordPress has** on the type behind it — public or not, in
the REST API or not, with an archive or without, on which address, with which supports on the
editing screen, with which icon in the menu.

It is a page and not a filter because the requests it answers are ordinary: *this type must not have
an archive*, *this one must stay out of the site search*, *put the products on /shop*. Until it
existed, each of them ended as a snippet in a child theme.

- **The defaults have not moved.** A type declared before these options existed carries four keys and
  none of the rest, and every missing one falls back to what the plugin used to register by hand: it
  comes back public, in the REST API, with the same supports and the same address. Reading them as
  «not said, therefore off» would have made every site's types private on an update.
- **It destroys nothing.** Removing a type stops it being registered; its contents stay in the
  database and come back the day the type does. Saving a model that would leave contents behind
  **stops**, says who and how many, and asks — one type at a time.
- **The fields travel with their type**, and are saved as ordinary post meta under their own key: no
  hostages, and ACF reads and writes the same ones. Types registered by somebody else — `post`,
  `page`, another plugin's — are on the page too, but only to be given fields: their settings belong
  to whoever created them.
- **«Propose from the brief»** is still there: describe the site, the model fills in the cards. It
  saves nothing — a slug is in the URL of every content of that type and a key is the name of a meta
  in the database, and those are decisions that outlive the plugin.

## The template DSL

```html
Props:      {{t:title}}  {{r:rich_text}}  {{i:image}}  {{u:link}}  {{s:shortcode}}
Current:    {{t:post.title}}  {{h:post.content}}  {{i:post.thumbnail}}
Archive:    {{t:term.title}}  {{h:term.description}}  {{t:term.count}}
WP loop:    {{#loop source="post" type="post" limit="6" orderby="date"}} {{t:field.title}} {{/loop}}
Menu:       {{#loop source="menu" location="primary"}} {{t:field.label}} {{/loop}}
Repeater:   {{#loop source="prop" name="tabs"}} {{t:field.label}} {{/loop}}
Terms:      {{#loop source="term" taxonomy="category"}} {{t:field.name}} ({{t:field.count}}) {{/loop}}
Condition:  {{#if promo_on}} … {{/if}}   with an alternative: {{#if x}} … {{else}} … {{/if}}
Filters:    {{#filter id="catalogue" type="product"}} … {{#results}} … {{/results}} {{/filter}}
```

`type="…"` accepts **any post type** on the site (post, page, product, custom CPTs) — there is no
whitelist to keep up to date. The AI knows which ones exist because the prompt includes the **site
context**: post types with their counts, taxonomies with their terms, registered menus
(`class-site-context.php`). Loop items are enriched by type — WooCommerce prices are included — and
it is extensible with the `aipb_loop_item` filter.

In the terms loop the item has `field.name`, `field.slug`, `field.count`, `field.description`,
`field.url`, `field.permalink` **plus the term's custom fields** (ACF): an image becomes the URL
directly — `{{i:field.cover}}` — with `field.cover_alt/_w/_h` alongside. Terms with no contents are
hidden: to show them anyway, `hide_empty="0"` (`false`, `no`, `off` work too). The number of terms is
limited with `limit="…"`, not with `number`.

## The current content

A section can read **the content the visitor is looking at** — the product sheet, the post, the page
— with the `post.*` placeholders. It is what makes **single and archive templates** buildable, not
just pages: you draw the shape once, WordPress puts the content in on every request.

```html
<h1>{{t:post.title}}</h1>
<img src="{{i:post.thumbnail}}" srcset="{{t:post.thumbnail_srcset}}" sizes="{{t:post.thumbnail_sizes}}"
     width="{{t:post.thumbnail_w}}" height="{{t:post.thumbnail_h}}" alt="{{t:post.thumbnail_alt}}">
<div class="body">{{h:post.content}}</div>
<p>{{t:post.date}} — {{t:post.author}} — {{t:post.term_names}}</p>
```

The fields are **the same as the loop items'** (`title`, `permalink`/`url`, `excerpt`, `thumbnail`
with its `_srcset`/`_sizes`/`_w`/`_h`/`_alt`, `date`, `term_names`, `term_slugs`, `id`, plus the
per-type enrichments such as the WooCommerce prices), with `post.content`, `post.author`,
`post.modified` and `post.post_type` on top. Whoever has learnt `field.title` already knows how to
write `post.title`.

- The **body** has to be printed with `{{h:…}}`, not with `{{t:…}}`: `post.content` is block HTML
  passed through WordPress's filters, exactly as a theme would print it.
- In the **Studio preview** there is no query at all, so a sample content is used (`set_sample_post`):
  without it a product sheet template would be an empty page and there would be no telling whether it
  works.
- If a template embeds `post.content`, the page is **not** stripped of Gutenberg's CSS: the content's
  real blocks end up in there, and removing their styles would leave them bare
  (`AIPB_Render::template_embeds_content`).

The `post.*` context is that of a **single content**: on a taxonomy archive the query's object is a
term, not a content, and `post.*` stays empty. There the scope is `term.*` — see below.

## The current archive

On an archive template (`category`, `tag`, `taxonomy-…`) the `term.*` placeholders print **the term
the visitor is looking at**. It is `post.*`'s twin, for the pages that list instead of showing.

```html
<h1>{{t:term.title}}</h1>
{{#if term.description}}<div class="summary">{{h:term.description}}</div>{{/if}}
<p>{{t:term.taxonomy_label}} — {{t:term.count}} products</p>

{{#loop source="post" type="product" term="current" limit="12"}}
  <article><h3>{{t:field.title}}</h3></article>
{{/loop}}
```

Fields: `term.title` (an alias of `term.name`), `term.description`, `term.count`, `term.slug`,
`term.url`/`term.permalink`, `term.taxonomy`, `term.taxonomy_label`, `term.parent_name`,
`term.parent_url`, `term.id`, **plus the term's custom fields** (ACF) such as `term.acf_cover` — the
same ones the terms loop exposes as `field.*`.

- `term="current"` in the loop means «the contents **of this** category». Without it the loop lists
  every content of the type and **every archive shows the same list** — a mistake that gives no sign
  of itself until two different categories are opened. Outside an archive `term="current"` returns
  nothing, instead of returning everything.
- `{{h:term.description}}` prints it with its paragraphs, `{{t:term.description}}` as plain text.
- Categories are filled in unevenly: put the description, the cover and every optional field under a
  `{{#if term.…}}`.
- In the **Studio preview** the same reasoning as `post.*` applies: a sample term is used, chosen from
  the template's slug (`set_sample_term`, `AIPB_REST::sample_term_for_template`). `category-cakes`
  shows *Cakes* itself; `category` shows the most populated category.

## Asset isolation

On a page owned by Aura **and already built**, the frontend loads neither Gutenberg's styles nor the
theme's (`class-assets.php`): the page carries all of its own CSS, scoped per section. On every other
page — Gutenberg, archives, the theme's home — nothing changes.

The theme's styles are recognised by their `src` (child and parent theme), not by the handle's name.
The removal happens on two hooks: `wp_enqueue_scripts` at priority 9999 and the `print_styles_array`
filter, which acts after dependency resolution and therefore also catches the styles that came back
in because another one declared them as a dependency.

> **Header and footer**: if the page is not in **canvas mode**, the surroundings are still drawn by
> the theme — and without its CSS they are left unstyled. For a page that is entirely Aura's, turn on
> the canvas, which removes the theme's surroundings altogether.

Filters: `aipb_isolate_assets` (bool, per page) to switch the isolation off, `aipb_isolate_keep` (an
array of handles) to save a few of them, `aipb_block_style_handles` for the list of Gutenberg styles.

## SEO: how far we go and where we stop

Aura does **technical SEO in the code it writes**, and nothing else. Meta tags, title, canonical,
robots, Open Graph, structured data and sitemaps are an SEO plugin's job (Yoast, Rank Math…), and the
plugin does not write a single one of them.

**The boundary is already in the code and has to be kept.** `AIPB_Assets::strip_theme_chrome()`
cleans up `wp_head`/`wp_footer` on Aura's pages, but it recognises the callbacks **by the file path**
and only touches the ones defined inside the theme: the plugins' output goes through intact. Verified
on a canvas page with Yoast active — robots, canonical, OG, JSON-LD and `<title>` are all there. If
one day somebody "simplifies" that function by emptying the hook, the whole site's SEO disappears.

What we do do, in `AIPB_Render::finalize_html()`, deterministically and on every render:

- **One `<h1>` only, and it is the one you SEE** (`visible_h1()` + `single_h1()`). The model, left to
  itself, treats the h1 as a formality separate from the design: it used to put an
  `<h1 class="sr-only">` with an invented "SEO title" in and leave the real title in an `<h2>`. On
  this site the result was that *every* page — home, about, products, contact — declared the same
  invisible h1, «Tiramisu». Now, if the page's only h1 is hidden and there is a visible h2 further
  down, the two swap levels. Nothing is deleted: an `sr-only` heading labelling a landmark is a
  legitimate use and stays, it just is not the page's title any more.
- **Every `<img>` has an `alt`** (empty if nothing more is known), `decoding="async"`,
  `loading="lazy"` below the first image and `fetchpriority="high"` on the first one.
- **`width`/`height` worked out from the file** when both are missing: from the size in the variant's
  name (`photo-1200x800.jpg`, no query) or from the Media Library. Nothing on SVGs and external
  images: better no attribute than an invented one.
- **A `post_excerpt` that describes the page**, skipping `<header>`, `<nav>`, `<footer>` and hidden
  text. It is not a meta tag: it is raw material for whoever does that job. Without it Yoast used to
  fish from the beginning of the snapshot — which starts with the header — and a pastry shop's
  og:description was the list of menu items, twice.
  A summary **written by hand is never overwritten**; only the empty ones are redone, the ones we had
  generated ourselves (`_aipb_excerpt`) and the plainly automatic ones, recognised because over 70%
  of their vocabulary is the menu's.

**The images' alternative text lives in the Media Library**, not in the section's panel: the alt
describes the image, not the use made of it in that section, and the same photo used in three places
should be described once. It is the only field in which the attachment beats the panel. The value
written in the panel stays as a **fallback** until the attachment has its own, so a Library that is
not filled in yet does not empty the existing alts. For a decorative image you write `alt=""` in the
markup and expose no field.

> The recognition of "hidden text" (`hidden_classes()`) reads the page's real CSS rules, not the
> class names — but it **discards the `@media`** and accepts **only simple selectors** (`.class`,
> `#scope .class`). `.grid{display:none}` inside a breakpoint means "hidden below 768px", not
> "hidden", and `.navbar.is-scrolled` describes a state: before those two filters half of the home
> page's sections were marked as invisible. Better not to recognise a hidden text than to mistake a
> visible one for hidden.

## The Style panel: fixing the CSS without writing it

`assets/studio-style.js` plus the `window.AIPBStudio` bridge exposed by `studio.js`.

The **Style tab** in the left column — beside Chat, Blocks, Library, Fields, Code and Checks —
opens it: you point at an element in the preview and the rules that concern it become fields —
typography, colours, spacing, borders, effects — the way Gutenberg would show them. It is for manual
correction, the kind for which rewriting the prompt costs more than moving a value by 4px.

It was a third column on the right, opened from a button in the top bar. It moved for two reasons:
the control was the only one not where all the other panels are, and the column took 302px away from
the preview exactly while somebody was correcting how something looks — the worst moment to shrink
what you are looking at. Leaving the tab switches off the pick mode in the preview too: a crosshair
still on over a panel nobody can see is a Studio that looks broken.

Three choices explain the rest:

**We write rules, not `style=""`.** The change ends up in the section's `<style>`, so it stays
readable in the Code tab, the AI keeps understanding it, and touching `.card` applies to every card —
which is almost always what you want. The **«only this one»** switch moves to a structural selector
(`.grid > div:nth-of-type(2)`) when a single element is what you want instead.

**We edit the existing text, we do not rewrite it.** The sheet is parsed keeping the offsets of every
declaration: changing a value replaces those few characters. Reserialising the CSS would reformat
hundreds of lines written by the AI on every touch-up, and the diff in the Code tab — together with
the history — would become unreadable. Removing a rule's last declaration takes the rule away, and
with it the `@media` if it is left empty: writing and then undoing gives back a file **identical** to
how it was.

**The default selector is the one that already has a rule.** On a `<div class="card card--promo">`
the panel opens on `.card` if that is where the CSS says something: it shows the element's rules
instead of creating new ones next to them. The dot on each candidate says where rules exist. Switching
to **Hover** recalculates the candidate for that state — the normal colour is often in `.btn`, the
hover one in `.btn--primary:hover` — until a scope is chosen by hand.

**The panel opens shut.** At the top a card answers the two questions you have on arriving — *what is
selected* (the element, where it sits, which section it belongs to) and *where what I touch gets
written* (the class, the state, the rule) — each line with its caption. Underneath, the eight groups
of properties are **all closed**, and each header carries the number of values already set inside it.
Eight half-open groups meant arriving on two screens of mostly empty fields; closed, the whole thing
fits on one screen and the badges say where to click. What you open stays open while the Studio is up,
selection after selection.

Other things worth knowing:

- **States** (Normal/Hover/Focus) and **breakpoints** are writing contexts. The breakpoint is the
  Desktop/Tablet/Mobile switch already in the bar: in Tablet the change goes into a `@media`, and if
  the section already has one for that device it is written **inside it** instead of opening a second
  threshold for the same idea. On the section's container the states are disabled: a scoped `:root`
  would become `#scope :hover`, that is, the descendants.
- **Grey = computed, black with a dot = yours.** The dot removes the declaration. The values shown
  come from `getComputedStyle` inside the preview, so they are the real ones.

### The tokens are visible, and used

A value taken from the design system is not a number: it is a **bond**. `padding:
var(--aipb-space-md)` means «the brand's medium space», and changing that token changes every section
that uses it on its own. If the panel only reads «24px», that bond gets broken without anyone
noticing — just type over it.

So the diamond **◆** means one thing only across the whole panel: *this comes from the design
system*. The blue dot stays *this one I wrote myself*. They are two different marks because they are
two different things.

- Where the value IS a token, the field becomes an **accent-coloured pill**: `◆ md — 24px`, the name
  and the real value beside it. Clicking it changes token, or picks «Write a value…», which unties
  the bond **starting from the current value** instead of from scratch.
- The row's label carries a small ◆, and at the bottom of the panel a line says how many of the
  rule's values come from the design system: *3 of 3* reads at a glance, *0 of 8* likewise.
- The **◆** button sits beside every field that can take one — spacing, type sizes, radii, widths,
  shadows, offsets — and it is there **even when those tokens are not defined**: the menu then
  explains where to define them. Hiding it would mean not letting anyone know they exist.
- In the menu the tokens are **visible**: a space as a bar that long, a radius as a corner curved like
  that, a type size written at that size, a colour as a pill. A list of names lets you choose nothing.
- The **font** has a field of its own: the brand's fonts written **in their own typeface** (Google's
  stylesheet is loaded in the admin on purpose, otherwise they would all look the same in the menu),
  plus «System font» and a hand-written stack for the edge cases.

And above all: **a shorthand does not lose its tokens.** `margin: 0 0 var(--aipb-space-lg)` is
recognised as a bond even when written in the short form — which is how the AI writes it almost
always — and when touching one side forces it to be exploded into the four longhands, the parts are
worked out from the shorthand's **text**, not from the computed values: `margin-bottom` stays
`var(--aipb-space-lg)` instead of becoming `40px`. It was the quietest way this panel had of taking a
design system apart, one line at a time.

- The **colour** is picked in a dialog, and the order of things is the order in which they should be
  chosen: first the **brand's colours** — a token taken from there stays `var(--aipb-color-…)` in the
  CSS, so changing the site's palette changes the section too — then the ones **already used in this
  section** (which are the decisions taken in here, and are never zero), and at the bottom the wheel
  for an arbitrary colour, which is the last resort and not the first choice. Dragging shows it in the
  preview straight away without writing anything; «Apply» writes, «Cancel» puts it back. If the design
  system has no colours, the dialog says so and links to where to define them instead of showing an
  empty space. A `var()` is resolved from the token table — the variables do not exist here, they are
  defined in the preview — and every other spelling (names, `hsl()`, `color-mix()`) is resolved by the
  browser with a test element.
- A value is seen **immediately**, from a `<style>` injected into the preview with the same scope as
  the section (the same cascade as the final result). The render from the server and the history entry
  arrive with a steady hand, 600 ms later: a slider must not produce forty ⌘Zs.
- **Nothing is written while typing.** Every keystroke only updates the preview; the template is
  touched by the commit, half a second after the last keystroke (or right away, with Enter or by
  leaving the field). Writing on every keystroke would mean redrawing the panel halfway through «300»
  — and whoever is typing would find the field pulled out of their hands after the «3». For the same
  reason every control signs itself with `data-k`: before redrawing the panel it notes who had the
  focus, what was written and where the caret was, and afterwards it gives them back. The redraw also
  comes from outside — the preview's verification, the list of rules — so the timer's patience alone
  would not be enough.
- If the AI redoes the section and the element no longer exists, the selection is cleared instead of
  pointing at random. After every preview the element is found again by path, and failing that by
  selector.
- A **shorthand** in the rule is exploded before writing the matching longhand: changing one side of a
  `padding: 40px 20px` must not zero another one without saying so. A `background` that is only a
  colour is replaced, not put beside.
- On a **synced pattern** the panel does not edit: it says so, and explains where to do it.

### The structural scale ships filled in

Colours and fonts are the brand's, and inventing them would be deciding in somebody's place: they
start empty. **The structural tokens do not** — spacing, type sizes, radii, shadows and content
widths all carry a value out of the box, and that is deliberate.

They used to start empty too, and the cost was invisible. A site where nobody had opened the Design
Tokens page — every site on day one, and most sites for ever — sent the service a prompt whose
structural chapter read, in full: *«none defined by the user. Use sensible, consistent, elegant CSS
values, decided by you.»* So the model was **instructed to invent** a vertical rhythm, a type scale,
a corner radius and a shadow, once per section, with nothing carried over from the one before. Ten
sections, ten rhythms — which is most of what makes a generated page look as though three people had
made it.

With a value there, the strongest rule in the prompt applies instead — use `var(--aipb-*)`, never a
hand-written number — on every generation, on every page, with nobody having had to configure
anything. It stays your design system all the same: type over any of them and yours wins; empty a
field and the variable stops being emitted. What you never typed goes on following the plugin's
scale, so it moves with the plugin instead of freezing at install time.

A second thing it buys, for free: a brand reset no longer leaves a structural variable undefined, so
the failure that reset used to cause — a `var()` losing its definition and taking the whole
declaration with it — can no longer happen to one.

### Hook it to the design system

The AI writes numbers: `#0b1020`, `24px`, `12px`. They are the same values the tokens already state —
just written so that the brand can no longer change them. The panel counts how many there are and
offers to replace them: **«◆ Hook 11 values to the design system»**.

The dialog shows row by row what would change — selector, property,
`#22D3EE → var(--aipb-color-cyan)` — with checkboxes to leave out what you do not want. Before
touching anything.

The comparison is cautious by choice: colours are equated by the browser (`#22D3EE` and
`rgb(34, 211, 238)` are the same colour written by two different people), everything else is a textual
comparison up to whitespace and case. **No conversion between units**: `1.5rem` and `24px` coincide
only as long as the root is 16px, and hooking them would be a bet placed on the reader's behalf.

Every property only fishes from the group that concerns it (`font-size` from the type sizes, not from
the spaces), composite values are hooked part by part (`padding: 72px 24px` becomes
`var(--aipb-space-2xl) var(--aipb-space-md)`), and two substitutions that overlap — a whole value and
a part of it — are not both applied.

The property that matters: **hooking does not change the appearance**. It only changes what it depends
on. Verified by comparing every element's computed style before and after.

### If it goes off screen, you know beforehand

In the Tablet or Mobile preview, the preview measures who overflows to the right and says so
underneath: *«Goes off screen: `.cards` +334px»*. Hovering it highlights it, clicking it opens it in
the Style panel.

Two choices make it a warning that gets read instead of one you learn to ignore:

- It fires **only if the page really scrolls horizontally**. An element wider than its container but
  inside an `overflow:hidden` is not a defect: it is a decoration that overflows on purpose.
- It points at the **container**, not at the symptom. What overflows is the third card, but what has
  to be fixed is the grid containing them: if the parent cannot contain its own content, the parent is
  the culprit. Pointing at the card would send you looking in the wrong place.

On Desktop the question makes no sense — the preview is as wide as the window — and indeed it is not
asked.

### Breadcrumbs and notes

- Above the panel, the chain from the section to the element:
  `section › .hero › .cards › .card › h3`. Hovering it highlights it in the preview, clicking selects
  it. Clicking a card's text gives back the text's `<span>`, not the card, and aiming with the mouse
  at an element nested inside four others is a job to be done with a finger rather than simply done.
- **Copy style / Paste**: a selector's declarations can be carried over to another one, even in
  another section. The paste is a single write in the template, not N: otherwise every declaration
  would cause a redraw, a preview and a history entry of its own.

### When the change is not visible

That is how a panel like this betrays the person using it: the value is in the code, the field is
filled in, and nothing changes on screen. It happens if another, more specific rule wins
(`.stats .stat span` beats `span`), or if the value is not valid.

After every touch-up the preview **verifies** that the declaration really reaches its destination: it
puts the same declaration inline on the element — maximum specificity, no cascade to beat — and looks
at whether the computed value changes. If it does, our rule was losing; if the browser refuses the
value, it was badly written. Comparing strings would not work: `var(--x)`, `1.4` and `2rem` become
something else entirely once computed.

The field is marked orange and at the top **who is winning** appears, with its real selector — not
guessed from the text, but asked of the browser with `matches`. The **«Move to a more precise
selector»** button rewrites **only** the properties that do not get through (the others stay where
they are: the same rule can hold declarations written by the AI) onto a selector built by anchoring
the current one to the ancestors' classes, until it reaches the specificity of the one that wins — on
a tie the order decides, and the new rule sits at the bottom of the sheet. Ancestors already present
in the selector are skipped, on word boundaries: `.stat > span` must not become `.stat .stat > span`,
which is a selector matching nothing written with the face of one that works. If what wins is an
`!important`, specificity has nothing to do with it any more and the moved property takes it along.

For the same reason an element **with no classes of its own** never gets the bare tag as its only
proposal: `span` on its own loses against any rule written with a minimum of context. The default
candidate is anchored to the nearest ancestor that does have a class — `.stat > span` — which is also
how a person would write it.

On the **states** the check does not run: the element is not under the mouse, so the rule rightly does
not apply and they would all come out losing.

The CSS engine has a non-regression test of its own — `node tests/css-engine.test.js` — because a
mistake in it gives no message: it corrupts a page's code silently.

## The preview does not flash

Every change used to rebuild the preview by reassigning `srcdoc`, that is, by creating a **new**
document: a white screen for an instant, fonts and images requested all over again, the scroll reset
and put back afterwards — the flash. And since the document restarted, everything living inside it
restarted too: the sections' scripts, the Style panel's agent, the selection in progress.

Almost always a new document is not needed. Changing a field changes **one** section, and the others
have no reason to be touched; changing a style does not even change the markup. So the preview now
compares the old HTML with the new one and applies **only the difference**:

| what changed | what happens |
|---|---|
| nothing | nothing is touched |
| a text, an attribute, the CSS of a `<style>` | that value is written, the DOM stays identical |
| a section's structure | **that** section is redone, the others stay where they are |
| sections added, removed or reordered | a new document (the flash is there, and it is the moment everything is expected to change) |

Three details that make it correct instead of merely fast:

- The comparison is **old against new**, not live-document against new. The live document may have
  been changed by the section's scripts — a counter, a slider that has moved — and taking it back to
  how it came out of the server would mean resetting its state on every keystroke.
- A changed `<script>` is **not** updated in place: rewriting its text does not run it. There the
  section is redone, and `window.__aipbRun()` restarts only the new ones (the others already carry
  their mark and are skipped).
- At the slightest structural difference it says no and redoes. Better one redo too many than a DOM
  that silently diverges from what the code says.

For it to work on the **first** style change too, the Style panel rewrites the CSS *inside* the
template instead of recomposing it (`applyCss`): `splitTemplate` + `recombineTemplate` move the
`<style>` to the bottom and normalise the line breaks, so the resulting markup is equivalent but not
identical — and that alone is enough to force the preview to redo the section. By changing only what
sits between the two tags, the only difference between one render and the next is the CSS.

## Tidying the code

Style touch-ups are **additive**: every change appends its rule at the end of the `<style>` and wins
by cascade order. That is what makes a patch infallible — it cannot "miss the anchor" — but the code
swells: after twenty rounds the same selector appears twelve times and half the declarations no
longer reach the screen.

The tidying (`class-tidy.php`) puts things back in order **without AI**, because it is a problem with
an exact answer: it merges rules with the same selector in the same context (never across a
`@media`), keeps the last declaration of each property preserving the original order, and throws away
empty blocks and `/* restyle */` comments.

It does not merge a rule past another one that could hit the same elements with the same properties
**at the same specificity** — moving it would change its cascade order — and it does not touch two
declarations of the same property adjacent in the original block, which are a fallback
(`color: red; color: color-mix(…)`), not a duplicate.

- **Automatic** after every touch-up, once the operations are over (`finalize_patch`).
- **By hand** from the broom button on the section or from the `/tidy` command: it shows the summary
  and the before/after code, and applies only on confirmation (undoable with ⌘Z).
- On the HTML it does little by choice (classes and attributes repeated in the same tag, blank lines):
  there the whitespace is significant. Classes present in the CSS but never in the HTML are only
  **reported** — they may come from a `{{#loop}}` or from the JS.

## Forms

**Aura no longer does forms** (removed in 0.38.0). A serious form wants storage, notifications,
anti-spam, consents and privacy: that is a dedicated plugin's job, and keeping a second, half-done
version of it inside the builder was twice the work for a worse result.

A form is done like this: you build it with your forms plugin and hand the AI **its shortcode** (see
below). The AI frames and dresses it, the plugin does the form.

The prompt knows: if you ask it for a form without giving it a shortcode, it does not invent a
`<form>` — it builds the section and leaves an empty container to paste it into, telling you so.

What is left of that feature:

- The entries already collected stay in the database (`aipb_entry`) but no longer have a screen.
  `uninstall.php` still deletes them, so removing the plugin leaves no orphans.
- `AIPB_Filters::client_ip()` is the heir of `AIPB_Forms::client_ip()`: behind a proxy/CDN, hook
  **`aipb_client_ip`** to the header you trust. The old `aipb_form_client_ip` is still applied
  (deprecated), so whoever had configured it for Cloudflare does not break.

## Shortcodes

A shortcode written in a section **works**: `class-shortcodes.php` expands it once the section has
finished rendering (frontend, Studio preview and the filters' AJAX re-render).

```html
<div class="form">[contact-form-7 id="42" title="Contact"]</div>
```

Why a dedicated piece was needed: `the_content` applies `do_shortcode` at priority 11 on the saved
content, while our HTML comes in at priority 99 — after the train has left. Before, then, the
shortcode stayed printed on the page as text.

- **Expansion once the section is finished**, after the CSS scoping and the neutralisation of the
  scripts: the markup, the CSS and the JS coming from the plugin stay as it wrote them.
- **Never inside `<script>` and `<style>`**: among the registered tags there are some very common as
  words (`video`, `audio`, `gallery`), and a `data[video]` in a piece of JS would turn into a player.
- **In the `post_content` snapshot they stay literal**: freezing a form (with its nonce) in there
  would make no sense, and leaving them written is how the plugins that scan `post_content` to decide
  whether to enqueue their own assets find them — that is how their CSS reaches the page even though
  the shortcode lives in our meta.
- **An editable field** `{{s:key|[tag id="1"]}}`: in the panel it becomes a "Shortcode" field. It only
  accepts the `[tag attributes]` form, with no HTML around it.
- A **text** field's value that is entirely a **registered** shortcode is recognised and not escaped
  (otherwise `id="3"` would reach the plugin as `id=&quot;3&quot;`, and the plugin would answer «form
  not found»).
- The AI knows which shortcodes really exist here: the site context lists their names.
- The `aipb_do_shortcodes` filter (bool) switches the expansion off; `aipb_context_exclude_shortcodes`
  controls the list the AI sees.

> The assets enqueued by a shortcode come out in the footer, because the section is rendered after
> `wp_head`. In the Studio preview, which is a REST response, those assets are not there at all: the
> shortcode's output is visible, but it may appear unstyled.

## Filterable lists

A `{{#filter}}` turns a loop into a list the visitor can filter — by taxonomy, by search, by ordering
— updating over AJAX, without reloading the page.

```html
{{#filter id="catalogue" type="product" limit="9" orderby="date"}}
  <a href="{{u:filter.reset_url}}" data-aipb-reset {{t:filter.reset_hidden}}>All</a>
  {{#loop source="term" taxonomy="product-cat"}}
    <a href="{{u:field.url}}" data-aipb-filter="product-cat" data-value="{{t:field.slug}}"
       aria-current="{{t:field.aria_current}}">{{t:field.name}} ({{t:field.count}})</a>
  {{/loop}}
  <input type="search" data-aipb-search>
  <p><span data-aipb-total>{{t:filter.total}}</span> results</p>
  <div class="grid">
    {{#results}}<article>{{t:field.title}} — {{t:field.term_names}}</article>{{/results}}
    {{#empty}}<p>No results.</p>{{/empty}}
  </div>
  <button type="button" data-aipb-more {{t:filter.more_hidden}}>Load more</button>
{{/filter}}
```

**The state lives in the URL** (`?catalogue-product-cat=cakes,ipsum&catalogue-s=…&catalogue-page=2`):
a filtered result can be shared, reloaded and bookmarked. The prefix with the filter's `id` lets
several filters live on the same page without treading on each other.

**It works without JavaScript too**: the chips are real `<a href>`s, the server reads them and returns
the page already filtered. The runtime intercepts the clicks only to avoid the reload, and uses
`pushState` — so the Back button undoes the last filter instead of leaving the page.

Recognised controls: `data-aipb-filter` + `data-value` (a chip or a `<select>`), `data-aipb-search`,
`data-aipb-sort` (values as `field-direction`, e.g. `title-asc`), `data-aipb-more`,
`data-aipb-page="next|prev"`, `data-aipb-reset`. Counters updated on their own: `data-aipb-total`,
`data-aipb-count`. State readable in the template: `filter.total`, `filter.count`, `filter.page`,
`filter.pages`, `filter.search`, `filter.reset_url`, `filter.next_url`, `filter.prev_url`, and the
`{{#if filter.has_more}}` / `{{#if filter.is_filtered}}` conditions.

`match="all"` requires every term chosen within the same taxonomy (the default: at least one).
Different taxonomies always add up.

### Filtering by field, and by range

Taxonomies are not the only thing a catalogue is narrowed by: a price has a **from** and a **to**, a
date has a **before**. The filter declares which fields it opens with `meta="…"`, and the controls
are the same kind of markup as the chips:

```html
{{#filter id="catalogue" type="product" meta="price,date" limit="9"}}
  <input type="number" data-aipb-min="price" value="{{t:filter.min_price}}">
  <input type="number" data-aipb-max="price" value="{{t:filter.max_price}}">
  <select data-aipb-meta="colour">…</select>
  {{#results}}…{{/results}}
{{/filter}}
```

`data-aipb-min` / `data-aipb-max` are the two ends of a range, `data-aipb-meta` an equality (one
value or a list). In the URL they are `catalogue-min-price`, `catalogue-max-price`, `catalogue-m-colour`
— a prefix of their own, because a field and a taxonomy can perfectly well share a name, and without
it the same key would mean two things.

Two gates, and both are needed. The field has to be **named in `meta="…"`** — the section decides
what it opens, not the caller — and it has to be **really declared for that content type**, so the
comparison uses the right type: a price compared as text puts «100» before «20», which is the most
elegant way to get a price filter wrong without anybody noticing. **Ranges only apply to what can be
ordered** (numbers and dates); on anything else the bounds are ignored and the equality stands.

The current values are readable in the template as `filter.min_<field>`, `filter.max_<field>` and
`filter.m_<field>` — flattened, because the placeholders do not go two levels down. Conditions on
different fields add up: under 30 euros **and** after 1 March.

### The endpoint's security

`/wp-json/aipb/v1/filter` is public — whoever filters a catalogue is not logged in — and it stands on
one principle: **the query is commanded by the template, not by the client**.

- No `WP_Query` argument ever arrives from the browser: only term slugs, a search string and an
  ordering from a whitelist. The server **re-reads the saved template** and rebuilds the query from
  scratch; the content type, the per-page limit and the fixed constraints stay the ones written in the
  section. The client can only narrow down within what the template has already granted.
- Only taxonomies that are **public and really connected** to that post type: the others are ignored,
  instead of becoming a way of probing contents that should not show up.
- Only `publish` contents: the endpoint is not a window onto drafts and private posts.

The defences above prevent **reading** what should not be read. What was left open was the cost: a
public endpoint that accepts expensive requests is an amplifier, even without exposing anything.

- **A search under 2 characters: ignored.** A search is a `LIKE '%…%'` over the title and the content,
  which no index helps; `?catalogue-s=a` was a full table scan on request. Below the threshold the
  term is dropped and the list stays the complete one — it is not an error, whoever is still typing
  does not deserve a red message. The threshold: `aipb_filter_min_search`.
- **The page is capped at the top too** (200, `aipb_filter_max_page`): `?catalogue-page=999999999`
  used to reach `WP_Query` intact and become a huge OFFSET with `SQL_CALC_FOUND_ROWS` on.
- **A brake on searches alone**: 40 a minute per IP (`aipb_filter_rate_limit`), beyond which the
  endpoint answers `429`. The search field waits 300 ms before starting, so whoever is typing does not
  come near it. The IP is worked out by `AIPB_Filters::client_ip()`, filterable with `aipb_client_ip`
  (and still with the old, deprecated `aipb_form_client_ip`): one place in the plugin knows how to
  stand behind a proxy, and it has to stay one.
- **A response cache** (5 minutes, `aipb_filter_cache_ttl`, `0` to switch it off): a click on a chip
  goes from ~16 queries to 0. Chips, orderings and pages are a small, repetitive set — exactly what is
  worth caching.

**Searches are never cached**, and that is deliberate: the key would be chosen by the caller, and
filling the options table with transients does as much damage as scanning it. That is why they are the
only thing that pays the brake's toll.

The cache invalidates itself, without chasing events: the key holds the page's modification date —
because the filter's template lives in there — and `get_lastpostmodified()`, that is, the last change
to any content. Publishing a product makes the old answers unreachable, without having to delete them.
It is coarse on purpose: it also invalidates filters that have nothing to do with that content, but
erring on the generous side costs a query, while erring on the mean side means serving an old
catalogue.

### How it replaces the results

The results are delimited by two **HTML comments**, not by a container: slipping a `<div>` between the
grid and the cards would break the layout the AI designed. The runtime empties and refills the range
between the two markers.

## Installation

The plugin is the Aura service's **client**: the generation happens there (see `ARCHITECTURE.md`). No
build, no composer/npm. Requirements: **WordPress 5.9+**, **PHP 7.2+** (for libsodium, which encrypts
the licence key at rest).

Compress the plugin's folder into a zip and upload it from **Plugins → Add new → Upload plugin**, or
copy it into `wp-content/plugins/`. Then **Plugins → Activate "Aura"**.

Finally the configuration, which is **one value you have to set**: in **Aura → Settings** you paste
the **activation key** — the one that arrived by email at the time of purchase, and always findable
again in your own Aura panel. The **Verify** button tries the key you have just pasted, before even
saving it, and tells "I cannot reach the service" apart from "I reach it but the key is refused".

The other field on that page is already set: **Interface language** follows WordPress unless you
tell it otherwise. It changes Aura's own screens and nothing else — the rest of the admin, the other
plugins and the site itself go on speaking whatever WordPress is set to — which is what makes it
useful, because building an Italian site and reading the tool in English is a common enough wish and
the alternative is changing the language of the whole installation. English and Italian ship today.

The service address is NOT configured: it is always ours, and it is stamped into the zip when it is
built (`bin/build-plugin.php` on the server takes it from `AURA_PUBLIC_URL`). One field fewer is one
character fewer to get wrong — and a mistyped address looks exactly like a refused licence.

The key is **encrypted** before it lands in the database (see `AIPB_Secret`) and is not shown again.
No model key lives on the site: the tokens are paid for by the service.

For DEVELOPMENT the address to talk to is the one in `AIPB_SERVER_URL`, at the top of
`ai-page-builder.php`: the plugin carries it with itself, so a working copy symlinked into any site
already knows where to go. `define( 'AURA_SERVER_URL', ... );` in a site's `wp-config.php` still
overrides it, for the one site out of several that has to point elsewhere.

### Updates

Once installed, the plugin updates itself from the dashboard like any other: a newer release shows
up as the usual "There is a new version available" row, with **Update now**. Core does the
download, the unpacking and the swap — see `includes/class-update.php` for the small part that is
ours.

**How soon.** WordPress is pull-based: nothing is ever sent to a site, it finds out when it next
asks, and left alone it asks every twelve hours. So the plugin does not wait to be asked — an
hourly job looks at what the service is serving and, when there is something newer, writes the
offer straight into the transient the dashboard reads. The badge is there on the next admin page
load. What that cannot do is reach a site with no traffic at all: WP-Cron only runs when somebody
visits, which is WordPress and not something this can fix. The honest promise is **within the hour
of a site being used**.

Three things it depends on, and each one breaks it silently if it is wrong:

- **The folder must be `aura-client`.** That is the root inside the zip, and WordPress replaces
  the directory whose name matches. Installed anywhere else, an update lands beside the old copy
  instead of over it, and the site runs two plugins that both think they are Aura.
- **The service must be published over `https://`.** The package URL is code that gets fetched and
  unpacked over a live site; over plain HTTP whoever sits in between chooses what that code is, so
  the plugin declines it. A service on HTTP generates fine and never offers an update.
- **Not on a symlink.** In development the plugin is usually a symlink to the working directory,
  and an updater that deletes and recreates that directory would take the working copy with it.
  It does not come up in practice, because the source tree ships with an empty `Update URI:` header
  and only the built zip carries a real one — a symlinked checkout is never offered anything.

### Releasing a version

**Bump `Version:` in `ai-page-builder.php`, bump `AIPB_VERSION` on the line below it to match, and
push to `main`.** That is the whole procedure.

`.github/workflows/release.yml` does the rest: it waits for the `tests` workflow to go green on
that commit, checks the service answers, and sends **the source tree** to
`POST /v1/plugin/release`. The service packages it there — stamping in its own address, checking
that the two version numbers agree, writing the zip and the manifest in one run — and installations
are offered it within the hour.

Three things worth knowing about it:

- **The two version numbers must agree.** The header is what WordPress reads and what the updater
  compares; `AIPB_VERSION` is what the running code knows itself as. The packaging **refuses** if
  they differ, because the failure is otherwise silent in both directions: an update that never
  arrives, or one that installs and then offers itself forever because the dashboard still shows
  the old number. The build is the only place that check can be made, since it is the only road to
  the customer.
- **A push that does not move `Version:` publishes nothing.** The workflow asks the service what it
  is currently serving and skips if it matches. One version, one artefact — otherwise "it is on
  0.59" stops being an answer to anything.
- **No zip is built here, and none is committed anywhere.** The package carries the service's
  address inside it, so it is produced where that address is known. A zip built on a laptop says
  `http://127.0.0.1:8080`, and since that is not https the plugin refuses its own updates from then
  on, silently, forever.

It needs two secrets on the repository — `AURA_SERVICE_URL` and `AURA_RELEASE_KEY`. The workflow
file says where each comes from. (The service was on a tailnet until it moved to a public address;
the `TS_OAUTH_CLIENT_ID` / `TS_OAUTH_SECRET` pair the runner used to join it is no longer read, and
can be deleted from the repository's secrets.)

### If the service does not answer

The pages already built **are not touched**: they live in the site's meta and the local engine draws
them. The only thing that stops is the generation of new sections, with an explicit message in the
Studio. It is a property of the boundary, not a courtesy: it is worth telling the customers.

### First steps

- Create a **Page**: it opens in Gutenberg; in the **Aura** side box click **"Build with Aura"**.
- Write a prompt → **Generate with AI** → edit the fields on the right → **Save section** → publish.

## Icons

The icons are a **folder of SVGs the plugin carries locally** (`assets/icons/`) — Lucide's artwork,
ISC licensed, with the notice in `assets/icons/LICENSE`. What is ours is the plumbing, not the
drawings. The AI uses them with Lucide's native syntax — `<i data-lucide="arrow-right"></i>` — and
at render time we replace them with the **inline** SVG (they inherit the colour through
`currentColor` and the size through `1em`).

Carrying them rather than linking them is the same rule as everywhere else: a visitor's browser
must not fetch code or assets from somebody else's server.

- **Light**: at render time **only the icons actually used** are read and printed.
- **Safe**: only the folder's SVGs end up on the page; the name is whitelisted (`[a-z0-9-]`, no path
  traversal); any scripts/handlers are stripped.
- **The full set**: copy the whole of Lucide in as a drop-in →
  `cp node_modules/lucide-static/icons/*.svg assets/icons/`.
- **Your own icon**: add `assets/icons/my-logo.svg` (use it with `data-lucide="my-logo"`), or overwrite
  an existing name, or go through the filter
  `add_filter('aipb_icons', fn($m) => $m + ['x' => '<svg…>'])`.

## Security

The **templates** hold raw HTML/CSS/JS, including `<script>`s that run on the frontend: they are
**trusted content**, whereas the fields' **values** are always sanitised by type by the engine.

That is why the builder is reserved for whoever can write unfiltered markup:

- The Studio, the "Build with Aura" box, the row actions and **every REST endpoint** require the
  **`unfiltered_html`** capability (admin/editor on a single site; super admin on multisite).
- The capability is filterable: `add_filter('aipb_required_cap', fn() => 'manage_options');`
- Whoever does not have the capability sees neither the Studio nor the buttons, and the REST calls are
  refused.

## Known limits

- Editing by props plus block reordering (no free pixel-perfect dragging).
- Every section lives in the **light DOM** with its CSS confined by rewriting the selectors
  (`class-css-scope.php`): the markup stays in the document — so it is indexable — and the styles do
  not leave the section. Scripts using `document.querySelector*` are redirected inside their own
  section through a proxy (`AIPB_Render::runtime_js`).
- **Archives**: `{{#loop … term="current"}}` still does not paginate — it is meant for a curated list,
  not for browsing. For a long archive you use `{{#filter … term="current"}}`, which inherits the term
  of the category being looked at and does have pagination: it is the `archivio-filtro` chapter
  that teaches it (the server's chapter names are a contract and stay as they are), and it only
  reaches the engines declaring the `filter-current` capability.
- **Non-taxonomy archives**: `term.*` covers categories, tags and custom taxonomies. On
  `archive-<type>`, author, date and search the query's object is not a term and it stays empty.
- The plugin's **custom fields** are scalar: ten types, no repeater. A repeater stays ACF's business,
  which encodes it its own way — and the two worlds do not read each other there. It is a choice, not
  a gap: a value inside a JSON cannot be queried, and without queryable values there will never be a
  way to filter by price or by date.

## Tests

```
php bin/test.php                 everything: the lint, then every file in tests/
php bin/test.php --only=tidy     one file
php bin/test.php --lint-only     just the sources, which is what CI runs under PHP 7.2
```

Translations are part of it, and they are four scripts rather than one because the two halves of
the interface read two different formats. `bin/make-pot.php` rebuilds the catalogue from the
sources, `bin/merge-po.php` brings every `.po` back in line with it without losing a translation,
`bin/make-mo.php` compiles the `.mo` PHP reads, and `bin/make-json.php` writes the per-handle JSON
files the Studio's JavaScript reads. None of the four needs gettext or WP-CLI installed, for the
same reason: a catalogue that only builds on one laptop ships stale, silently.

`tests/i18n.test.php` fails when a `.mo` or a `.json` is older than its `.po`, when a script that
asks for translations has no catalogue beside it, when a `.po` still carries a string the sources no
longer have, or when a translation has lost a `%s` or a `<b>` — the ways this goes wrong without
anybody noticing. `tests/locale.test.php` covers the choice of language itself: that it follows the
PERSON and not the site, that the override reaches our text domain and no other, and that a locale
whose catalogue has stopped being shipped falls back instead of pointing at a missing file.

No framework and nothing to install: each file in `tests/` is a plain script that exits non-zero
when it is unhappy, and prints what went wrong on the way. The runner lints every PHP source with
the interpreter it is running on, so running it under the oldest supported PHP is what keeps the
`Requires PHP: 7.2` header honest — see `.github/workflows/tests.yml`, which does exactly that in a
job of its own.

The JS tests need `node`. If it is missing the run FAILS rather than skipping them: a green that
covered two thirds of the suite looks exactly like a green that covered all of it.

## Possible next steps

- **A third language.** English and Italian are complete on both sides — PHP and the Studio — and
  the machinery to add another is a `.po` and two commands (see `languages/README.txt`). The list in
  Aura → Settings is read off the `.mo` files on disk, so a language starts being offered the moment
  it is shipped.
- **Media as a first-class citizen**: contents get an image when they are created, but a section
  cannot yet ask for one by itself (`{{i:…}}` reads what exists, it does not go looking).
- **Coverage where there is none**: `class-rest.php`, `class-admin.php` and `class-nav.php` are the
  three largest files no test touches. The REST layer is the one that matters — it is where every
  permission check lives.
