# Edit Translation modal — ACF + SEO accordions

**Slug:** `modal-acf-yoast-accordions`
**Date:** 2026-08-15
**Branch:** `wt/modal-acf-yoast-accordions` (worktree off `origin/master`)
**Surface:** Translations → Pages/Posts → “Edit Translation” modal (`admin/src/pages/content-translate.js`)

## Goal

Under the Content row of the Edit Translation modal, present two collapsed disclosure groups —
**Custom Fields (n)** and **SEO (n)** — each expanding to the same two-column
`original | translation` grid used by Title/Excerpt/Content. Each group renders **only** when its
integration is available on the site and it has at least one field; otherwise nothing is emitted
(no header, no empty state, no placeholder).

SEO reaches full parity with ACF: source vs translation display, editable inputs, persistence on
Save, and inclusion in the AI Translate/Generate payload.

## Current state (verified against `origin/master`)

- ACF fields already reach the modal flat, appended after Content:
  `TranslationsController` builds `language_details[].acf_fields` via
  `ACFIntegration::editorFields( $sourceId, $targetId )` (≈ line 1568), rendered by
  `renderAcfOriginalFields()` / `renderAcfTranslationFields()`, collected on save from
  `[data-acf-token][data-acf-mode="translate"]` into the `acf_fields` request param
  (≈ line 1632).
- **Yoast is not wired into this flow at all.** `YoastSEOIntegration::sync_seo_meta` is bound to
  `ipz_after_post_translation`, an action **no code in the plugin ever fires**. Yoast meta is
  therefore never translated today.
- Backend `fields` channel accepts an arbitrary token→string map and echoes keys verbatim:
  `canonicalizeStructuredFields()` only sorts, and `geminiClient` hard-fails when response keys
  differ from input keys. **Key charset is constrained**:
  `STRUCTURED_FIELD_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/` — colons are rejected, so
  a `seo::…` namespace is not viable; `ipz_seo_*` is.
- ACF tokens are `ipz_<32 lowercase hex>` and `ipz_verify_<32 hex>`. `ipz_seo_` cannot collide
  (`s` is not a hex digit).
- `TranslateController` shapes confirmed on master: `buildContentApiPayload()` (≈ line 1265) takes
  `array $acfFields` and sets `$payload['fields']` only when non-empty; `$acf_context['source_fields']`
  / `['submitted_keys']` and `$characters_estimated` are built at ≈ lines 961–966.
- `TranslationFinalizer` applies ACF through **two** call sites — `applyAutomatic()` and `apply()`
  (≈ lines 294–295) — and restores prior target state through `restoreExistingTarget()`
  (≈ line 948).

**No backend change is required.** SEO rides the existing generic `fields` map.

## Approach

**Chosen: namespaced tokens on the existing `fields` channel, behind a provider seam.**

| Dimension | Assessment |
|-----------|------------|
| Robustness | Backend already validates key round-trip; partition is explicit, driven by a token list carried in `translation_context`, with prefix as a defensive assertion |
| Long-term | SEO plugin knowledge isolated in one provider; `CompatibilityManager` already registers `rankmath` as the natural second adapter |
| Scalability | Adds ≤7 short strings per job; character estimate accounts for them |
| Performance | Source SEO read once per request, not once per language |
| Reversibility | Two-way door — no schema change, no backend deploy, no new endpoint |

**Rejected: revive `ipz_after_post_translation`.** Firing it to activate `sync_seo_meta` would
simultaneously switch on `ElementorIntegration::translate_elementor_data`, which has never run in
production, on every site with Elementor. Unrequested behavior change in never-exercised code.
The finalizer writes SEO meta directly instead; the dead action and `sync_seo_meta` are left
untouched (removing them is a separate call).

## Components

### 1. `SeoFieldsProvider` (new interface) — `includes/Compatibility/Seo/`

```php
interface SeoFieldsProvider {
    public function isAvailable(): bool;
    /** @return array<string,string> token => human label */
    public function tokens(): array;
    /** @return array<string,string> token => value read from $postId */
    public function read( int $post_id ): array;
    /** @param array<string,string> $values token => value */
    public function write( int $post_id, array $values ): void;
}
```

### 2. `YoastSeoFieldsProvider implements SeoFieldsProvider`

- `isAvailable()`: `defined( 'WPSEO_VERSION' )` (same predicate as `YoastSEOIntegration`).
- Token map is a **static, explicit, stable** table — never derived by hashing, so tokens stay
  debuggable and reversible:

  | token | meta key | label |
  |---|---|---|
  | `ipz_seo_title` | `_yoast_wpseo_title` | SEO Title |
  | `ipz_seo_metadesc` | `_yoast_wpseo_metadesc` | Meta Description |
  | `ipz_seo_og_title` | `_yoast_wpseo_opengraph-title` | Facebook Title |
  | `ipz_seo_og_desc` | `_yoast_wpseo_opengraph-description` | Facebook Description |
  | `ipz_seo_tw_title` | `_yoast_wpseo_twitter-title` | Twitter Title |
  | `ipz_seo_tw_desc` | `_yoast_wpseo_twitter-description` | Twitter Description |
  | `ipz_seo_focuskw` | `_yoast_wpseo_focuskw` | Focus Keyword |

  All tokens satisfy `STRUCTURED_FIELD_KEY_PATTERN`. Labels use the `international-press-zone`
  text domain. Reuse `YoastSEOIntegration::get_translatable_fields()` for the label strings rather
  than duplicating them.
- `read()` returns **every** token in `tokens()`, with `''` for meta that is unset. Callers filter:
  the translate path drops empties (nothing to pay to translate), the detail path renders them as
  empty inputs so a user can author SEO translations from scratch.
- `write()` uses `update_post_meta` for each submitted token. An empty string **clears** the meta
  (Yoast treats empty as unset); a token absent from `$values` leaves existing meta untouched.
  This is the deliberate answer to “user clears a translated meta description”: it clears.

Registration: **`CompatibilityManager` does not exist on `origin/master`** — it appears only in the
unrelated `fix/content-generate-all` working checkout, and an early draft of this spec wrongly
cited it. Availability is gated by the provider's own `isAvailable()`, and the provider is injected
the way `ACFIntegration` already is in these classes: a nullable constructor parameter defaulting
to `new YoastSeoFieldsProvider()`, reached through a single per-class accessor. When no provider is
available, every consumer sees an empty token set and the feature is inert.

### 3. Token partition — the one place a convention bug corrupts a channel

```php
final class SeoTokenPartition {
    /**
     * @param array<string,string> $translated_fields  full map returned by the backend
     * @param list<string>         $seo_tokens         authoritative list from translation_context
     * @return array{acf: array<string,string>, seo: array<string,string>}
     */
    public static function split( array $translated_fields, array $seo_tokens ): array;
}
```

Partition is driven by `$seo_tokens` (authoritative), with `str_starts_with( $token, 'ipz_seo_' )`
asserted as a consistency check — a token matching the prefix but absent from `$seo_tokens`, or
vice versa, is a hard error, not a silent pass.

This must run **before** the ACF apply. Verified: both apply call sites funnel into
`ACFIntegration::apply_runtime()` (≈ line 2287), which **fails closed** — any token outside
`context['field_map']` returns `ipz_acf_fields_mismatch` (≈ line 2292), and in automatic mode it
additionally requires exact set equality with `context['translation_fields']` (≈ line 2301).
So an unpartitioned `ipz_seo_*` token cannot silently corrupt an ACF value — but it *will* fail
every job. Partitioning is mandatory, and its absence surfaces loudly. Both call sites
(`applyAutomatic()` and `apply()`) receive the ACF partition only.

### 4. Read path — `TranslationsController` content detail

- Compute `$source_seo = $provider->read( $post_id )` **once, above the per-language loop**. Source
  SEO values are language-invariant; the loop already runs once per configured language on this
  payload and must not gain that many redundant meta reads.
- Per language detail gains:
  ```
  seo_fields: [ { token, label, source_value, target_value } ]
  ```
  ordered by the provider's token table, target values read from `$detail['element_id']` when it
  exists (empty strings otherwise). Emitted as `[]` when the provider is unavailable — that empty
  array is the *only* thing that suppresses the accordion, so the frontend needs one rule, not two.
- `source_value` is duplicated into every language entry. This is a **deliberate parity choice**
  with the existing `acf_fields` shape, not an accident of the hoist — the frontend reads source
  and target from the same per-language record. Only the *read* is hoisted, not the shape.

### 5. Write path — `saveContentTranslation`

New request param `seo_fields` (token → string), distinct from `acf_fields`:

- `null` → `array()`; non-array → `WP_Error( 'invalid_seo_fields', …, 400 )`.
- Unknown token (not in the provider's table) → `WP_Error( 'invalid_seo_fields', …, 400 )`.
- Sanitize with `sanitize_text_field( wp_unslash( $value ) )` — SEO meta is plain text, **not**
  HTML. Do not route it through `wp_kses_post` (the ACF sanitizer) and do not reuse the
  `acf_fields` param: ACF's copy/translate mode semantics do not apply here.
- Persist via `$provider->write( $translation_post_id, $values )` after the translated post is
  saved.

### 6. Translate path — `TranslateController`

- `$seo_fields = array_filter( $provider->read( $content_id ) )` — empty source values are never
  sent for translation.
- Merge into the API payload's `fields` map alongside `$acf_fields` (`buildContentApiPayload`
  gains a `$seo_fields` parameter, or receives a pre-merged map — either is acceptable provided
  the merge happens in exactly one place).
- Merge into `$acf_context['source_fields']` and `$acf_context['submitted_keys']`, and add
  `$acf_context['seo_tokens'] = array_keys( $seo_fields )` — the authoritative partition list the
  finalizer consumes.
- Add SEO characters to `$characters_estimated` so the estimate matches what is billed.
- `content_hash` already derives from `$translation_context`, so including SEO source values there
  makes an SEO-only edit produce a distinct hash rather than a hash-identical re-run.

### 7. Finalizer — `TranslationFinalizer`

- Split `$translated_fields` with `SeoTokenPartition::split()`; pass **only** the ACF partition to
  `applyAutomatic()` / `apply()`.
- When the target already existed, snapshot existing SEO meta alongside `$existing_acf`
  (`$existing_seo = $provider->read( $target_id )`), and restore it in `restoreExistingTarget()`
  on failure — a half-written `_yoast_wpseo_*` set on a failed job is the failure mode this
  prevents. `restoreExistingTarget()` gains a nullable SEO snapshot parameter.
- Write the SEO partition only after the ACF apply succeeds, so both channels commit together.

### 8. Frontend — `admin/src/pages/content-translate.js`

Reuse the existing disclosure pattern from `fields-translate.js` (≈ line 420): `<details>` +
`<summary>` with `data-disclosure-key`, `aria-controls`, `aria-expanded`.

```
renderFieldDisclosure({ key, label, count, open, originalNodes, translationNodes }) -> HTMLDetailsElement
```

- Rendered after the Content row, full modal width, `open = false` by default.
- Summary text: `Custom Fields (12)` / `SEO (7)` — count = number of fields in the group.
- Body reuses the existing `ipz-st-edit-grid__original` / `__translation` two-column grid so
  expanded rows line up with Title/Content above.
- **Group is omitted entirely when its field array is empty.** Availability and emptiness collapse
  to one rule — no separate “not installed” branch.
- ACF group body = existing `renderAcfOriginalFields()` / `renderAcfTranslationFields()` output,
  moved (not rewritten) inside the disclosure.
- SEO inputs: `<input type="text">` (single-line) for titles/keyword, `<textarea rows="2">` for
  descriptions, carrying `data-seo-key="<token>"`, `dir` from the language's `text_direction`,
  `readonly` while `isProcessing` — mirroring the existing field controls.
- Save collects `this.modal.modalEl.querySelectorAll( '[data-seo-key]' )` into `seo_fields`.
- **Open/closed state must survive re-render.** The modal fully re-renders on language-tab switch,
  save, and job-poll updates; persist per-group open state on the page instance
  (`this.editModalDisclosureOpen = { acf: false, seo: false }`) and feed it back as `open`.
- Styling in `admin/src/styles/pages/_translations.scss` only — no inline CSS (monorepo rule).

## Data flow

```
GET /translations/content/{id}
  provider.read(source) ──once──┐
                                ├─> language_details[].seo_fields[{token,label,source_value,target_value}]
  per-language: provider.read(target)

Translate:
  acf.extract + provider.read(source)
    -> payload.fields = { ipz_<hex>: …, ipz_seo_*: … }
    -> translation_context.seo_tokens = [ipz_seo_*]
  backend echoes keys verbatim
    -> SeoTokenPartition::split(translated, seo_tokens)
       ├─ acf partition -> ACFIntegration::apply()/applyAutomatic()
       └─ seo partition -> provider.write(target)

Save (manual):
  PUT /translations/content/{id} { …, acf_fields, seo_fields }
    -> provider.write(target)
```

## Error handling

| Condition | Behavior |
|---|---|
| No SEO plugin active | Provider unavailable → empty token set → no accordion, no params accepted, no payload contribution |
| `seo_fields` not an array | `400 invalid_seo_fields` |
| Unknown token in `seo_fields` | `400 invalid_seo_fields` |
| Partition inconsistency (prefix vs `seo_tokens`) | Job fails with an explicit error; nothing written to either channel |
| ACF apply fails on an existing target | Existing post, ACF **and** SEO meta all restored via `restoreExistingTarget()` |
| Backend omits an SEO key | Cannot occur silently — `geminiClient` rejects key-set mismatch upstream |

## Testing

- **PHPUnit (standalone, `tests/unit/`)** — provider token table + availability gating;
  `SeoTokenPartition::split()` including the inconsistency error; `saveContentTranslation`
  rejection of malformed/unknown `seo_fields`; source-SEO hoisting (assert one source read per
  request, not one per language).
- **Vitest (`admin/tests/`)** — accordion renders collapsed by default; omitted when the group is
  empty; open state survives a language-tab switch; `seo_fields` collected from `[data-seo-key]`
  on save.
- **E2E** — extend the content-translate journey spec with expand/collapse + SEO round-trip.
- **Gate:** all gates run remotely via `/ipz-e2e` on registry-selected debian1/2/3. Never gate
  locally.

## Pre-existing bug found during this work — FIXED in `d1b359ea9`

`TextareaControl` / `InputControl` in `admin/src/components/FormControls.js` destructure a fixed
option set and spread **only** `safeControlAttributes( attrs )` — attributes passed at the *top
level* are silently discarded.

`renderAcfTranslationFields()` passes `dir`, `data-acf-token` and `data-acf-mode` at the top level.
They therefore never reach the DOM, so on `origin/master`:

- `saveContentTranslation()`'s `querySelectorAll( '[data-acf-token][data-acf-mode="translate"]' )`
  matches nothing → **manually edited ACF field translations are silently discarded on Save**
- `dir` is lost → ACF inputs render LTR for RTL target languages

This predates this feature and is independent of it. The SEO inputs added here nest `dir` and
`data-seo-key` under `attrs`, so `seo_fields` collection is unaffected.

**Fix (when someone takes it):** nest the three attributes under `attrs` in
`renderAcfTranslationFields()`, plus a regression test asserting they reach the DOM.

## Known behavior, deliberately not handled

- `_yoast_wpseo_focuskw` is a keyword, not prose; machine-translating it is semantically dubious.
  Included per the full-parity decision, but flagged — it may warrant a copy-mode later.
- `_yoast_wpseo_metadesc` has a ~156-character display budget that translations routinely
  overflow. No length handling is built; overflow is accepted as existing Yoast behavior.

## Architecture Decisions

- **`SeoFieldsProvider` seam kept** — passes the single-adapter test honestly: `CompatibilityManager`
  already registers `rankmath` as the obvious second implementation. Only Yoast is built here.
- **`SeoTokenPartition` kept as a named unit** — deletion scatters prefix logic across the
  finalizer and the controller, which is exactly where a convention bug corrupts data silently.
- **No new REST endpoint** — SEO extends the existing content detail/save contracts. A dedicated
  endpoint would fail the deletion test (complexity would not scatter; it would just add a hop).
- **`ipz_after_post_translation` left dead** — reviving it is an unrequested Elementor behavior
  change (see Approach).
