# Site Entry UX Completion — plan

Audience: AI coding agents first.

- **Status:** ACTIVE
- **Outcome:** The Pages screen names the homepage and posts page outright and shows each page's real layout.
- **Worktree:** `wp-content/.worktrees/ipz-site-entry-ux`, branch `wt/ipz-site-entry-ux`, based on `origin/master` (`e2c119860`).
- **Reference WIP (stale, read-only):** `<scratchpad>/wipref/` — copies pulled from the abandoned `.worktrees/multilingual-site-entry-and-layout` dirty tree.
- **Canonical design:** `2026-08-09-multilingual-site-entry-and-layout-design.md` (in `wipref/`). Its invariants remain binding.

## Source request

Owner, 2026-08-09 (session `ipz-ux-improve`, verbatim intent):

- Cannot tell which of hundreds of Pages is the homepage; none is labelled. The plugin's Pages list must mark it.
- A translated homepage must appear automatically at the target-language root (`example.com/he/`).
- The admin must show which page is which — page role and the layout/template actually in effect.
- Template pages must be translatable; decide whether they belong under Pages or a separate area (decision taken: separate Site Content area).
- Industry-standard behaviour, explained without WordPress jargon. Owner works on **dev1**; local WordPress is fixture data only and never evidence about dev1.

Owner, 2026-08-15: continue and deliver; sonnet subagents implement, opus reviews.

## Prior state (audited 2026-08-15)

The 2026-08-09 worktree is an ancestor of `origin/master`; every commit it held has landed. Its remaining dirty tree is 6 days and 533 commits stale, and nearly every file it touches also moved on master — it is salvage material, not a mergeable branch.

Already on `origin/master`:

- `Frontend/`: `LocaleBootstrap`, `LocaleContextCoordinator`, `LanguageRequestContext`, `SpecialPageResolver`, `EffectiveTemplateResolver`, `LocalizedURLResolver`, wired through `Core/Plugin.php`.
- `SiteContent/`: `class-blocksegmentcodec`, `class-blocktemplatetranslationadapter`, `class-sitecontentcatalog`, `class-sitecontentrepository`, `class-sitecontentrequestcontext`, `class-siteentityidentity`.
- `site_content_translations` table, migrations 008/009, `ipz_site_content` feature flag.
- `Translation/TranslationResourceHandler` interface, `FinalizeResult`, `TranslationPayload`.

Absent on `origin/master` — this plan's scope:

1. `API/PostsController.php` emits no homepage / posts-page / effective-layout fields. The owner's primary ask is undelivered.
2. No admin UI renders page-role badges or effective layout.
3. No `API/SiteContentController.php`; the Site Content domain layer is unreachable.
4. No `admin/src/pages/site-content.js`.
5. No implementations of `TranslationResourceHandler`.

## Phases

### Ownership — read before touching any file

The page-role/effective-layout work and the whole Site Content controller/admin surface are being implemented RIGHT NOW by the concurrent `international-press-zone-fd` lane in `.worktrees/mslayout-complete` (branch `wt/mslayout-complete`, same `origin/master` base). Verified 2026-08-15: its `PostsController` already carries `site_roles`/`assigned_layout`/`effective_layout`.

This plan MUST NOT create or edit any of:
`API/PostsController.php`, `API/SiteContentController.php`, `Translation/PostTranslationResourceHandler.php`, `Translation/SiteContentTranslationResourceHandler.php`, `Translation/{TranslationResourceHandler,JobSender,JobReceiver,TranslationFinalizer,TranslationService,TranslationBridge}.php`, `admin/src/pages/{site-content,translations,content-translate,main}.js`, `admin/src/styles/pages/_translations.scss`, `SiteContent/class-{synced pattern,navigation}translationadapter.php`, the two new e2e specs, or any `press-zone-backend` file.

### Scope — the three unclaimed slices of the canonical plan

1. **`includes/Frontend/LanguageSwitcher.php`** — root-to-root links and unavailable-target behavior. Directly serves the owner's "`/he/` must show the Hebrew homepage" ask: the switcher must link language root to language root rather than to a translated slug, and must state honestly when a target translation does not exist instead of emitting a false URL.
2. **`includes/Compatibility/YoastSEOIntegration.php`** — take ownership through Yoast's documented filters only, never by scraping rendered HTML. Exact-root source fallback must be `noindex,follow` even when Yoast owns robots output.
3. **`includes/Migrations/` + `includes/Database/`** — schema version handling, multisite new-site creation, bounded cleanup, and cache invalidation for the `site_content_translations` table.

Plus focused unit tests for each under `tests/`.

### Landing

Each slice lands independently: rebase on current `origin/master`, gate there, land. Superseded the earlier single-lane plan on 2026-08-15 at the `international-press-zone-fd` lane's request — trunk-based incremental delivery, smallest independently verifiable unit. A worktree commit is not delivery.

### dev1 — deploy and verify are governed separately

Deploying and testing are NOT one decision, and the 2026-08-09 acceptance text conflates them:

- **Deploy to dev1** — a supported, maintained path (`tools/deploy-dev1.mjs`), gated on the owner's explicit request. Not forbidden.
- **Verify against dev1** — forbidden outright. `CLAUDE.md` (origin/master): "WordPress runtime/browser verification: canonical local `devzone-wordpress` only. Browser MUST use `e2e-remote`. NEVER contact `dev1.danzigeronline.com`, `dev3.press.zone`, `devzone-wordpress-snappy`, or remote/client WordPress." Reinforced by `.claude/skills/ipz-e2e.md:38` and its localhost-only `127.0.0.1:8080` topology.

Only the verification half of the old acceptance text is superseded. Until the owner rules: gates run on the buildbox stack only; nothing is deployed to dev1; nothing is tested against it.

### Binding delivery rules — read from `origin/master`, not the main checkout

The shared main checkout sits on `fix/content-generate-all` and carries a 25-line `CLAUDE.md`; `origin/master` carries 47 lines. The extra 22 lines are binding and were nearly missed:

- **Trunk-based incremental delivery.** Smallest independently verifiable task. Commit, then immediately land and push to `origin/master`. A worktree commit is NOT done.
- **Expand–migrate–contract for every database change.** Add compatible schema → deploy dual-state code → migrate/verify → enable → remove obsolete schema in a LATER verified release. Never combine destructive removal with initial deployment. Binds the Migrations/Database slice.
- **Feature flags:** server-enforced, single canonical primitive, default OFF, fail closed, each recording owner/purpose/rollout state/kill switch/removal task. Explicitly NOT for security fixes, tests, internal tooling, compatible API additions, or dormant schema preparation.
- **Preserve dirty WIP.** Never reset, stash, clean, or overwrite another session.
- Declare a blocker investigation limit before starting; on reaching it record the exact failure and use the established path. Never invent a bypass.

## Constraints

- No inline CSS. Text-plus-icon status, keyboard, live regions, focus restoration, RTL, dark mode, narrow width, reduced motion.
- Never remove Site Editor types from `PostTypeRegistry::PROTECTED_TYPES`.
- `wipref/` files target pre-rename APIs; port intent, re-derive against landed signatures. Never copy verbatim.
- Local WordPress data is never evidence about dev1.

## Gate invocation — two traps, both hit

1. **Never pipe `e2e-remote` output.** It fails closed with exit 254 when the remote runner refuses, but a pipeline reports its LAST command's status, so `e2e-remote ... 2>&1 | tail -N` reports `tail`'s 0 and hides the refusal. Redirect to a file and read `$?` directly. A refusal was briefly misdiagnosed as a fake-green defect in shared infrastructure on 2026-08-15 and retracted; `e2e-remote` is correct and needs no fix.
2. **The remote runner requires a package lock to own the cwd or the command arguments.** A composer-only workload dispatched from the repo root is refused. Dispatch from a lock-owned directory (`plugins/international-press-zone/admin`) and `cd` to the plugin root inside the payload.

## Gates

Every gate runs remotely through `/ipz-e2e` (`~/.claude/bin/e2e-remote`), dependencies installed inside the same remote workload. No local gate runs, no local fallback after remote failure.

- `composer test`, `composer phpcs`, `composer phpstan`
- `npm run lint:js`, `npm run build` (admin), committed `dist` matching source
- Focused Playwright specs, remote only

## Execution

1. Main thread writes per-task specs.
2. Sonnet subagents implement, disjoint file sets per wave.
3. Opus review of each wave; blocking findings return to the implementers.
4. Main thread reviews the whole phase, gates remotely, lands.

## Known defect found during survey — NOT this plan's to fix

The plugin autoloader (`international-press-zone.php:91-107`) maps `InternationalPressZone\Foo\Bar` to `includes/Foo/Bar.php` by exact short-name match and has no knowledge of the `class-{name}.php` convention. All six `includes/SiteContent/class-*.php` files are therefore unreachable by autoload; only tests load them, via explicit `require_once`, which is why no gate has caught it. Any controller that autoloads a SiteContent class will fatal at runtime while unit tests stay green. Reported to the owning lane on 2026-08-15. Related: `FeatureFlags::isEnabled('ipz_site_content')` defaults to false because `ipz_feature_flags` is unset on a normal install.

## Implemented

| Slice | Files | Invariant |
|---|---|---|
| Switcher | `Frontend/LanguageSwitcher.php`, `tests/Unit/Frontend/LanguageSwitcherTest.php` | Static homepage links root-to-root; unavailable targets emit no URL, are non-focusable, and announce state to assistive tech. Posts index deliberately keeps its translated slug, per design over brief. |
| Yoast | `Compatibility/YoastSEOIntegration.php`, `tests/unit/Compatibility/YoastSEORobotsFallbackStandaloneTest.php` | Source fallback is `noindex, follow` whether core or Yoast owns robots output. `wpseo_sitemap_entry` (undocumented) replaced with documented `wpseo_xml_sitemap_post_url`. |
| Schema | `Migrations/Migration009SiteContentAttemptAuthority.php`, `Database/SiteContentProvisioner.php`, `Database/SiteContentCleanup.php`, `Database/DatabaseMaintenanceOrchestrator.php`, 4 tests | Drifted schema self-heals; multisite new-site provisioning; bounded retention-gated cleanup, scheduled as a maintenance tier and reactivation-race safe. |

Review findings fixed in-thread: strict-typed Yoast sitemap callback would fatal on a third-party call shape; switcher guard omitted one of four resolvers; cleanup and provisioner were unreachable code until scheduled/registered.

`wp_get_themes()` must be called with `errors => null` — the default silently drops themes with errors, whose rows would then look orphaned and be purged. Cleanup callers must pass ALL installed theme scopes, never just the active one.

## Closed seam — `Core/Plugin.php` registration landed

`( new \InternationalPressZone\Database\SiteContentProvisioner() )->register();` is called from `registerHooks()`, which runs unconditionally from `init()` — not inside the `is_admin()` branches, so the `wp_initialize_site` hook is attached on site-creation requests. `SiteContentProvisioner::register()` self-guards on `is_multisite()`. `SiteContentProvisionerStandaloneTest` now asserts the bootstrap call is present, so removing it fails the test instead of silently reverting to inert.

### Verified finding — network activation does NOT provision existing sites

The `register_activation_hook` callback in `international-press-zone.php` takes no `$network_wide` parameter and runs `create_tables()`/`verify_tables()`/`migrate()` against the current blog only. Network-activating on an existing multisite network therefore provisions the activating site alone; sites created *afterwards* are covered by the provisioner above, but sites that already existed are not.

Status: **open product decision, not a defect to absorb silently.** A fix means iterating existing sites at activation (unbounded work on large networks) or lazy per-site provisioning on first request. Both are new scope beyond the site-entry UX plan and neither is required by any environment in play — local `devzone-wordpress` defines no `MULTISITE` constant. Owner decides before anyone builds it.

## Gate receipt — 2026-08-15, remote `debian3` via `e2e-remote`

- `composer test` → **OK (85 tests, 315 assertions)**
- `composer phpstan` → **[OK] No errors** (after fixing one real finding: `SiteContentCleanup::selectEligibleIds()` had an `is_array()` ternary whose else branch was unreachable — `$wpdb->get_col()` always returns an array. Removed.)
- Standalone tests, all `exit=0`: `SiteContentCleanup`, `SiteContentMigrationDrift`, `SiteContentProvisioner`, `DatabaseMaintenanceOrchestratorSiteContentCleanup`, `YoastSEORobotsFallback`
- `composer phpcs` → **FAILS, pre-existing at baseline.** 156 files flagged across the whole tree, including files this plan never touched (`Translation/JobSender.php`, `API/PostsController.php`, `Core/Plugin.php`). `--standard=WordPress` demands tabs against a space-indented PSR-style codebase. Not addressable within this scope: CLAUDE.md forbids cleaning baselines and mass-formatting, and a prior lane's attempt to narrow the ruleset was correctly rejected in review as a security-sniff regression. Declared benign-for-this-slice and escalated as a standing repo condition, NOT silently passed.

Two further dispatch traps beyond the two above, both cost a failed run:
3. Some buildbox login shells shim `composer` to a missing `bun`. Use `bash -c`, never `bash -lc`.
4. The local `bash-gate` hook rewrites `tail`/`head`/`cat` **inside the remote payload string**, so the remote receives `ft`. Prefix the dispatch with `FT_FULL=1`.

## Receipt

- 2026-08-15: seam surveys returned; three sonnet implementers dispatched on disjoint file sets (LanguageSwitcher / YoastSEOIntegration / Migrations+Database). Landing agreed with the `international-press-zone-fd` lane: this branch is never pushed to master; that lane cherry-picks and gates the merged tree once. No build here, no `admin/dist/` commits.
- 2026-08-15: the `multilingual-navigation-adapter` lane claimed the Site Content controller/handler/admin-page work; Phase 2 dropped from this plan, Phase 1 confirmed non-overlapping by both concurrent IPZ lanes.
- 2026-08-15: recovered the lost 2026-08-09 request from session `9dc5735a`; audited the stale worktree against `origin/master`; established the gap list above; created worktree `ipz-site-entry-ux` at `origin/master`; staged salvage into `wipref/`; dispatched two read-only seam surveys.

## Next executable action

Review the three implementers' output (opus, main thread), then dispatch the remote gate set through `/ipz-e2e` and hand the reviewed commits to the `international-press-zone-fd` lane for single-lane landing.
