# Disclosure lifecycle contract

- **Outcome:** The canonical `Disclosure` factory (`admin/src/components/Disclosure.js`) retains its existing props/API, DOM structure (native `<details>`/`<summary>` accessible disclosure, `.ipz-disclosure`/`.ipz-disclosure__*` classes, lazy content mounting), and native `<details>` keyboard/toggle semantics, while gaining idempotent `mount()`, in-place `update(next)`, and idempotent `destroy()` with safe remounting and listener cleanup — matching the settled Toggle.js/Checkbox.js pattern. The pre-existing both-handlers-fire bug (built-in disabled/lockedOpen click guard always running alongside a caller-supplied `summaryAttrs.onclick`) is fixed: a supplied `summaryAttrs.onclick` now fully replaces the built-in guard, matching the landed precedence convention.
- **Status:** IDLE
- **Task ID:** D5-DISCLOSURE-01
- **Source request:** Owner requested Delivery 5 Disclosure lifecycle convergence for `plugins/international-press-zone/admin/src/components/Disclosure.js`, from current `origin/master`, without delegation, external models, or skills; preserve every public API, caller behavior, event-callback precedence (supplied attrs handlers fully replace built-ins, per the landed Toggle.js/Checkbox.js pattern), expand/collapse state, aria-expanded semantics, keyboard behavior (never change native activation timing), and DOM identity. Focused tests must genuinely discriminate: fail against `origin/master`'s Disclosure and against plausible regressions (both-handlers-fire, custom keydown added, listener leak on destroy). Cover caller compatibility, repeated lifecycle calls, no-op update identity, cleanup/remount, and accessibility state. Update inventory/report truthfully and record a durable per-slice plan receipt. Builds/gates run only through the `local-gate` wrapper (raw `npm run build` refused). Commit locally; do not land.
- **Acceptance criteria:** Existing `Disclosure()` callers (only caller: `admin/src/pages/fields-translate.js`'s `disclosure()` helper) keep working unchanged (`<details data-disclosure-id>`/`<summary data-disclosure-key>` shape, `.ipz-disclosure`/`.ipz-disclosure__summary`/`.ipz-disclosure__title`/`.ipz-disclosure__meta`/`.ipz-disclosure__chevron`/`.ipz-disclosure__content` classes, `expanded`/`disabled`/`lockedOpen`/`unmountOnClose`/`onToggle`/`children`/`summaryAttrs` props, lazy content mounting on open, unmount-on-close when configured, locked-open re-open guard, disabled revert-to-initial-expanded guard); `details.mount(container)`, `details.update(next)`, `details.destroy()` exist, are idempotent, and preserve node identity across repeated calls and no-op updates; a supplied `summaryAttrs.onclick` fully replaces the built-in disabled/lockedOpen click guard (never both firing — proven by asserting `event.defaultPrevented === false` when a custom handler is supplied against a disabled disclosure); no keydown listener is added by the component (native `<details>`/`<summary>` Enter/Space activation timing is untouched — the component relies entirely on the native `toggle` event, proven by dispatching a bare `keydown` and asserting no `onToggle` invocation); `destroy()` removes both the `click` (or override) and `toggle` listeners and detaches the node; remount reattaches listeners without changing node identity or leaking a duplicate registration (proven via post-destroy `toggle` event producing zero `onToggle` calls, and post-remount `toggle` event producing exactly one call); focused tests, full admin suite (pre-existing failures verified pre-existing via reverting only `Disclosure.js` against the current tree), lint, production build (with changed generated assets committed and a clean, byte-identical second build), `tools/ui-inventory.mjs --check` + `tools/ui-gate.mjs`, and `tools/factory-gate.sh all` all pass with locked dependency install.
- **Preserved WIP/ref/path:** Worktree `agent-a0f2f148db43bf8bd` at `/home/user/Projects/Press.zone/wordpress/wp-content/.claude/worktrees/agent-a0f2f148db43bf8bd`; HEAD `4586b3cb4` equals fetched `origin/master`; clean tree at start, no pre-existing uncommitted changes.
- **Constraints:** Only `admin/src/components/Disclosure.js`, its new lifecycle test file (`admin/tests/disclosure-lifecycle.test.js`), generated build assets touched by the required production build, `docs/architecture/ui-inventory.report.{md,json}` (regenerated, not hand-edited), and this plan/index may change. Do not change unrelated components, page behavior, or public DOM/API identity. Do not land.
- **Execution steps:**
  1. Read Toggle.js and Checkbox.js (settled lifecycle + override-precedence pattern) and current `origin/master` Disclosure.js; find its only caller (`fields-translate.js`).
  2. Add `mount`/`update`/`destroy` to the returned `<details>` root, following the bind/unbind + destroyed-flag pattern, applied to the `summary` click listener and the `details` `toggle` listener; fix the both-handlers-fire bug by removing the built-in click guard when `summaryAttrs.onclick` is supplied (mirroring `hasCustomClickHandler` in Toggle/Checkbox).
  3. Add `admin/tests/disclosure-lifecycle.test.js` covering: public markup/lazy-mount/native semantics, click-override precedence via `event.defaultPrevented`, no-added-keydown-listener/native-timing proof, disabled/lockedOpen guards, no-op `update({})` identity + in-place title/meta/disabled updates, `onToggle` swap via `update()`, destroy/remount listener cleanup with no leak, caller-compatibility shape matching `fields-translate.js`, and the original `id` guard.
  4. Verify discrimination: run the new test file against unmodified `origin/master` Disclosure.js (reverted in-place) — confirm genuine failures (both-handlers-fire assertion and missing `mount`/`update`/`destroy` APIs).
  5. Run: locked `composer install` + `npm ci`, focused Disclosure tests, full admin suite, lint, production build (commit changed generated assets, verify a second byte-identical build), `ui-inventory.mjs --check` + `ui-gate.mjs`, `tools/factory-gate.sh all` — all through `local-gate`.
  6. Commit locally with terse messages; update this plan's receipt and INDEX.md; do not land.
- **Current receipt:** `Disclosure()` now returns the same `<details>` node with `mount()`/`update(next)`/`destroy()` following the Toggle.js/Checkbox.js bind/unbind + destroyed-flag pattern, applied to the `summary` click listener and the `details` `toggle` listener. All prior props/classes/DOM shape are unchanged (`.ipz-disclosure`, `.ipz-disclosure__summary`, `.ipz-disclosure__title`, `.ipz-disclosure__meta`, `.ipz-disclosure__chevron`, `.ipz-disclosure__content`, lazy mount-on-open, unmount-on-close, locked-open re-open guard, disabled revert-to-initial-`expanded` guard). `summaryAttrs.onclick`, when supplied, now fully replaces the built-in disabled/lockedOpen click guard (verified fixing the pre-existing both-handlers-fire bug present on unmodified `origin/master`, where the built-in guard's `preventDefault()` always ran alongside a caller's own handler). The component adds no keydown listener; native `<details>`/`<summary>` activation timing is untouched. New `admin/tests/disclosure-lifecycle.test.js` (10 tests) covers: public markup + lazy content mounting, click-override precedence via `event.defaultPrevented` (genuinely fails on `origin/master`), no-added-keydown-listener/native-timing proof, disabled-blocks-toggle-and-reverts-to-initial-expanded, locked-open + unmount-on-close guards, no-op-update identity + in-place title/meta/disabled updates, `onToggle` swap via `update()`, destroy/remount listener cleanup with zero leaked invocations (genuinely fails on `origin/master` — no `mount`/`update`/`destroy` exist there), caller-compatibility shape matching `fields-translate.js`'s `disclosure()` helper usage, and the original missing-`id` `TypeError` guard.
  Discrimination verified directly: reverting only `Disclosure.js` to `origin/master` (test file kept) and re-running the suite produces 4 genuine failures — the click-override-precedence assertion (`defaultPrevented` true instead of false, proving both handlers fire) and 3 `TypeError: details.{update,mount}/destroy is not a function` — confirming the tests discriminate both the behavioral regression and the missing lifecycle API, not just presence of new code.
  All required gates ran through `local-gate`: locked `composer install` + `npm ci`, focused Disclosure tests (10/10 pass), full admin suite (705/709 pass — the same 4 pre-existing failures reproduced identically with only `Disclosure.js` reverted to `origin/master`: `admin-stores.test.js` PHP-policy-fixture drift and 3 `translations-route-loading.test.js` failures, none touching Disclosure), `npx eslint src/components/Disclosure.js` (clean, no output), production build twice in a row (second build byte-identical to the first, diffed directly on `runtime.js` and the emitted common chunk — zero further diff), `node tools/ui-inventory.mjs --check` (ok: 123 modules, 0 direction violations, 2 duplicate ids, after regenerating the previously drifted report), `node tools/ui-gate.mjs` (ok: 3 changed files scoped, no new violations), and `bash tools/factory-gate.sh all` (all green: php-syntax, phpunit/procedural regressions [63 discovered, 4 known-failing pre-existing], phpcs baseline, phpstan 167/167 no errors, admin lint, admin build, ui-architecture 45/45 gate tests, ui-inventory --check, ui-gate 9 changed files scoped).
  Generated-asset churn (corrected): only one file underwent genuine content-hash rotation from the `Disclosure.js` source change — `486.de1d46df.js` → `486.75bfd6ad.js`, still referenced by the rebuilt `runtime.js`'s chunk map. `549.2c6602a7.js` and `549.da6a9cef.js` were **not** rotated; they were pre-existing orphans already unreferenced by `origin/master`'s own `runtime.js` (which points at a third, still-present `549.8e95b0a2.js`) before this slice touched anything — verified by extracting `origin/master`'s committed `runtime.js` and grepping its embedded chunk filenames. The production build's orphan-pruning step removed them as stale committed build output, not as rotated output of this change (`asset-manifest.json`'s named entrypoints are unaffected either way).
  **Review round 2 — three confirmed defects repaired.** Independent review found three genuine bugs in the `update()` paths of commit `1df2ec685`, all fixed in place:
  1. BLOCKER — a disabled disclosure could get stuck open forever: `handleToggle`'s disabled branch reverted `details.open` to the constructor-time `expanded` value, not the state at the moment disabling actually took effect. Fixed by tracking `frozenOpen`, captured from `details.open` at the instant `update({disabled:true})` transitions disabled from false→true (and initialized to `expanded` for the constructor-disabled case, preserving prior behavior there).
  2. BLOCKER — `update({children})` was silently dropped: `mountContent()` early-returns once a `.ipz-disclosure__content` node exists, so new children never rendered under the (default) `unmountOnClose:false` configuration. Fixed by having `update()` explicitly replace any already-rendered content node with freshly rendered children via a new shared `renderContent()` helper, instead of only updating the closed-over `currentChildren` variable and hoping a future `mountContent()` call would pick it up.
  3. `summaryAttrs` flattening in `update()` (`{...next, ...next.summaryAttrs}`, copied from the Checkbox pattern where it doesn't apply) consumed nothing — no code ever read the flattened keys — and risked colliding `summaryAttrs.title`/`summaryAttrs.meta` with the top-level heading options. Fixed by consuming `next.summaryAttrs` directly against the `summary` DOM node (`setAttribute`/`removeAttribute`, with `class`/`className` merged onto the base `ipz-disclosure__summary` class), excluding `onclick` (a construction-time-only override, matching Toggle.js/Checkbox.js).
  Three new discriminating tests were added to `admin/tests/disclosure-lifecycle.test.js` (13 tests total, up from 10) covering exactly the three previously-untested paths: `update({children})` replacing already-rendered content (and surviving a close/reopen cycle), `update({summaryAttrs})` applying to the summary node without disturbing title/meta, and toggle-after-disabled-flip freezing the live open state. Discrimination was verified directly: reverting only `Disclosure.js` to the pre-fix commit `1df2ec685` (test file kept at HEAD) reproduces exactly these 3 failures and no others (10/13 pass, the 3 new tests fail with the exact described symptoms — stale `'first content'`, `null` instead of `'Custom label'`, and `details.open` snapping to `false` instead of staying `true`).
  All required gates re-run through `local-gate` on the fixed code: focused Disclosure tests (13/13 pass), full admin suite (708/712 pass — the same 4 pre-existing failures, unchanged), `npx eslint src/components/Disclosure.js` (clean), production build twice in a row (byte-identical second build), `node tools/ui-inventory.mjs --check` (ok, after regenerating), `node tools/ui-gate.mjs` (ok), and `bash tools/factory-gate.sh all` (all green).
  **Post-fix sync attempt.** `origin/master` advanced again during the fix round (`634693158`, no changes overlapping `plugins/international-press-zone` — verified via `git diff --stat 4586b3cb4..634693158 -- plugins/international-press-zone`, empty). Unlike the earlier pre-first-commit sync, `git rebase FETCH_HEAD` now fails: with real commits already on the branch, rebase replays them as new commit objects without running the pre-commit hook (git rebase's default merge backend does not invoke `pre-commit` per replayed patch), so the replayed commits lack the `reference-transaction` hook's required per-commit trust claim, and the ref update is refused (`IPZ reference transaction: normal checked-out-ref commit is missing its required claim`). A plain `git merge FETCH_HEAD` hits the same wall (merge commits also bypass `pre-commit` by default). Both attempts were cleanly aborted (`git rebase --abort` / `git merge --abort`) with zero working-tree loss (verified via `git stash` before and `git stash pop` after, diff-clean).
  This also explained a second, unrelated `resolve_trusted_install` failure hit while committing the fix: `634693158` itself updated `.dev-config/bin/ipz-remote-php-gate` and `.dev-config/tests/ipz-remote-php-gate.test.sh` (outside `plugins/international-press-zone`, so invisible to the plugin-scoped diff check above), and another worktree session's legitimate commit had already re-synced the shared trusted install (`.git/ipz-remote-gate-hooks/`) to match that newer content, leaving this branch's still-old tracked `.dev-config` blobs mismatched against the active install. Fixed without merging/rebasing branch history: `git checkout FETCH_HEAD -- .dev-config/bin/ipz-remote-php-gate .dev-config/tests/ipz-remote-php-gate.test.sh` pulled in only that already-published infra content (byte-identical to what's already installed and trusted), committed separately as `f9166d743` "Sync trusted pre-commit gate helper from origin/master" (2 files, +80/-30, outside the plugin, required only to make any further commit on this branch possible again — not a change to this slice's scope). `origin/master` continued to advance further (`45ee7ef34`, adding a dormant, unrelated `NavigationTranslationAdapter` under a different `plugins/international-press-zone` subtree) with still zero overlap with the files this slice touches; not chased further since this slice is local-only and explicitly not landing.
  **Committed.** `origin/master` advanced once mid-work (`491fac855`, no changes to `plugins/international-press-zone` — verified via `git diff --stat`). Rebased cleanly (`git stash` → `git fetch` → `git rebase FETCH_HEAD` → `git stash pop`, zero conflicts). The rebase also self-healed a stale trusted pre-commit-hook installation (the shared `ipz-remote-php-gate` helper installed at `.git/ipz-remote-gate-hooks/` did not match the currently staged `.dev-config/bin/ipz-remote-php-gate` blob before the rebase — a cross-worktree install-sync gap, not a defect introduced by this slice; moving `HEAD` via the rebase re-triggered the repo's `reference-transaction` hook, which re-synced the trusted install to match). Final commit: **`1df2ec685`** "Converge Disclosure lifecycle" on branch `worktree-agent-a0f2f148db43bf8bd`. 10 files changed, 343 insertions(+), 22 deletions(-): `Disclosure.js`, `disclosure-lifecycle.test.js`, `admin/dist/js/{runtime.js, 486.75bfd6ad.js (renamed from 486.de1d46df.js), 549.2c6602a7.js (deleted), 549.da6a9cef.js (deleted)}`, `docs/architecture/ui-inventory.report.{json,md}`, this plan, `docs/plans/INDEX.md`. The pre-commit gate ran its full remote PHPCS/PHPStan/slopgate check inline against the staged tree and passed (`IPZ_REMOTE_PHP_GATE_OK` nonce observed; ESLint clean). Post-commit `git fetch origin master` shows no further advance (`git log --oneline HEAD..FETCH_HEAD` empty); working tree is clean.
  **Review-round-2 commits:** `f9166d743` "Sync trusted pre-commit gate helper from origin/master" (infra-only, 2 files, +80/-30, no plugin code) and **`6372b4620`** "Fix Disclosure update(): disabled-freeze, children replace, summaryAttrs apply" (5 files changed, 138 insertions(+), 20 deletions(-): `Disclosure.js`, `disclosure-lifecycle.test.js`, `admin/dist/js/{runtime.js, 486.92f63a9a.js (renamed from 486.75bfd6ad.js)}`, this plan). The remote PHPCS/PHPStan/slopgate gate ran and passed inline (`IPZ_REMOTE_PHP_GATE_OK` nonce observed; ESLint clean). Working tree is clean after both commits.
- **Next executable action:** None within this local-only slice. Landing remains explicitly excluded per the owner's instruction.
