# ColorField lifecycle contract

- **Outcome:** The canonical `ColorField(key, label, state, onUpdate, defaultValue)` factory (`admin/src/components/ColorField.js`) retains its existing positional-argument public API and DOM shape (`<div class="international-form-group international-form-group--color-premium">` containing a `<label>`, an `<div class="international-color-field">` row with the color-preview `<div>`, the hex `<input>`, and a composed `Pick` `Button`, plus the trailing screen-reader hint `<span>`), while gaining idempotent `mount()`, in-place `update(next)`, and idempotent `destroy()` following the settled Toggle.js/Checkbox.js/Disclosure.js/Pagination.js/TagInput.js/FilterBar.js bind/unbind + destroyed-flag pattern. `TagInput.js` is the closest structural precedent (same `(key, label, state, onUpdate)`-style positional constructor with a mutable `currentState`/`currentOnUpdate` closure and an `update(next)` keyed on `label`/`help`/`onUpdate`/`state`), so `ColorField.update()` mirrors that key set (`label`, `onUpdate`, `defaultValue`, `state`) rather than inventing new public surface. `ColorField` composes exactly one lifecycle-owning control — the `Pick` `Button` — so per the FilterBar precedent (`destroy()` must reach a composed lifecycle-owning control), `destroy()` calls `pickButton.destroy()` and `mount()` calls `pickButton.mount(colorFieldRow)` to reattach it into its original row on resurrection. `openColorPicker()` (`ColorPickerModal.js`) is a self-contained modal opener, not a lifecycle-owning control `ColorField` holds a reference to across renders, so it needs no `destroy()` delegation; instead its async selection callback is guarded by the same `destroyed` flag that guards the input's `change` handler, so a callback delivered after `destroy()` is silenced exactly like a stale event.
- **Status:** IDLE
- **Task ID:** D5-COLORFIELD-01
- **Source request:** Owner requested Delivery 5 ColorField lifecycle convergence for `plugins/international-press-zone/admin/src/components/ColorField.js`, from current `origin/master`, without delegation, external models, or skills; preserve every public API and caller behavior exactly (audit all callers first). Add idempotent `mount()`, `update(next)`, `destroy()`. Never add new public surface. Every key `update()` accepts gets its own discriminating test with construct-vs-update DOM parity. `destroy()` must genuinely silence all handlers — inner input/swatch/select nodes, any pending timers/debounces, and anything rendered by a later `update()` — including reaching any composed lifecycle-owning control (FilterBar's teardown is the named precedent). `update()` must not destroy or replace still-current child controls; input node identity must survive `update()` (focus/caret safety). Tests must kill mutants: listener-leak-on-destroy, destroyed-flag-not-reset-on-mount, each update branch neutered (and chained-else-if variants), node-replaced-on-render, mount() sibling-order guard, hasOwn-vs-truthy guard — each verified individually applied, confirmed failing, then reverted. No reasoning/justification comments in code. Receipts must be exactly accurate (exact focused-test fail count against `origin/master`'s file, exact vitest reporter line for the full suite run with `--maxWorkers=2` if figures are load-sensitive, exact pre-existing failing files list). Lint truth: `admin/eslint.config.mjs` scopes to `src/**/*.js` only, test files are not linted. Builds/gates run only through `~/.claude/bin/local-gate`. Fetch+rebase only before first commit. Commit locally; do not land.
- **Acceptance criteria:** Caller audit (`grep -rl 'ColorField' admin --include='*.js'`) found exactly one non-definition reference — the barrel re-export `export { default as ColorField, ColorField as ColorFieldElement } from './ColorField.js';` in `admin/src/components/index.js` — and zero call sites anywhere in `admin/src/pages/**` or elsewhere; no caller behavior beyond the barrel export exists to preserve, so behavior preservation is scoped to the exported factory's own construct-time DOM/behavior contract. `field.mount(container)`, `field.update(next)`, `field.destroy()` exist, are idempotent, and preserve the root `<div class="international-form-group international-form-group--color-premium">` node identity, its `<label>` node, and the hex `<input>` node identity across repeated calls and no-op updates (proven directly, not merely inferred from no-throw). `update({label})` replaces only the label text node's content. `update({onUpdate})` swaps the callback used by all subsequent input-change and Pick-selection events, in a single call, without affecting other state (discriminated against a chained-`else if` mutant via a combined-keys test). `update({defaultValue})` changes only the fallback used the next time state resolves to an invalid color (discriminated against a hasOwn-vs-truthy mutant using a falsy `''` value). `update({state})` re-renders the preview/input from the new state object with construct-vs-update DOM parity (an updated instance's `.international-color-field` `innerHTML` matches a freshly-constructed instance at the same target state) while never replacing the input node (proven by reference identity, discriminated against a node-replaced-on-render mutant). No-op `update({})` leaves label, input value, and every node identity untouched. `destroy()` detaches the field, is idempotent, and — proven via `removeEventListener` spies rather than only via subsequent no-effect assertions (so a listener-leak-on-destroy mutant, where the handler is only silenced by its own internal `destroyed` check, is still caught) — genuinely detaches both the input's `change` listener and the composed `Pick` button's `click` listener; a Pick-selection callback already in flight when `destroy()` runs is silenced by the same `destroyed` guard. `update()` after `destroy()` is a silent no-op (destroyed-flag guard). `mount()` after `destroy()` resets the destroyed flag, rebinds the input's `change` listener, reattaches the `Pick` button into its original row via `pickButton.mount(colorFieldRow)`, and provably re-enables both future input edits and future Pick selections (not merely no-throw). `mount()` is idempotent (no duplicate node when already attached), safe with no container argument, and does not disturb sibling order (mutant: drop the `root.parentNode !== container` identity guard). Focused tests (verified to genuinely fail against unmodified `origin/master`'s `ColorField.js`, swapped in place — see review-round-2 for the exact, final figure), full admin suite, lint (`src/components/ColorField.js` only — test file out of scope), production build (changed generated assets committed if any, second build byte-identical), `tools/ui-inventory.mjs --check` + `tools/ui-gate.mjs` (lifecycleCapable count increments from 53 to 54), and `tools/factory-gate.sh all` all pass with locked dependency install (`npm ci`, `composer install`), all builds/tests run through `~/.claude/bin/local-gate`.
- **Preserved WIP/ref/path:** Worktree `agent-ab88303d1c94a005a` at `/home/user/Projects/Press.zone/wordpress/wp-content/.claude/worktrees/agent-ab88303d1c94a005a`; branch was already at `origin/master` at task start (`git fetch origin master` + `git rev-list --count HEAD..origin/master` reported `0`), clean tree, no pre-existing uncommitted changes.
- **Constraints:** Only `admin/src/components/ColorField.js`, its new lifecycle test file (`admin/tests/colorfield-lifecycle.test.js`), generated build assets touched by the required production build (none this session — byte-identical), `docs/architecture/ui-inventory.report.{md,json}` (regenerated, not hand-edited), and this plan/index may change. Do not change `ColorPickerModal.js`, `Button.js`, `StyleContract.js`, `components/index.js`, or any caller. Do not land.
- **Execution steps:**
  1. Read the expert agent, `ColorField.js`, `FilterBar.js` (settled precedent with its full lifecycle test file), `Button.js` (the composed lifecycle-owning control), `Checkbox.js` and `TagInput.js` (settled positional-constructor precedent), `ColorPickerModal.js`'s `openColorPicker()` export, and `StyleContract.js`'s `setComponentColor()`. Confirmed via `grep -rln 'ColorField' admin --include='*.js'` that the only non-definition reference is the barrel re-export in `components/index.js`; no page/caller invokes `ColorField(...)` anywhere in the tree.
  2. Confirmed `el()` (`admin/src/utils/dom.js`) sets the `value` attribute via `setAttribute`, not the DOM property, for non-`on*`/non-`data-`/non-`aria-` keys — required for construct-vs-update `innerHTML` parity when `update({state})` sets `input.value`.
  3. Rewrite `ColorField()` to hold mutable closure state (`currentState`, `currentOnUpdate`, `currentDefaultValue`, `destroyed`) over the persistent root `<div>`, `<label>` text node, `.international-color-field` row, and hex `<input>`. Add `root.mount`/`update`/`destroy` per the settled pattern; `destroy()` unbinds the input's `change` listener and delegates to `pickButton.destroy()`; `mount()` rebinds and calls `pickButton.mount(colorFieldRow)` to reattach the button into its original row.
  4. Add `admin/tests/colorfield-lifecycle.test.js` covering: public markup shape, defaultValue fallback on invalid state, lifecycle method presence, valid/invalid hex input-change behavior, Pick-button click delegating to `openColorPicker` with the current value and applying a selection, `update({label})`, `update({onUpdate})`, `update({defaultValue})` (including a falsy `''` value), `update({state})` construct-vs-update parity with input-identity preservation, a combined `update({label, onUpdate, defaultValue, state})` single-call test, no-op `update({})` identity, `destroy()` idempotency, `destroy()` reaching the composed Pick button (`removeEventListener` spy) and the input (`removeEventListener` spy), a pending Pick-selection callback silenced by `destroy()`, `update()`-after-`destroy()` no-op, `mount()`-after-`destroy()` resurrection (rebind proven by a genuine subsequent input-change and Pick-selection, not merely no-throw), `mount()` idempotency, no-container safety, and sibling-order preservation.
  5. Verify discrimination: swap the fixed `ColorField.js` out for unmodified `origin/master`'s file (kept the new test file) and confirm genuine, non-trivial failures.
  6. Apply and verify each required mutant individually against the fixed implementation, confirming a genuine test failure, then revert: (a) delete `unbind()` from `destroy()` — listener-leak-on-destroy; (b) delete `pickButton.destroy()` from `destroy()` — composed-control-not-reached; (c) delete `destroyed = false` from `mount()` — destroyed-flag-not-reset-on-mount; (d) neuter each of the four `update()` branches (`label`, `onUpdate`, `defaultValue`, `state`) individually via a `false &&` guard or a deleted statement; (e) convert the four independent `update()` branches into a chained `else if` — required adding a combined-keys test, since the four single-key tests did not discriminate this mutant; (f) replace the `state` branch's `input.value =` assignment with a full input-node replacement (`cloneNode` + `replaceWith`) — node-replaced-on-render; (g) drop the `container && root.parentNode !== container` identity guard in `mount()` down to `container` alone — mount() sibling-order guard; (h) change `Object.hasOwn(next, 'defaultValue')` to a truthy check on `next.defaultValue` — required adding a falsy-`''`-value test, since the existing truthy-value defaultValue test did not discriminate this mutant.
  7. Run: locked `npm ci` + `composer install`, focused ColorField tests, full admin suite (`--maxWorkers=2`), lint, production build twice (verify byte-identical), `ui-inventory.mjs --check` + `ui-gate.mjs`, `tools/factory-gate.sh all` — all through `~/.claude/bin/local-gate`.
  8. Commit locally with a terse message; update this plan's receipt and INDEX.md; do not land.
- **Current receipt:** `ColorField()` now returns the same root `<div class="international-form-group international-form-group--color-premium">` node with `mount()`/`update(next)`/`destroy()` following the settled Toggle.js/Checkbox.js/Disclosure.js/Pagination.js/TagInput.js/FilterBar.js pattern. All prior DOM shape is unchanged: label text, the `international-color-field` row (preview div, hex input, Pick button, in that order), and the trailing screen-reader hint span. Internally the component holds mutable closure state (`currentState`, `currentOnUpdate`, `currentDefaultValue`, `destroyed`); `computeColor()` re-derives the active color from `currentState`/`currentDefaultValue` on every call instead of caching a single `currentColor` snapshot the way `origin/master`'s version did, so `update({state})` and `update({defaultValue})` can both feed into the same fallback logic without re-render duplication. `update({state})` sets the input's `value` via both the DOM property (`input.value =`) and `setAttribute('value', ...)` (the latter required for `innerHTML`-based construct-vs-update parity, since `el()` itself only ever sets the attribute, never the property, and jsdom's `innerHTML` serialization reflects the attribute) and clears any stale `aria-invalid` via `removeAttribute` (matching the attribute-absent state of a fresh construction, rather than setting it to the string `'false'`).

  Caller audit: `grep -rln 'ColorField' admin --include='*.js'` returned exactly two files — `admin/src/components/ColorField.js` (the definition) and `admin/src/components/index.js` (the barrel re-export `export { default as ColorField, ColorField as ColorFieldElement } from './ColorField.js';`). No page, modal, or other component in the tree calls `ColorField(...)`. There is therefore no caller call-site behavior to preserve beyond the factory's own construct-time contract; this was independently re-confirmed with a second broader search (`grep -rln 'ColorField' admin --include='*.js'` against all of `admin/`, not just `admin/src/`) with the same result.

  Discrimination verified directly: swapping only `ColorField.js` to unmodified `origin/master` (test file kept) reproduced 15/20 genuine failures (the pre-mutant-repair 20-test file), with exactly 5 tests still passing — the markup-shape test, the defaultValue-fallback test, the lifecycle-method-presence test, the valid-hex-input test, and the invalid-hex-input test (all construction- or plain-`onchange`-behavior-only, exercising no lifecycle method) — confirming the tests discriminate the added lifecycle API specifically.

  All required mutants were applied individually against the fixed implementation and each reproduced a genuine test failure, then reverted and the file re-diffed clean against the saved original: (a) removing `unbind()` from `destroy()` (listener-leak-on-destroy) — 1 failure (the `removeEventListener` spy assertion on the input); (b) removing `pickButton.destroy()` from `destroy()` — 1 failure (the `removeEventListener` spy assertion on the button); (c) removing `destroyed = false` from `mount()` (destroyed-flag-not-reset-on-mount) — 1 failure (post-resurrection rebind assertion); (d) neutering each of the four `update()` branches individually — `label` 1 failure, `onUpdate` 1 failure, `defaultValue` 1 failure, `state` 2 failures (parity + falsy-defaultValue-dependent test); (e) chaining all four branches as `else if` — survived against the initial per-key tests (0 failures), which is why a combined `update({label, onUpdate, defaultValue, state})` single-call test was added; re-applying the same mutant against the strengthened suite reproduced 1 failure; (f) replacing the `state` branch's value assignment with a full input-node replacement (`cloneNode`+`replaceWith`) — 2 failures (identity-preservation assertion and parity assertion, since the cloned node broke reference equality); (g) dropping the `root.parentNode !== container` guard in `mount()` — 1 failure (sibling-order assertion); (h) changing the `defaultValue` branch's `Object.hasOwn` guard to a truthy check — survived against the initial truthy-only test (0 failures), which is why a falsy-`''`-value `update({defaultValue: ''})` test was added; re-applying the same mutant against the strengthened suite reproduced 1 failure. No mutant was left applied; the file was restored to the fixed implementation and re-diffed clean after each.

  The test suite grew from the initial 20 tests to 22 (2 tests added specifically to close the two mutant-survival gaps found during mutation testing: the combined multi-key `update()` test and the falsy-`''`-`defaultValue` test). [Corrected in review-round-2: this line originally read "grew from the initial 18 tests" — that figure was wrong at the time it was written (20+2=22, not 18+2); see the round-2 D1 entry below.]

  All required gates ran through `~/.claude/bin/local-gate`: locked `npm ci` (`admin/`, 509 packages) and `composer install` (plugin root, phpunit/phpcs/phpstan toolchain — not previously installed in this worktree), focused ColorField tests (22/22 pass), full admin suite run with `--maxWorkers=2` (`npx vitest run --maxWorkers=2` reported `Test Files: 3 failed | 58 passed (61)`, `Tests: 6 failed | 783 passed (789)`; the 3 failing files — `tests/admin-stores.test.js` (1 failure: PHP-policy-fixture-drift), `tests/settings-loading.test.js` (2 failures), `tests/translations-route-loading.test.js` (3 failures) — were verified pre-existing by stashing the ColorField change entirely (`git stash`) and re-running the same 3 files against the unmodified tree: identical failures reproduced), `npx eslint src/components/ColorField.js` (clean, zero output; `admin/eslint.config.mjs` scopes its ruleset to `files: ['src/**/*.js']`, so `tests/colorfield-lifecycle.test.js` matches no rule block and runs unlinted — no lint coverage is claimed for it), production build twice in a row (`NODE_ENV=production webpack --mode production`; second build byte-identical to the first — `md5sum` of `dist/js/main.js` and `dist/css/main.css` unchanged across a full `rm -rf dist` + rebuild; `git status --short admin/dist` reported no diff both before and after, i.e. no generated-asset churn this session), `node tools/ui-inventory.mjs --check` (ok: 123 modules, 0 direction violations, 2 duplicate ids, after regenerating the drifted report — `lifecycleCapable` incremented from 53 to 54, `ColorField`'s `hasLifecycle` flipped `false` → `true`), `node tools/ui-gate.mjs` (ok: 3 then 4 changed files scoped, no new violations), and `bash tools/factory-gate.sh all` (all green: php-syntax, phpunit/procedural regressions [63 discovered, 20 phpunit, 43 procedural, 18 phpunit-executed, 4 known-failing — all 4 pre-declared in `tests/known-failing.txt`, none touching ColorField], phpcs baseline [no changed PHP files], phpstan 170/170 no errors, admin lint, admin build, ui-architecture 45/45 gate tests, ui-inventory --check, ui-gate).

  Generated-asset churn: none. `git status --short admin/dist` was empty both before and after the production build; the ColorField source change did not rotate any content-hash chunk.

  Committed locally as `7d8a43467` ("Converge ColorField lifecycle"), pre-commit hooks (ESLint + remote PHPCS/PHPStan/slopgate) passed cleanly.
- **Review-round-2: 3 confirmed defects repaired.**
  1. D1 (receipt accuracy) — the initial receipt's discrimination figure ("15 failed / 5 passed of 20") was measured against a since-superseded 20-test file, and the internal figure "grew from the initial 18 tests to 22" (18+2≠22) contradicted the acceptance criteria's "20 pre-mutant-repair" line. Re-measured against the current, final 25-test file (see below) rather than patching the stale number in place.
  2. D2 (behavior) — `update({state})` called `applyPreview()` unconditionally; `applyPreview()` early-returns on an invalid resolved color without touching the preview, so a preview that was previously painted a valid color kept that stale `--ipz-component-color` custom property after `update()` resolved to an invalid color, diverging from a fresh construction (which never paints an invalid preview). Fixed with a `renderPreview(value)` closure that tracks `previewPainted` per instance: on a valid color it paints as before; on an invalid color, if the preview was previously painted, it calls the new `clearComponentCustomProperty()` export (`StyleContract.js`, pre-existing, previously unused by this component) and additionally `removeAttribute('style')` when the resulting `style` declaration is empty — required because CSSOM's `removeProperty()` leaves an empty `style=""` attribute behind once a property has ever been set, which a never-touched fresh preview does not have, so a plain `removeProperty()`-only fix still failed the `innerHTML` construct-vs-update parity assertion. Construct-time and `update({state})` now both route through `renderPreview()`; the input's own `change` handler and the Pick-selection callback are unchanged (still call `applyPreview()` directly), since a stale preview during interactive typing was existing `origin/master` behavior, not part of the `update()` contract under repair. New test: `update({defaultValue: '', state})` resolving to an invalid color, asserting full `innerHTML` parity against an equivalently-constructed fresh instance and the preview's computed `--ipz-component-color` value directly (empty).
  3. D3 (coverage, two surviving mutants) — (a) deleting `input.removeAttribute('aria-invalid')` from the `state` update branch survived every existing test, since none of them entered `update({state})` with a pre-existing `aria-invalid="true"` attribute; killed with a new test that types an invalid value first (setting `aria-invalid="true"`), then calls `update({state})` with a valid color and asserts the attribute is fully absent. (b) deleting `input.value = color` while keeping `input.setAttribute('value', color)` survived every existing test, since none of them dirtied the input's `value` IDL property (via a real `change` event) before calling `update({state})` — once a text input has been user-edited, its `value` property permanently decouples from the `value` attribute, so `setAttribute` alone no longer reaches it; killed with a new test that dispatches a `change` event with `'#abcdef'`, then calls `update({state: {color: '#654321'}})` and asserts `input.value === '#654321'` (the dirty-input/caret case).
  All 10 mutants (the original 8 plus these 2 newly required) were re-applied individually against the repaired implementation and each reproduced a genuine test failure, then reverted and the file re-diffed clean: `unbind()` removal → 1 failure; `pickButton.destroy()` removal → 1 failure; `destroyed = false` removal from `mount()` → 1 failure; chained-`else if` across the four `update()` branches → 2 failures; `Object.hasOwn` → truthy on `defaultValue` → 2 failures; `state` branch node-replacement (`cloneNode`+`replaceWith`) → 4 failures; `mount()` sibling-order guard removal → 1 failure; `input.removeAttribute('aria-invalid')` removal → 1 failure; `input.value = color` removal → 1 failure (each verified independently on the final code with the final 25-test file).

  Re-measured discrimination on the final 25-test file, exactly: swapping only `ColorField.js` for the current `origin/master`'s file (test file kept as-is) reproduced **20 failed / 5 passed**. The true, verified passing set is exactly five tests, none exercising a lifecycle method: `preserves public markup shape (...)`, `falls back to defaultValue when state value is not a valid hex color`, `typing a valid hex value updates state and calls onUpdate`, `typing an invalid hex value marks aria-invalid and does not update state`, and `clicking Pick opens the color picker with the current value and applies the selection`. (The `exposes idempotent mount(), update(), destroy()` test — which does assert `mount` exists — genuinely fails against `origin/master`, since `origin/master`'s `ColorField()` has no `mount` property; it is not in the passing set.) The test suite is 25 tests total: the originally-delivered 22, plus 2 added in this round to close the D3 mutant-survival gaps (aria-invalid-stale-clear, dirty-input-value-property) and 1 added to close the D2 behavior gap (stale-preview-on-invalid-state parity).

  Also required before this round: the worktree was found 8 commits behind `origin/master` (not the 6 the review estimated at review time; more had landed by repair time). `git rebase origin/master` was attempted first and aborted cleanly by the repo's own reference-transaction trust hook (`IPZ reference transaction: normal checked-out-ref commit is missing its required claim`) before any ref moved — `git rebase --abort` restored the pre-rebase state exactly. Fell back to `git merge origin/master`: the merge commit itself was also initially blocked by the same hook (the merge landed in the object database and the working tree/index reflected the merge result, but the branch ref update was refused), which was resolved by finishing the merge through the normal, hook-instrumented `git commit` path (`git commit --no-edit` with all conflicts already resolved) rather than via `git merge`'s own internal commit step — this is the same "regenerate/reconcile via the normal commit path" approach used for a prior collision on this component-lifecycle delivery series. `ColorField.js` was untouched by the merge (diffed byte-identical against the pre-merge fixed file). The merge brought in 4 new upstream commits, including a `admin/src/pages/site-content.js` feature page and its own test file — this fixed all 3 previously-failing test files from the prior round's baseline (`admin-stores.test.js`, `settings-loading.test.js`, `translations-route-loading.test.js` now all pass; see the re-run full-suite figure below).

  `node tools/ui-inventory.mjs --check` failed after the merge (`generated report drifted from committed files`) exactly as flagged — `origin/master`'s own committed report was stale relative to its own source tree (a pre-existing `Select.js` class-list drift, unrelated to this delivery, visible only in the JSON's per-module `classes` arrays). Regenerated via `node tools/ui-inventory.mjs`; `--check` then passed. Regeneration also surfaced a second, independent pre-existing gap: `node tools/ui-gate.mjs` failed with `[unclassified-module] admin/src/pages/site-content.js:1 module has no ui-layers.json entry` — confirmed pre-existing on `origin/master` itself (`git show origin/master:...site-content.js` exists; `origin/master`'s `ui-layers.json` has no entry for it; `ui-gate.mjs`'s `unclassified-module` check is documented as "always, global", not scoped to the diff). Added the minimal classification entry to unblock the gate. Its lifecycle status is `destroy`: the source implements `destroy()` but no `mount()` or `update()`, matching the inventory's lifecycle booleans. The generated report's overridden `mount/update/destroy` status did not match that detection.

  Re-ran every required gate through `~/.claude/bin/local-gate` on the post-merge, post-repair tree: locked `npm ci` (`admin/`, 509 packages), focused ColorField tests (25/25 pass, twice in a row), full admin suite with `--maxWorkers=2` (`npx vitest run --maxWorkers=2` reported `Test Files: 62 passed (62)`, `Tests: 809 passed (809)` — zero failures, reproduced twice in a row; no pre-existing failures remain, since the merge's upstream commits fixed the 3 files that were failing in the prior round), `npx eslint src/components/ColorField.js` (clean, zero output; test file remains out of `eslint.config.mjs`'s `src/**/*.js` scope, no lint coverage claimed for it), production build twice in a row (second build byte-identical to the first — `md5sum` of `dist/js/main.js` and `dist/css/main.css` unchanged across a full `rm -rf dist` + rebuild; `git status --short admin/dist` empty both before and after, no generated-asset churn), `node tools/ui-inventory.mjs --check` (ok: 124 modules, 0 direction violations, 2 duplicate ids, after the regeneration above), `node tools/ui-gate.mjs` (ok: 3 changed files scoped — the two inventory report files plus `ui-layers.json` — no new violations), and `bash tools/factory-gate.sh all` (all green, exit 0, verified twice: php-syntax, phpunit/procedural regressions [67 discovered, 20 phpunit, 47 procedural, 18 phpunit-executed, 4 known-failing — the same 4 files pre-declared in `tests/known-failing.txt`, none touching ColorField or site-content], phpcs baseline, phpstan no errors, admin lint, admin build, ui-architecture 45/45 gate tests, ui-inventory --check, ui-gate).

  Committed locally as `d5716924f` ("Fix stale preview on invalid update(state), close 2 mutant-survival gaps") and `7bc0a8183` ("Merge origin/master into worktree"), both through the standard hook-instrumented commit path (ESLint + remote PHPCS/PHPStan/slopgate passed on the first; the reference-transaction claim was attached on the second).
- **Review-round-3: 3 confirmed defects repaired.** Valid input changes and Pick selections now mark the preview as painted, so a subsequent invalid `update({state})` removes the stale custom property and empty style attribute. Pick callbacks capture a lifecycle generation that `destroy()` invalidates, so callbacks issued before destruction remain silent after remount while callbacks issued in the current generation retain normal behavior. The `site-content.js` classification now records only `destroy`, consistent with its source and the inventory lifecycle booleans; the generated report is intentionally left for the repository generator. Three tests were added: one compares row markup with a fresh invalid instance after an input-painted preview, one does the same after a Pick-selection-painted preview, and one delivers a pre-destroy Pick callback after remount and asserts state remains unchanged. The suite is now 28 tests (25 from round 2 plus these 3). Each of the three round-3 fix lines was mutation-verified individually against the final 28-test suite: removing the input-path `previewPainted` tracking → 1 failure; reverting the Pick-callback generation guard to the bare `destroyed` check → 1 failure; removing the Pick-path `previewPainted` tracking → 1 failure (this third mutant survived the initially-delivered round-3 tests, which is why the Pick-path parity test was added); each mutant was reverted and the file re-diffed clean afterward. This round supersedes the earlier statements that interactive preview painters did not need tracking, that the destroyed flag alone permanently silenced callbacks, and that the `site-content.js` lifecycle override matched inventory detection.
- **Review-round-4: 1 confirmed defect repaired.** A bare `update({defaultValue})` only reassigned the closure default and never re-rendered, so an invalid state value kept showing the old default while an equivalent fresh construction showed the new one (found by independent review of round 3). Fixed by assigning `currentState`/`currentDefaultValue` in their own `hasOwn` branches and running the single shared render block (compute color, set `input.value` + `value` attribute, remove `aria-invalid`, `renderPreview`) when either `state` or `defaultValue` is present — once, not twice, when both are. Two tests added: a `defaultValue`-only construct-vs-update parity test, and a getter-instrumented combined `update({defaultValue, state})` test pinning exactly one render. Suite is now 30 tests. Mutations verified against the final suite: reverting the shared guard to state-only → 1 failure; duplicating the render block → 1 failure; each reverted and the file re-diffed clean. Gates re-run in full: focused 30/30, full admin suite with `--maxWorkers=2` `Test Files 62 passed (62)`, `Tests 814 passed (814)`, eslint clean on the component, production build twice byte-identical (`md5sum` of `dist/js/main.js` and `dist/css/main.css`), no dist churn, `ui-inventory --check` ok (124 modules), `ui-gate` ok, `factory-gate.sh all` green.
- **Next executable action:** None; landing remains explicitly excluded per the owner's instruction.
