# Edit Translation modal — ACF + SEO accordions — request

**Spec:** `docs/specs/2026-08-15-modal-acf-yoast-accordions-design.md` (read it first — it carries
the verified backend constraints and the rejected alternative)
**Worktree:** `/home/user/Projects/Press.zone/wordpress/wp-content/.worktrees/modal-acf-yoast-accordions/plugins/international-press-zone`
**Branch:** `wt/modal-acf-yoast-accordions`

**Goal:** Render ACF and Yoast SEO fields as two collapsed accordions under Content in the Edit
Translation modal, each shown only when its integration is active, with Yoast reaching full parity
with ACF (display, edit, save, AI-translate).

## Context

ACF already flows end-to-end through this modal. Yoast does not exist in it at all —
`YoastSEOIntegration::sync_seo_meta` hangs off `ipz_after_post_translation`, an action nothing in
the plugin fires. This request adds a parallel SEO channel that rides the **existing** backend
`fields` map, so no backend change and no new REST endpoint.

Hard constraint verified from backend source: structured field keys must match
`/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/`. Colons are rejected. Tokens are `ipz_seo_*`.

## Files

**Create**
- `includes/Compatibility/Seo/SeoFieldsProvider.php` — interface for SEO-plugin field access
- `includes/Compatibility/Seo/YoastSeoFieldsProvider.php` — Yoast implementation
- `includes/Translation/SeoTokenPartition.php` — splits a backend field map into ACF vs SEO
- `tests/unit/Compatibility/YoastSeoFieldsProviderStandaloneTest.php`
- `tests/unit/Translation/SeoTokenPartitionStandaloneTest.php`
- `admin/tests/content-translate-accordions.test.js`

**Modify**
- `includes/API/TranslationsController.php` — content detail (≈1542–1570) gains `seo_fields`;
  `saveContentTranslation` (≈1632) gains the `seo_fields` param
- `includes/API/TranslateController.php` — `buildContentApiPayload` (≈1265) and the context/estimate
  block (≈961–966)
- `includes/Translation/TranslationFinalizer.php` — partition before apply (≈294–295); SEO snapshot
  + restore (`restoreExistingTarget`, ≈948)
- `admin/src/pages/content-translate.js` — disclosures in `renderEditModalContent`, collection in
  `saveContentTranslation`
- `admin/src/styles/pages/_translations.scss` — disclosure styling

## Contracts

Namespace root is `InternationalPressZone`. PHP files follow the repo's WPCS style
(`array()`, spaced parens, `declare(strict_types=1)`, `ABSPATH` guard).

### SeoFieldsProvider — `InternationalPressZone\Compatibility\Seo`

```php
interface SeoFieldsProvider {
    public function isAvailable(): bool;
    /** @return array<string,string> token => translated human label */
    public function tokens(): array;
    /** @return array<string,string> every token from tokens(), '' when meta is unset */
    public function read( int $post_id ): array;
    /** @param array<string,string> $values token => value; '' clears the meta */
    public function write( int $post_id, array $values ): void;
}
```

### YoastSeoFieldsProvider

`isAvailable()` returns `defined( 'WPSEO_VERSION' )`.

Token table — static, exact, in this order. Labels come from
`YoastSEOIntegration::get_translatable_fields()` keyed by meta key; do not duplicate the strings.

| token | meta key |
|---|---|
| `ipz_seo_title` | `_yoast_wpseo_title` |
| `ipz_seo_metadesc` | `_yoast_wpseo_metadesc` |
| `ipz_seo_og_title` | `_yoast_wpseo_opengraph-title` |
| `ipz_seo_og_desc` | `_yoast_wpseo_opengraph-description` |
| `ipz_seo_tw_title` | `_yoast_wpseo_twitter-title` |
| `ipz_seo_tw_desc` | `_yoast_wpseo_twitter-description` |
| `ipz_seo_focuskw` | `_yoast_wpseo_focuskw` |

When `isAvailable()` is false, `tokens()` and `read()` both return `array()`.

Resolution: availability is gated by the provider's own `isAvailable()`. **There is no
`CompatibilityManager` on `origin/master`** — it exists only in the unrelated
`fix/content-generate-all` checkout, and an earlier revision of this plan wrongly named it.
Inject the provider exactly as `ACFIntegration` already is in these classes: a nullable constructor
parameter defaulting to `new YoastSeoFieldsProvider()`, reached through one accessor per class.

### SeoTokenPartition — `InternationalPressZone\Translation`

```php
final class SeoTokenPartition {
    /**
     * @param array<string,string> $translated_fields backend response map
     * @param list<string>         $seo_tokens        authoritative list from translation_context
     * @return array{acf: array<string,string>, seo: array<string,string>}
     * @throws \UnexpectedValueException on prefix/list disagreement
     */
    public static function split( array $translated_fields, array $seo_tokens ): array;
}
```

`$seo_tokens` is authoritative. `str_starts_with( $token, 'ipz_seo_' )` is asserted against it:
a token in one and not the other throws. Never "best effort".

### REST — content detail

Each entry of `language_details` gains:

```
seo_fields: [ { token: string, label: string, source_value: string, target_value: string } ]
```

Ordered by the provider token table. `array()` when the provider is unavailable — that empty
array is the only accordion-suppression signal the frontend gets.

`$source_seo = $provider->read( $post_id )` is computed **once, above the per-language loop**.
`source_value` is then duplicated into each language entry — deliberate parity with the existing
`acf_fields` shape.

### REST — `saveContentTranslation`

New param `seo_fields`, token => string, independent of `acf_fields`.

- `null` → `array()`
- not an array → `WP_Error( 'invalid_seo_fields', __( 'SEO field translations are invalid.', 'international-press-zone' ), array( 'status' => 400 ) )`
- any key not in `$provider->tokens()` → same `WP_Error`
- sanitize each value with `sanitize_text_field( wp_unslash( $value ) )` — **not** `wp_kses_post`
- persist with `$provider->write( $translation_post_id, $values )` after the translated post saves

### Translate path

- `$seo_fields = array_filter( $provider->read( $content_id ) )` — empty source values never ship
- merged into the `fields` map in exactly one place (extend `buildContentApiPayload` with a
  `array $seo_fields` parameter)
- `$acf_context['source_fields']` and `['submitted_keys']` gain the SEO tokens
- `$acf_context['seo_tokens'] = array_keys( $seo_fields )`
- `$characters_estimated` gains `array_sum( array_map( 'mb_strlen', $seo_fields ) )`

### Finalizer

- `SeoTokenPartition::split( $translated_fields, $context['seo_tokens'] ?? array() )` runs before
  the apply; **only** the ACF partition reaches `applyAutomatic()` / `apply()`
- when the target already existed, capture `$existing_seo = $provider->read( $target_id )`
  alongside `$existing_acf`
- `restoreExistingTarget()` gains a nullable `?array $seo` parameter and restores it via
  `$provider->write()` on the failure path
- the SEO partition is written only after the ACF apply returns `true`

### Frontend

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

`<details>` + `<summary>` carrying `data-disclosure-key`, `aria-controls`, `aria-expanded` —
mirror `admin/src/pages/fields-translate.js` ≈ line 420.

- rendered after the Content row, full modal width, `open` defaults to `false`
- summary text: `Custom Fields (12)` / `SEO (7)`
- body reuses the `ipz-st-edit-grid__original` / `ipz-st-edit-grid__translation` grid
- **a group with zero fields is not rendered at all** — no header, no empty state
- ACF body = existing `renderAcfOriginalFields()` / `renderAcfTranslationFields()` output moved
  inside the disclosure, not rewritten
- SEO inputs: `<input type="text">` for `ipz_seo_title`, `ipz_seo_og_title`, `ipz_seo_tw_title`,
  `ipz_seo_focuskw`; `<textarea rows="2">` for `ipz_seo_metadesc`, `ipz_seo_og_desc`,
  `ipz_seo_tw_desc`. Each carries `data-seo-key="<token>"`, `dir` from the language's
  `text_direction`, and `readonly` while the language is `processing`
- save collects `this.modal.modalEl.querySelectorAll( '[data-seo-key]' )` into `seo_fields`
- open state persists on the page instance as `this.editModalDisclosureOpen = { acf: false, seo: false }`
  and is fed back as `open` — the modal fully re-renders on tab switch, save, and job poll
- all styling in `_translations.scss`; **no inline CSS** (monorepo rule)

## Behavior — named edge cases

- Yoast inactive → `seo_fields` is `array()` everywhere; no accordion, `seo_fields` param rejected
  as unknown tokens, nothing added to the translate payload
- Post has no ACF fields → Custom Fields accordion absent, SEO accordion still shown if Yoast is on
- User clears a translated meta description → empty string is submitted and **clears** the meta
- Language tab switched while an accordion is open → it stays open
- Backend returns a key set that disagrees with `seo_tokens` → job fails with an explicit message;
  nothing is written to either channel
- ACF apply fails on a pre-existing target → post, ACF **and** SEO meta all restored

## Out of scope

- Do **not** fire, revive, or delete `ipz_after_post_translation`, and do not touch
  `YoastSEOIntegration::sync_seo_meta` or `ElementorIntegration` — firing that action would switch
  on never-exercised Elementor code on every site
- Do **not** implement RankMath (the provider seam anticipates it; only Yoast is built)
- Do **not** change the backend, add a REST endpoint, or alter the DB schema
- Do **not** add meta-description length handling or a copy-mode for the focus keyword
- Do **not** reformat unrelated code in the touched files

## Acceptance

From the plugin root in the worktree:

- `./tests/run-unit-tests.sh tests/unit` → exit 0. **This is the repo's real PHP test gate**
  (see `tools/factory-gate.sh`). Standalone tests declare their own WordPress stubs, so they are
  discovered per-file here and are deliberately **not** registered in `phpunit.xml.dist` — do not
  add them there.
- `php tools/phpcs-baseline.php check <changed files>` → exit 0. **This is the repo's real PHPCS
  gate.** Do *not* use `composer phpcs` (`phpcs --standard=WordPress includes/`): it bypasses
  `phpcs.xml.dist`, so it flags every PSR-4 class file in the repo and fails on untouched files —
  verified against `includes/Compatibility/YoastSEOIntegration.php`.
- `composer phpstan` → no new errors
- `cd admin && npm test` → PASS, including `content-translate-accordions.test.js`
- `cd admin && npm run build` → succeeds; built assets committed (the repo tracks `admin/dist`)

New tests must assert at minimum:
- `SeoTokenPartition::split()` separates a mixed map correctly and throws on prefix/list disagreement
- `YoastSeoFieldsProvider` returns `array()` from `tokens()`/`read()` when `WPSEO_VERSION` is undefined
- the content detail reads source SEO **once** per request, not once per language
- `saveContentTranslation` rejects a non-array `seo_fields` and an unknown token with 400
- the accordions render collapsed, are omitted when empty, survive a language-tab switch, and
  `[data-seo-key]` inputs are collected into `seo_fields` on save

**Gates are remote-only.** Run E2E via `/ipz-e2e` on registry-selected debian1/2/3. Never gate
locally.

## Delivery

Executed by sonnet subagents working directly (no nested delegation), in three waves:

1. `SeoFieldsProvider`, `YoastSeoFieldsProvider`, `SeoTokenPartition` + their unit tests — no deps
2. `TranslationsController`, `TranslateController`, `TranslationFinalizer` — consumes wave 1
3. `content-translate.js`, `_translations.scss`, vitest — consumes wave 2's payload shape

Waves 2 and 3 must not start before the contract they consume exists. The orchestrating session
reviews every diff before landing.
