# IPZ Advanced Settings Port — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Bring `translate-press-zone`'s entire remaining settings surface — translation tone, auto-publish, debug mode, and exact/contains exceptions with bulk import — into `international-press-zone`, wired end to end from PHP model through REST to admin UI and backend sync.

**Architecture:** IPZ already owns the PHP model (`Translation\Settings`, option prefix `presszone_international_`). This is a wiring job: add sync-state to the model, a new `Translation\ExceptionSync` transport, a new `API\ExceptionsController`, delegate three `API\SettingsController` keys to the canonical model, add one auto-publish rule to `TranslationFinalizer`, and surface all of it in the vanilla-JS admin SPA. Every task owns a strictly disjoint file set so same-wave tasks never collide.

**Tech Stack:** PHP 8 (WordPress plugin, namespace `InternationalPressZone`, text domain `international-press-zone`), WP REST API (`international-press-zone/v1`), vanilla-JS admin SPA (`el()` builder, `Tabs`, `Toggle`, `Select`, `API`, `Toast`, `__()`), SCSS, Playwright journeys.

**Spec:** `plugins/international-press-zone/docs/specs/2026-08-04-ipz-advanced-settings-design.md` — read §2 (Decisions) and §3 (Seams) before starting any task.

**Hard constraint:** `translate-press-zone/` and `multilingual-press-zone/` are **read-only reference trees**. Never edit a file under either.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1, Task 2, Task 3, Task 4 | `plugins/international-press-zone/includes/Translation/Settings.php` · `plugins/international-press-zone/includes/Migrations/Migration20260804MigrateDebugMode.php` · `plugins/international-press-zone/includes/Translation/ExceptionSync.php` + `plugins/international-press-zone/includes/Translation/BulkActions.php` · `plugins/international-press-zone/admin/src/styles/pages/_exceptions.scss` + `plugins/international-press-zone/admin/src/styles/main.scss` | ✅ no overlap |
| 2 | Task 5, Task 6, Task 7 | `plugins/international-press-zone/includes/API/ExceptionsController.php` + `plugins/international-press-zone/includes/Core/Plugin.php` · `plugins/international-press-zone/includes/API/SettingsController.php` · `plugins/international-press-zone/includes/Translation/TranslationFinalizer.php` | ✅ no overlap |
| 3 | Task 8, Task 9 | `plugins/international-press-zone/admin/src/components/ExceptionsTab.js` · `plugins/international-press-zone/admin/src/pages/settings.js` | ✅ no overlap |
| 4 | Task 10, Task 15, Task 16 | `plugins/international-press-zone/admin/dist/*` (build output) · `plugins/international-press-zone/tests/integration/AutoPublishStatusTest.php` · `plugins/international-press-zone/tests/e2e/run-journey.sh` | ✅ no overlap |
| 5 | Task 11, Task 12, Task 13, Task 14 | `plugins/international-press-zone/tests/e2e/journeys/UJ-022-*` · `UJ-023-*` · `UJ-024-*` · `UJ-025-*` (spec + manifest each) | ✅ no overlap |

**Dependency rationale:**
- Wave 2 consumes Wave 1's `Settings` additions (Task 1) and `ExceptionSync` (Task 3).
- Wave 3 consumes Wave 2's REST surface (Tasks 5, 6). Each wave-3 task carries its consumed contract **inline** so its implementer never needs to read another task's entry.
- Wave 4's build (Task 10) must precede the browser journeys, which run against built admin assets. Task 15 (PHP-only) and Task 16 (the E2E runner script) need no build, so they ride in wave 4.
- Wave 5's four journeys are file-disjoint and fully independent.

**Shared-file guard for wave 5:** `plugins/international-press-zone/tests/e2e/journeys/journey-support.js` and `plugins/international-press-zone/tests/e2e/run-journey.sh` are shared by all journeys. `journey-support.js` is **owned by no task in this plan** — no wave-5 task may modify it; a journey needing a helper that does not already exist must define it locally inside its own spec file. `run-journey.sh` is owned solely by Task 16; wave-5 tasks invoke it and never edit it.

**How E2E actually runs here — do not re-derive this (Task 16 encodes it):** the dev WordPress is a podman container bound to the laptop's `0.0.0.0:8080`. Local headless browsers exit 97 by design, so journeys must run through `~/.claude/bin/e2e-remote` on debian2 — but debian2 **cannot** reach the laptop over LAN (verified: `curl http://192.168.1.100:8080` from debian2 returns `000`). An SSH **reverse** tunnel bridges it (verified: HTTP `200` from debian2 through `ssh -R 18080:127.0.0.1:8080`). `127.0.0.1` also satisfies `assertSafeE2ETarget`'s loopback check, so no host allowlist env is needed.

---

## File Structure

**Create:**
- `plugins/international-press-zone/includes/Translation/ExceptionSync.php` — backend push/pull transport for the exceptions list
- `plugins/international-press-zone/includes/API/ExceptionsController.php` — REST surface for exceptions CRUD, import and sync
- `plugins/international-press-zone/includes/Migrations/Migration20260804MigrateDebugMode.php` — one-shot debug-option consolidation
- `plugins/international-press-zone/admin/src/components/ExceptionsTab.js` — Exceptions settings tab component
- `plugins/international-press-zone/admin/src/styles/pages/_exceptions.scss` — Exceptions tab styles
- `plugins/international-press-zone/tests/integration/AutoPublishStatusTest.php` — auto-publish status assertions
- `plugins/international-press-zone/tests/e2e/journeys/UJ-022…UJ-025` spec + manifest pairs

**Modify:**
- `plugins/international-press-zone/includes/Translation/Settings.php` — sync-timestamp accessors; dirty-on-mutation
- `plugins/international-press-zone/includes/Translation/BulkActions.php` — remove the dead `exceptions` request key
- `plugins/international-press-zone/includes/API/SettingsController.php` — delegate `tone`/`auto_publish`/`debug_mode` to `Translation\Settings`
- `plugins/international-press-zone/includes/Translation/TranslationFinalizer.php` — one auto-publish rule
- `plugins/international-press-zone/includes/Core/Plugin.php` — register the new controller
- `plugins/international-press-zone/admin/src/pages/settings.js` — tone + auto-publish controls; register Exceptions tab
- `plugins/international-press-zone/admin/src/styles/main.scss` — one `@use` line

---

## Task 1: Settings model — exceptions sync state

**Wave:** 1
**Blocks:** Task 5, Task 6, Task 7
**Blocked by:** —

**Files:**
- Modify: `plugins/international-press-zone/includes/Translation/Settings.php` — add sync-timestamp accessors; mark the list dirty on every local mutation

**Contract (pin EXACTLY):**
```php
public function get_exceptions_synced_at(): ?string   // option presszone_international_exceptions_synced_at; ISO-8601 string or null
public function set_exceptions_synced_at(): void      // writes gmdate('c')
public function clear_exceptions_synced_at(): void    // deletes the option — list is now dirty
```
- Option name: `presszone_international_exceptions_synced_at` (built from the existing `self::PREFIX` constant, line 28 — do not hard-code the prefix).
- `get_all()` (line 301) gains exactly one key: `'exceptions_synced_at' => $this->get_exceptions_synced_at()`.

**Behavior:**
- `get_exceptions_synced_at()` returns `null` when the option is absent or an empty string — never `false`, never `''`.
- `add_exception()` (line 252), `remove_exception()` (line 272) and `set_exceptions()` (line 287) each call `clear_exceptions_synced_at()` **after** a successful write. `add_exception()` returning `false` on a duplicate must NOT clear the timestamp — nothing changed.
- Sync state is derived, never separately stored: `synced_at === null` ⇒ dirty. Do not add a boolean flag.
- Existing method signatures and option names are unchanged. No other behavior in this file changes.

**Acceptance (one executable check):**
- Run: `podman exec devzone-wordpress php -r 'require "/var/www/html/wp-load.php"; $s = \InternationalPressZone\Translation\Settings::instance(); $s->set_exceptions([]); $s->set_exceptions_synced_at(); echo (is_string($s->get_exceptions_synced_at()) ? "SET_OK " : "SET_FAIL "); $s->add_exception("Acme Corp","exact"); echo (($s->get_exceptions_synced_at() === null) ? "DIRTY_OK" : "DIRTY_FAIL"), PHP_EOL;'`
- Expected: `SET_OK DIRTY_OK`

- [ ] Add the three accessors and the `get_all()` key
- [ ] Wire `clear_exceptions_synced_at()` into the three mutators
- [ ] Run the acceptance command, confirm `SET_OK DIRTY_OK`
- [ ] Commit: `git add plugins/international-press-zone/includes/Translation/Settings.php && git commit -m "feat: track exceptions sync state in settings model"`

---

## Task 2: Debug-mode consolidation migration

**Wave:** 1
**Blocks:** Task 6
**Blocked by:** —

**Files:**
- Create: `plugins/international-press-zone/includes/Migrations/Migration20260804MigrateDebugMode.php` — migrate the dead `ipz_debug_mode` option into the canonical one

**Contract (pin EXACTLY):**
```php
namespace InternationalPressZone\Migrations;
use InternationalPressZone\Core\AbstractMigration;

class Migration20260804MigrateDebugMode extends AbstractMigration {
    protected string $version = '20260804000000';
    protected string $description = 'Consolidate debug mode into presszone_international_debug_mode';
    public function up(): bool;
    public function down(): bool;
}
```
- Follow `plugins/international-press-zone/includes/Migrations/Migration007RemoveLegacyApiCredentials.php` verbatim for file header, `declare(strict_types=1)`, the `ABSPATH` guard, and the `mark_as_applied()` / `mark_as_rolled_back()` calls.
- Discovery is automatic: `Core\Migrations` globs `plugins/international-press-zone/includes/Migrations/Migration*.php`. Do not register it anywhere.

**Behavior:**
- `up()`: if `get_option('presszone_international_debug_mode')` is `false` (absent) **and** `get_option('ipz_debug_mode')` is not `false`, copy the value across with `update_option()`. Then `delete_option('ipz_debug_mode')` unconditionally. Call `mark_as_applied()`, return `true`.
- `down()`: write the canonical option's current value back to `ipz_debug_mode`. Call `mark_as_rolled_back()`, return `true`.
- **Do not touch `ipz_log_queries`** — separate concern, explicitly out of scope.
- Idempotent: running `up()` twice must not error and must not resurrect the deleted option.
- No new hook is added. Migrations already run from the activation hook (`plugins/international-press-zone/international-press-zone.php:163`) and from `Core\Plugin::checkUpgrades()` on `admin_init` (`plugins/international-press-zone/includes/Core/Plugin.php:221`).

**Acceptance (one executable check):**
- Run: `podman exec devzone-wordpress php -r 'require "/var/www/html/wp-load.php"; delete_option("presszone_international_debug_mode"); update_option("ipz_debug_mode", true); $m = new \InternationalPressZone\Migrations\Migration20260804MigrateDebugMode(); $m->up(); echo ((get_option("presszone_international_debug_mode") == true && get_option("ipz_debug_mode") === false) ? "PASS" : "FAIL"), PHP_EOL;'`
- Expected: `PASS`

- [ ] Write the migration following the Migration007 pattern
- [ ] Run the acceptance command, confirm `PASS`
- [ ] Commit: `git add plugins/international-press-zone/includes/Migrations/Migration20260804MigrateDebugMode.php && git commit -m "feat: migrate legacy debug mode option"`

---

## Task 3: ExceptionSync transport + dead request key removal

**Wave:** 1
**Blocks:** Task 5
**Blocked by:** —

**Files:**
- Create: `plugins/international-press-zone/includes/Translation/ExceptionSync.php` — push/pull the exceptions list against the backend
- Modify: `plugins/international-press-zone/includes/Translation/BulkActions.php:445-447` — delete the dead `$body['exceptions']` block

**Contract (pin EXACTLY):**
```php
namespace InternationalPressZone\Translation;

class ExceptionSync {
    public function __construct(?Settings $settings = null);   // defaults to Settings::instance()
    public function push(): array;   // ['success' => bool, 'count' => int, 'message' => string]
    public function pull(): array;   // ['success' => bool, 'count' => int, 'message' => string]
}
```

Outbound requests (base URL from `Settings::get_api_url()`):

| Method | Path | Body |
|--------|------|------|
| POST | `/v1/exceptions/sync` | `{"exceptions":[{"text":"…","match_type":"exact"|"contains"}]}` |
| GET | `/v1/exceptions?limit=100` | — |

Headers on both, matching the existing convention at `plugins/international-press-zone/includes/Translation/BulkActions.php:449-455`:
```
Content-Type:     application/json
Authorization:    Bearer <license_key>
X-Plugin-Version: IPZ_VERSION
X-Site-URL:       home_url()
X-Plugin:         international
```

**Behavior:**
- `push()` replaces the whole server-side list with the local one, then calls `Settings::set_exceptions_synced_at()` on any 2xx.
- `pull()` reads the server list, writes it locally via `Settings::set_exceptions()`, then calls `Settings::set_exceptions_synced_at()`. Note `set_exceptions()` clears the timestamp (Task 1) — so `pull()` must set it **after** the local write, not before.
- Backend constraints, verified in `press-zone-backend/api/src/routes/exceptions.ts` — enforce client-side before sending: `text` 1–500 chars; `match_type` is `exact` or `contains` **only** (never `regex`); `/v1/exceptions/sync` accepts at most 5000 items — cap the payload and report the cap in `message` rather than sending an over-long body.
- Failure semantics: a missing license key, a transport `WP_Error`, or a non-2xx status returns `['success' => false, 'count' => 0, 'message' => <human-readable reason>]` and **leaves the local list untouched**. Never throw.
- Timeout: 30 seconds, matching the plugin's other outbound calls.
- `BulkActions.php`: delete the three-line `if (!empty($exceptions)) { $body['exceptions'] = $exceptions; }` block and the now-unused `$exceptions` assignment above it. The backend's zod schema has no `exceptions` field, so this key is stripped on every request today. Nothing else in that file changes. Do not remove the `tone` key — that one is real.

**Acceptance (one executable check):**
- Run: `podman exec devzone-wordpress php -r 'require "/var/www/html/wp-load.php"; $r = (new \InternationalPressZone\Translation\ExceptionSync())->push(); echo (is_array($r) && array_key_exists("success",$r) && array_key_exists("count",$r) && array_key_exists("message",$r) && is_bool($r["success"]) ? "SHAPE_OK" : "SHAPE_FAIL"), PHP_EOL;' && ! grep -q "body\['exceptions'\]" plugins/international-press-zone/includes/Translation/BulkActions.php && echo DEADKEY_GONE`
- Expected: `SHAPE_OK` followed by `DEADKEY_GONE` (the push itself may legitimately report `success:false` in dev — the contract under test is the return shape and that it never throws)

- [ ] Write `ExceptionSync` with `push()` / `pull()`
- [ ] Remove the dead `exceptions` key from `BulkActions.php`
- [ ] Run the acceptance command, confirm both markers
- [ ] Commit: `git add plugins/international-press-zone/includes/Translation/ExceptionSync.php plugins/international-press-zone/includes/Translation/BulkActions.php && git commit -m "feat: sync translation exceptions with backend"`

---

## Task 4: Exceptions tab styles

**Wave:** 1
**Blocks:** Task 8, Task 10
**Blocked by:** —

**Files:**
- Create: `plugins/international-press-zone/admin/src/styles/pages/_exceptions.scss` — styles for the Exceptions settings tab
- Modify: `plugins/international-press-zone/admin/src/styles/main.scss:49-56` — add one `@use 'pages/exceptions';` line to the existing page block

**Contract (pin EXACTLY — these class names are consumed verbatim by Task 8):**
```
.ipz-exceptions
.ipz-exceptions__add-form
.ipz-exceptions__table
.ipz-exceptions__row
.ipz-exceptions__import
.ipz-exceptions__sync
.ipz-exceptions__sync-dot
.ipz-exceptions__sync-dot--synced
.ipz-exceptions__sync-dot--unsynced
.ipz-exceptions__sync-dot--pending
.ipz-exceptions__empty
```

**Behavior:**
- Follow the structure and variable usage of the sibling `plugins/international-press-zone/admin/src/styles/pages/_settings.scss` — same import style, same design tokens/CSS variables. Read it first; do not invent a new convention.
- The three `--synced` / `--unsynced` / `--pending` modifiers differ only in the dot colour: synced = success, unsynced = warning, pending = neutral/animated. Use the existing token variables, not literal hex values.
- Dark mode: the tab must follow `body.dark-mode` the same way `_settings.scss` does.
- **Absolute ban on inline CSS** (monorepo rule) — every style lives in this file.
- `main.scss` gains exactly one line and nothing else.

**Acceptance (one executable check):**
- Run: `cd plugins/international-press-zone/admin && npm run build 2>&1 | tail -20 && grep -c "ipz-exceptions" dist/css/main.css`
- Expected: build completes with no warnings or errors, and the grep count is greater than 0

- [ ] Read `_settings.scss` for the established conventions
- [ ] Write `_exceptions.scss` with the pinned class names
- [ ] Add the `@use` line to `main.scss`
- [ ] Run the acceptance command, confirm a clean build and a non-zero grep count
- [ ] Commit: `git add plugins/international-press-zone/admin/src/styles/pages/_exceptions.scss plugins/international-press-zone/admin/src/styles/main.scss && git commit -m "feat: add exceptions tab styles"`

---

## Task 5: Exceptions REST controller

**Wave:** 2
**Blocks:** Task 8, Task 13, Task 14
**Blocked by:** Task 1, Task 3

**Files:**
- Create: `plugins/international-press-zone/includes/API/ExceptionsController.php` — REST surface for exceptions
- Modify: `plugins/international-press-zone/includes/Core/Plugin.php:650` — instantiate and register the controller beside the existing `SettingsController`

**Contract (pin EXACTLY):**

Namespace `international-press-zone/v1`, base `exceptions`. Every route's `permission_callback` returns `current_user_can('manage_options')`. Every response uses the plugin's existing envelope `{success, data, message}` — copy the shape from `plugins/international-press-zone/includes/API/SettingsController.php`.

| Method | Route | Params | `data` on success |
|--------|-------|--------|-------------------|
| GET | `/exceptions` | — | `{items: [{text, match_type}], synced_at: string\|null}` |
| POST | `/exceptions` | `text` (string, required), `match_type` (enum `exact`\|`contains`, default `exact`) | `{items: [...], synced_at: null}` |
| DELETE | `/exceptions` | `text` (string, required, **body param**) | `{items: [...], synced_at: null}` |
| POST | `/exceptions/import` | `text` (string, required) | `{added: int, skipped: int, items: [...]}` |
| POST | `/exceptions/sync` | — | `ExceptionSync::push()` result verbatim |
| POST | `/exceptions/pull` | — | `ExceptionSync::pull()` result verbatim |

**Behavior:**
- All reads and writes go through `Translation\Settings::instance()` — `get_exceptions()`, `add_exception()`, `remove_exception()`, `set_exceptions()`, `get_exceptions_synced_at()`. This controller never touches `get_option()` directly.
- POST duplicate: `add_exception()` returns `false` on a duplicate text. Respond `{success: false, message: <"already exists" text>}` with HTTP status `409`.
- `text` is sanitised with `sanitize_text_field(wp_unslash(...))` and rejected when empty after trimming or longer than 500 characters (backend limit).
- `match_type` is validated against `['exact','contains']` via the route `args` `enum`. **`regex` is not offered and must be rejected** — the backend zod enum refuses it.
- DELETE takes `text` as a **body/request param, never a path segment**. TPZ's `/(?P<text>.+)` route mishandles slashes and encoded characters; do not reproduce it.
- `/exceptions/import`: split the payload on newlines, `trim()` each line, skip blanks, skip lines already present (case-sensitive exact match on `text`), add the rest with `match_type: 'exact'`. Return the true `added` and `skipped` counts. A line over 500 characters counts as `skipped`. Cap at 1000 new entries per request; anything beyond counts as `skipped`.
- The import is a **local write only** — never fire a network call inside the import loop. A 500-line import must not half-apply because the network failed.
- `Plugin.php`: one instantiation plus its `registerRoutes()` wiring, mirroring exactly how `SettingsController` is handled at line 650. Nothing else in that file changes.

**Acceptance (one executable check):**
- Run: `podman exec devzone-wordpress php -r 'require "/var/www/html/wp-load.php"; do_action("rest_api_init"); $routes = rest_get_server()->get_routes(); foreach (["/international-press-zone/v1/exceptions","/international-press-zone/v1/exceptions/import","/international-press-zone/v1/exceptions/sync","/international-press-zone/v1/exceptions/pull"] as $r) { echo $r, isset($routes[$r]) ? " OK" : " MISSING", PHP_EOL; }'`
- Expected: all four routes print `OK`

- [ ] Write `ExceptionsController` with the six routes
- [ ] Register it in `Plugin.php` beside `SettingsController`
- [ ] Run the acceptance command, confirm four `OK` lines
- [ ] Commit: `git add plugins/international-press-zone/includes/API/ExceptionsController.php plugins/international-press-zone/includes/Core/Plugin.php && git commit -m "feat: add exceptions REST controller"`

---

## Task 6: Settings REST delegation

**Wave:** 2
**Blocks:** Task 9, Task 11, Task 12
**Blocked by:** Task 1, Task 2

**Files:**
- Modify: `plugins/international-press-zone/includes/API/SettingsController.php` — delegate three keys to `Translation\Settings`; fix reset

**Contract (pin EXACTLY):**

GET `/settings` `data` is **flat** and gains exactly three keys (`tone` already has no entry; `auto_publish` is new; `debug_mode` exists but changes source):
```json
{ "tone": "neutral|formal|casual", "auto_publish": false, "debug_mode": false }
```
PUT `/settings` accepts the same three keys. `args` schema entries follow the existing style in `getSettingsSchema()` (line 469):
```php
'tone'         => ['type' => 'string',  'enum' => ['neutral', 'formal', 'casual'], 'required' => false],
'auto_publish' => ['type' => 'boolean', 'required' => false],
'debug_mode'   => ['type' => 'boolean', 'required' => false],   // entry already exists at line 550 — leave as is
```

**Behavior:**
- `getSettings()` (line 135): `debug_mode` now reads `Translation\Settings::instance()->is_debug_mode()` instead of `get_option('ipz_debug_mode', false)` (line 159). Add `tone` from `get_tone()` and `auto_publish` from `get_auto_publish()`.
- `updateSettings()` (line 187): replace `update_option('ipz_debug_mode', …)` (line 271) with `Translation\Settings::instance()->set_debug_mode((bool) …)`. Add the same delegation for `tone` → `set_tone()` and `auto_publish` → `set_auto_publish()`. `set_tone()` already validates against `neutral|formal|casual` and returns `false` on an invalid value — an invalid tone must not be counted in `updated_fields`.
- The existing PUT response shape `{success, data: {updated_fields, count}}` is **unchanged** — an existing journey (`UJ-006`) asserts it exactly. Do not alter it.
- `resetSettings()` (line 358): remove `'ipz_debug_mode' => false` (line 384). Reset the canonical values instead — `set_tone('formal')`, `set_auto_publish(false)`, `set_debug_mode(false)`.
- **`ipz_log_queries` is untouched everywhere in this file** — separate concern, explicitly out of scope.
- The wire shape stays flat in both directions. Do not introduce nesting.

**Acceptance (one executable check):**
- Run: `podman exec devzone-wordpress php -r 'require "/var/www/html/wp-load.php"; wp_set_current_user(1); \InternationalPressZone\Translation\Settings::instance()->set_tone("casual"); $req = new WP_REST_Request("GET","/international-press-zone/v1/settings"); do_action("rest_api_init"); $d = rest_do_request($req)->get_data()["data"]; echo (($d["tone"] ?? "") === "casual" && array_key_exists("auto_publish",$d) && array_key_exists("debug_mode",$d) ? "PASS" : "FAIL"), PHP_EOL;'`
- Expected: `PASS`

- [ ] Delegate `tone`, `auto_publish`, `debug_mode` in `getSettings()` and `updateSettings()`
- [ ] Add the two new `args` schema entries
- [ ] Fix `resetSettings()` to reset canonical values
- [ ] Run the acceptance command, confirm `PASS`
- [ ] Commit: `git add plugins/international-press-zone/includes/API/SettingsController.php && git commit -m "feat: delegate tone, auto-publish and debug settings to canonical model"`

---

## Task 7: Auto-publish wiring

**Wave:** 2
**Blocks:** Task 15
**Blocked by:** Task 1

**Files:**
- Modify: `plugins/international-press-zone/includes/Translation/TranslationFinalizer.php:229-245` (`authorizedTargetStatus()`)

**Literal — apply inline (LOC≤LOP), no dispatch:**

Replace the first statement of `authorizedTargetStatus()`:
```php
$requested = sanitize_key((string) ($context['desired_target_status'] ?? 'draft'));
```
with:
```php
$explicit = sanitize_key((string) ($context['desired_target_status'] ?? ''));
$requested = $explicit !== ''
    ? $explicit
    : (Settings::instance()->get_auto_publish() ? 'publish' : 'draft');
```
Add `use InternationalPressZone\Translation\Settings;` only if the file is not already in that namespace (it is — so no import is needed; call `Settings::instance()` directly).

**Behavior:** an explicit caller-supplied status always wins. With no explicit status, auto-publish on ⇒ `publish`, off ⇒ `draft` (unchanged from today). The existing `publish_posts` capability gate below is untouched and still degrades an unauthorised `publish` to `draft` — never a silent privilege escalation. Nothing else in the file changes.

- [ ] Apply the edit
- [ ] Run: `podman exec devzone-wordpress php -l /var/www/html/wp-content/plugins/international-press-zone/includes/Translation/TranslationFinalizer.php` — expect `No syntax errors detected`
- [ ] Commit: `git add plugins/international-press-zone/includes/Translation/TranslationFinalizer.php && git commit -m "feat: honour auto-publish setting when finalizing translations"`

---

## Task 8: Exceptions tab component

**Wave:** 3
**Blocks:** Task 10
**Blocked by:** Task 4, Task 5

**Files:**
- Create: `plugins/international-press-zone/admin/src/components/ExceptionsTab.js` — the Exceptions settings tab

**Contract (pin EXACTLY):**
```js
export function ExceptionsTab(): HTMLElement   // returns a detached root element; loads its own data on mount
```

REST surface it consumes — namespace `international-press-zone/v1`, all through the existing `API` helper (`API.get`, `API.post`, `API.del` or the project's equivalent — read `plugins/international-press-zone/admin/src/utils/api.js` first and use what exists). Every response is `{success, data, message}`:

| Call | Path | Body | `data` on success |
|------|------|------|-------------------|
| GET | `/exceptions` | — | `{items: [{text, match_type}], synced_at: string\|null}` |
| POST | `/exceptions` | `{text, match_type}` | `{items, synced_at}` — HTTP `409` when the text already exists |
| DELETE | `/exceptions` | `{text}` — **body param, not a path segment** | `{items, synced_at}` |
| POST | `/exceptions/import` | `{text}` — newline-separated | `{added, skipped, items}` |
| POST | `/exceptions/sync` | — | `{success, count, message}` |
| POST | `/exceptions/pull` | — | `{success, count, message}` |

CSS classes (already authored by Task 4 — use verbatim, add none, and **never write inline styles**):
```
ipz-exceptions · ipz-exceptions__add-form · ipz-exceptions__table · ipz-exceptions__row
ipz-exceptions__import · ipz-exceptions__sync · ipz-exceptions__empty
ipz-exceptions__sync-dot  +  --synced | --unsynced | --pending
```

**Behavior:**
- Follow the existing vanilla-JS component idiom — read `plugins/international-press-zone/admin/src/components/BulkActionBar.js` and `plugins/international-press-zone/admin/src/components/Table.js` first. Use the `el()` builder, `Toast` for feedback, and wrap every user-facing string in `__('…', 'international-press-zone')`.
- **Add form:** text input + match-type `<select>` offering exactly `exact` and `contains` (**never `regex`** — the backend rejects it) + Add button. On a `409` show a Toast reading "This exception already exists" and leave the input populated.
- **Table:** one row per exception showing text and match type, each with a Delete button. Empty list renders the `ipz-exceptions__empty` state, not an empty table.
- **Bulk import:** textarea + Import button, helper text `Enter one exception per line.`. On success show a Toast with the returned `added` and `skipped` counts.
- **Sync row:** renders the last-synced timestamp or `Never synced`, a state dot, and two buttons — "Sync to server" and "Pull from server". Dot modifier: `--synced` when `synced_at` is non-null, `--unsynced` when null, `--pending` while a sync request is in flight.
- Every mutation refreshes the rendered list and the sync dot from the response — do not mutate local state optimistically and assume success.
- Buttons are disabled while their request is in flight. Every failed call surfaces the response `message` through a Toast; nothing fails silently.
- Accessibility: the delete button is icon-or-text but must carry an `aria-label` naming the exception it removes.

**Acceptance (one executable check):**
- Run: `npx eslint --config .dev-config/eslint.config.mjs --no-warn-ignored --max-warnings 0 plugins/international-press-zone/admin/src/components/ExceptionsTab.js` (from repo root)
- Expected: exit 0, no errors and no warnings

- [ ] Read `BulkActionBar.js`, `Table.js` and `utils/api.js` for the established idiom
- [ ] Write `ExceptionsTab.js` against the pinned route table
- [ ] Run the acceptance command, confirm a clean lint
- [ ] Commit: `git add plugins/international-press-zone/admin/src/components/ExceptionsTab.js && git commit -m "feat: add exceptions settings tab component"`

---

## Task 9: Settings page controls

**Wave:** 3
**Blocks:** Task 10
**Blocked by:** Task 6

**Files:**
- Modify: `plugins/international-press-zone/admin/src/pages/settings.js` — tone + auto-publish controls in General; register the Exceptions tab

**Contract (pin EXACTLY):**

GET `/settings` returns a **flat** `data` object that now carries these three keys; PUT `/settings` accepts the same three:
```json
{ "tone": "neutral|formal|casual", "auto_publish": false, "debug_mode": false }
```

Tab registration extends the existing list at line 129-135:
```js
{ id: 'exceptions', label: __('Exceptions', 'international-press-zone'), icon: '🚫' }
```

**Behavior:**
- `renderGeneralSettings()` (line 200) gains two controls, built with the same `Select` / `Toggle` helpers already used in that method:
  - `tone` — `Select` labelled "Translation Tone", options Neutral / Formal / Casual (values `neutral`, `formal`, `casual`), default `formal`, description "Tone used when translating content".
  - `auto_publish` — `Toggle` labelled "Auto-publish Translations", description "Publish translations immediately instead of saving them as drafts". This is **not** the same setting as the existing `auto_translate` toggle directly above it — `auto_translate` decides *whether* to translate, `auto_publish` decides the *resulting post status*. Do not merge or rename either.
- `saveSettings()` (line 521) payload gains `tone` and `auto_publish`. `debug_mode` is already in the payload — leave it.
- `loadSettings()` (line 488): the response is flat; the three keys land in `this.state` through the existing `Object.assign`. **Do not touch the legacy nested branch (lines 494-508) and do not introduce nesting** — pre-existing dead handling, out of scope.
- The tab switch (line 171-180) gains an `exceptions` case rendering `ExceptionsTab()` imported from `../components/ExceptionsTab.js`.
- Do not modify `renderAdvancedSettings()`. The inert `log_level` select there is pre-existing and out of scope — leave it exactly as it is.

**Acceptance (one executable check):**
- Run: `npx eslint --config .dev-config/eslint.config.mjs --no-warn-ignored --max-warnings 0 plugins/international-press-zone/admin/src/pages/settings.js && grep -c "auto_publish" plugins/international-press-zone/admin/src/pages/settings.js` (from repo root)
- Expected: lint exits 0 with no warnings, and the grep count is at least 2 (the control and the save payload)

- [ ] Add the tone `Select` and auto-publish `Toggle` to `renderGeneralSettings()`
- [ ] Extend the `saveSettings()` payload with `tone` and `auto_publish`
- [ ] Register and render the Exceptions tab
- [ ] Run the acceptance command, confirm a clean lint and the grep count
- [ ] Commit: `git add plugins/international-press-zone/admin/src/pages/settings.js && git commit -m "feat: surface tone, auto-publish and exceptions in settings UI"`

---

## Task 10: Admin build

**Wave:** 4
**Blocks:** Task 11, Task 12, Task 13, Task 14
**Blocked by:** Task 4, Task 8, Task 9

**Files:**
- Modify: `plugins/international-press-zone/admin/dist/*` — regenerated build output (tracked in git)

**Behavior:**
- Run the project build and commit the regenerated bundle so the browser journeys in wave 5 run against the new UI.
- **The build must be clean.** Every warning is addressed — fixed, or explicitly justified as benign in the commit message. A build that "passes with noise" is not done. Do not add `.warnignore` entries for warnings originating in this plan's own code.
- Commit only the files the build actually regenerated. Do not stage unrelated `dist` churn.

**Acceptance (one executable check):**
- Run: `cd plugins/international-press-zone/admin && npm run build 2>&1 | tee /tmp/ipz-build.log; grep -Ei "warn|error" /tmp/ipz-build.log; echo "exit=$?"`
- Expected: the build succeeds and the grep finds no warning or error lines

- [ ] Run `cd plugins/international-press-zone/admin && npm run build`
- [ ] Resolve every warning; re-run until clean
- [ ] Commit: `git add plugins/international-press-zone/admin/dist && git commit -m "build: rebuild admin bundle with settings and exceptions UI"`

---

## Task 11: Journey UJ-022 — tone and auto-publish persist

**Wave:** 5
**Blocks:** —
**Blocked by:** Task 6, Task 9, Task 10

**Files:**
- Create: `plugins/international-press-zone/tests/e2e/journeys/UJ-022-set-translation-tone.spec.js`
- Create: `plugins/international-press-zone/tests/e2e/journeys/UJ-022-set-translation-tone.spec.manifest.json`

**Contract:**
- Follow `plugins/international-press-zone/tests/e2e/journeys/UJ-006-save-settings.spec.js` for structure, and its `.spec.manifest.json` for the manifest schema (`version: "journey-e2e/v1"`, `journey`, `spec`, `expectedBranches`, `executionClasses`, `projects`, `fixtures`, `siblingCoverage`).
- Import helpers from `./journey-support` — `openAdmin`, `getNonce`, `json`, `request`, `captureResponse`, `restoreSettings`, `ownedState`, `runtimeFailures`, `assertNoRuntimeFailures`, `API_ROOT`.
- **`journey-support.js` is owned by no task and must not be modified.** A helper you need that does not exist gets defined locally in this spec file.

**Behavior — branches to cover:**
- `H1` happy path: open Settings → General, snapshot current settings for restore, set Translation Tone to `casual`, assert the PUT lands `200` and its payload carries `tone: "casual"`, reload the page and assert the control still reads `casual`.
- `H2`: toggle Auto-publish Translations, assert the PUT payload carries the flipped `auto_publish`, and that a follow-up `GET /settings` returns the same value — proving the REST delegation round-trips, not just the DOM.
- Restore every mutated setting through `ownedState()` so the journey leaves no residue.
- Assert no runtime console failures via `assertNoRuntimeFailures`.

**Acceptance (one executable check):**
- Run, from `plugins/international-press-zone/tests/e2e/`: `./run-journey.sh journeys/UJ-022-set-translation-tone.spec.js`
- Expected: all branches PASS
- **Use `run-journey.sh` (Task 16) — never a bare `npx playwright test`.** The bare form exits 97 on the local headless guard, and the remote form needs the reverse tunnel the runner encodes.

- [ ] Read UJ-006 spec + manifest for the established pattern
- [ ] Write the spec and its manifest
- [ ] Run the acceptance command through `run-journey.sh`, confirm PASS
- [ ] Commit: `git add plugins/international-press-zone/tests/e2e/journeys/UJ-022-* && git commit -m "test: cover translation tone and auto-publish settings"`

---

## Task 12: Journey UJ-023 — debug mode round-trip and reset

**Wave:** 5
**Blocks:** —
**Blocked by:** Task 6, Task 10

**Files:**
- Create: `plugins/international-press-zone/tests/e2e/journeys/UJ-023-debug-mode-round-trip.spec.js`
- Create: `plugins/international-press-zone/tests/e2e/journeys/UJ-023-debug-mode-round-trip.spec.manifest.json`

**Contract:** same pattern, helpers and manifest schema as Task 11. `journey-support.js` must not be modified.

**Behavior — branches to cover:**
- `H1`: PUT `/settings` with `debug_mode: true`, then GET `/settings` and assert `data.debug_mode === true`. This proves the controller now reads the canonical `presszone_international_debug_mode` rather than the removed `ipz_debug_mode` — a DOM-only assertion would pass even against the old broken wiring, so the GET round-trip is the point of this journey.
- `H2`: POST `/settings/reset` with `{confirm: true}`, assert `200`, and assert the returned payload has `debug_mode === false`, `tone === "formal"` and `auto_publish === false`.
- **Do not duplicate `UJ-006`**, which already covers the Advanced-tab Debug Mode auto-save mechanics. This journey asserts the *storage delegation and reset defaults*, not the toggle interaction.
- Restore the pre-test settings through `ownedState()`.

**Acceptance (one executable check):**
- Run, from `plugins/international-press-zone/tests/e2e/`: `./run-journey.sh journeys/UJ-023-debug-mode-round-trip.spec.js`
- Expected: all branches PASS

- [ ] Write the spec and its manifest
- [ ] Run the acceptance command through `run-journey.sh`, confirm PASS
- [ ] Commit: `git add plugins/international-press-zone/tests/e2e/journeys/UJ-023-* && git commit -m "test: cover debug mode delegation and settings reset"`

---

## Task 13: Journey UJ-024 — add and delete an exception

**Wave:** 5
**Blocks:** —
**Blocked by:** Task 5, Task 8, Task 10

**Files:**
- Create: `plugins/international-press-zone/tests/e2e/journeys/UJ-024-manage-exceptions.spec.js`
- Create: `plugins/international-press-zone/tests/e2e/journeys/UJ-024-manage-exceptions.spec.manifest.json`

**Contract:** same pattern, helpers and manifest schema as Task 11. `journey-support.js` must not be modified. Endpoints under test, all returning `{success, data, message}`:
```
GET    /exceptions            → {items: [{text, match_type}], synced_at}
POST   /exceptions  {text, match_type}   → 409 on duplicate
DELETE /exceptions  {text}    (body param, not a path segment)
```

**Behavior — branches to cover:**
- `H1`: open Settings → Exceptions, add an exception with a unique token (use `uniqueToken()` from journey-support), assert it appears in the table and that a `GET /exceptions` includes it.
- `A1`: re-add the same text and assert the request returns `409` and a Toast surfaces the duplicate message; assert the list length is unchanged.
- `H2`: delete the exception via its row button, assert it disappears from the table and from a follow-up `GET /exceptions`.
- Use a slash-containing exception text in at least one case (e.g. `Acme/Corp`) — the DELETE body-param design exists precisely so that works.
- Clean up through `ownedState()` so no test exception survives the run.

**Acceptance (one executable check):**
- Run, from `plugins/international-press-zone/tests/e2e/`: `./run-journey.sh journeys/UJ-024-manage-exceptions.spec.js`
- Expected: all branches PASS

- [ ] Write the spec and its manifest
- [ ] Run the acceptance command through `run-journey.sh`, confirm PASS
- [ ] Commit: `git add plugins/international-press-zone/tests/e2e/journeys/UJ-024-* && git commit -m "test: cover adding and deleting translation exceptions"`

---

## Task 14: Journey UJ-025 — bulk import counts

**Wave:** 5
**Blocks:** —
**Blocked by:** Task 5, Task 8, Task 10

**Files:**
- Create: `plugins/international-press-zone/tests/e2e/journeys/UJ-025-bulk-import-exceptions.spec.js`
- Create: `plugins/international-press-zone/tests/e2e/journeys/UJ-025-bulk-import-exceptions.spec.manifest.json`

**Contract:** same pattern, helpers and manifest schema as Task 11. `journey-support.js` must not be modified. Endpoint under test:
```
POST /exceptions/import  {text: "<newline-separated>"}  → data {added: int, skipped: int, items: [...]}
```

**Behavior — branches to cover:**
- `H1`: import a textarea payload of four unique lines, assert `added === 4` and `skipped === 0`, and that all four appear in the table.
- `A1`: import a payload mixing two of those same lines, one blank line and one new line; assert `added === 1` and `skipped === 2` (blank lines are skipped silently and are **not** counted in `skipped`).
- `A2`: import a single line longer than 500 characters; assert `added === 0` and `skipped === 1`, and that no over-long entry lands in the list.
- Clean up every imported exception through `ownedState()`.

**Acceptance (one executable check):**
- Run, from `plugins/international-press-zone/tests/e2e/`: `./run-journey.sh journeys/UJ-025-bulk-import-exceptions.spec.js`
- Expected: all branches PASS

- [ ] Write the spec and its manifest
- [ ] Run the acceptance command through `run-journey.sh`, confirm PASS
- [ ] Commit: `git add plugins/international-press-zone/tests/e2e/journeys/UJ-025-* && git commit -m "test: cover bulk import of translation exceptions"`

---

## Task 15: Auto-publish status integration check

**Wave:** 4
**Blocks:** —
**Blocked by:** Task 1, Task 7

**Files:**
- Create: `plugins/international-press-zone/tests/integration/AutoPublishStatusTest.php` — asserts the resulting post status across four auto-publish cases

**Contract:**
- Follow `plugins/international-press-zone/tests/integration/WorkflowIntegrationTest.php` for structure, bootstrap and assertion style. Read it first.
- Runs in the WordPress container: `podman exec devzone-wordpress php /var/www/html/wp-content/plugins/international-press-zone/tests/integration/AutoPublishStatusTest.php`.
- Exercises `Translation\TranslationFinalizer::authorizedTargetStatus()` behaviour through `finalize()` with a **stubbed translation payload** — no live backend call. The dev instance has no public URL and the run must not consume `api.press.zone` credits.

**Behavior — the four cases, asserting `post_status` read back from the database:**
| Auto-publish | Caller status | Acting user has `publish_posts` | Expected |
|---|---|---|---|
| off | none | yes | `draft` |
| on | none | yes | `publish` |
| on | `pending` | yes | `pending` (explicit caller status wins) |
| on | none | no | `draft` (capability gate degrades) |

- Each case creates its own fixture post and job row and removes them afterwards; the test leaves no residue.
- Restore the `presszone_international_auto_publish` option to its pre-test value at the end, including on failure.
- Prints one `PASS`/`FAIL` line per case and exits non-zero if any case fails.

**Acceptance (one executable check):**
- Run: `podman exec devzone-wordpress php /var/www/html/wp-content/plugins/international-press-zone/tests/integration/AutoPublishStatusTest.php; echo "exit=$?"`
- Expected: four `PASS` lines and `exit=0`

- [ ] Read `WorkflowIntegrationTest.php` for the established pattern
- [ ] Write the four-case test with fixture cleanup
- [ ] Run the acceptance command, confirm four `PASS` lines and `exit=0`
- [ ] Commit: `git add plugins/international-press-zone/tests/integration/AutoPublishStatusTest.php && git commit -m "test: assert auto-publish resolves the correct post status"`

---

## Task 16: E2E journey runner

**Wave:** 4
**Blocks:** Task 11, Task 12, Task 13, Task 14
**Blocked by:** —

**Files:**
- Create: `plugins/international-press-zone/tests/e2e/run-journey.sh` — fail-closed wrapper that runs one journey spec remotely against the local dev WordPress

**Contract:**
```
./run-journey.sh <spec-path-relative-to-tests/e2e> [extra playwright args...]
```
Exit code is the Playwright client's own exit code. Run from `plugins/international-press-zone/tests/e2e/`.

**Verified transport contract:**
- Discover the current dev URL from `devzone-wordpress` via `home_url()`; NEVER hard-code an IP.
- Parse and validate before dispatch: protocol `http`, explicit port `8080`, IPv4 host inside Tailscale CGNAT `100.64.0.0/10`. Anything else fails closed with the rejected URL; NEVER run against a public target.
- Invoke `e2e-remote --hosts debian2` with both `WP_BASE_URL=<discovered URL>` and `IPZ_E2E_ALLOWED_HOSTS=<validated host>`, then `npx playwright test "$SPEC" --project=chromium` plus forwarded extra args.

**Behavior:**
- Reason this wrapper exists: the naive `npx playwright test` is always wrong here — it exits 97 on the local headless guard. Never `export E2E_REMOTE_OK=1`; only `e2e-remote` may set it.
- **Fail closed.** Missing spec argument, spec file not found, `podman`/container/home URL discovery unavailable, unsafe URL, or `e2e-remote` missing → print the specific reason to stderr and exit non-zero. Never fall back to a local run.
- Exit with the Playwright client's exact exit code. Do not remap a normal test failure to an SSH-style status.
- `set -euo pipefail`. No SSH tunnel, control socket, PID handling, or hard-coded LAN/Tailscale IP remains — measured verification showed the remote sandbox cannot consume a host-side reverse-tunnel loopback, while the validated private canonical URL is directly reachable.
- `chmod +x` the script and commit the executable bit.

**Acceptance (one executable check):**
- Run, from `plugins/international-press-zone/tests/e2e/`: `./run-journey.sh journeys/UJ-006-save-settings.spec.js --grep "H1 auto-saves Debug Mode"`
- Expected: the existing UJ-006 H1 journey runs remotely and PASSes. This proves remote browser, authentication, UI and REST connectivity without coupling runner acceptance to UJ-006's unrelated stale timeout-normalization case. Second check: `./run-journey.sh` with no argument exits `2` and prints a usage line.

- [ ] Write `run-journey.sh` with the pinned mechanics and fail-closed handling
- [ ] Run both acceptance checks
- [ ] Commit: `git add plugins/international-press-zone/tests/e2e/run-journey.sh && git commit -m "test: add remote E2E journey runner"`

---

## Spec Coverage

| Spec requirement | Task |
|---|---|
| §3.1 `Settings` sync accessors + dirty-on-mutation | 1 |
| §3.2 `ExceptionSync` push/pull + dead `exceptions` key removal | 3 |
| §3.3 `ExceptionsController` six routes + registration | 5 |
| §3.4 `SettingsController` three-key delegation + reset | 6 |
| §3.5 `TranslationFinalizer` auto-publish rule | 7 |
| §3.6 debug migration | 2 |
| §3.7 `ExceptionsTab.js` | 8 |
| §3.7 settings page controls + tab registration | 9 |
| §3.7 exceptions styles + `main.scss` | 4 |
| §5 tone / auto-publish journey | 11 |
| §5 debug + reset journey | 12 |
| §5 add/delete exception journey | 13 |
| §5 bulk import journey | 14 |
| §5 auto-publish PHP integration check | 15 |
| §5 clean build gate | 10 |
| §5 E2E must run through `e2e-remote` | 16 |
