Skip to content

fix(php-transformer): reproduce the captured disclosure runtime faithfully in native dialogs - #2735

Merged
chubes4 merged 1 commit into
trunkfrom
fix/native-dialog-fidelity
Oct 13, 2026
Merged

chubes4 merged 1 commit into
trunkfrom
fix/native-dialog-fidelity

Conversation

@chubes4

@chubes4 chubes4 commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Refs Automattic/data-liberation-agent#787 (follow-up). Refs #2729.

Summary

#2729 listed three existing gaps in how native replacement reproduces the capture's disclosure runtime ("Known gaps"). This PR closes all three:

  1. Native dialogs now replay aria-* and data-* ancestor bindings.
  2. A text-labelled trigger keeps its native dialog instead of being turned into a lossy core/details.
  3. Every runtime retirement path checks the published digest.

1. aria-* / data-* ancestor bindings

Root cause. CapturedDialogProjector::triggerAncestorState() kept only rows whose attributes were class, style or hidden. The producer records class|style|hidden|aria-*|data-*, and DISCLOSURE_RUNTIME.applyAncestors replays every recorded attribute. A row like {class, data-section-theme} was therefore dropped as a whole.

What the runtime does, and what the native dialog now does. For hidden, aria-* and data-* the runtime calls setAttribute(name, value), or removeAttribute(name) when the recorded value is null. It does this with the opened value on open and the closed value on close. It also applies the closed value on load (ready() → apply() → applyAncestors(trigger, false)). The native view script now does the same:

  • replayAttributes() sets or removes the attribute on open, on close and Escape, and at mount.
  • The mount replay matters in practice. Block conversion drops some source attributes, for example data-section-theme on a header that becomes core/group. The runtime restores them on load, and now the native dialog does too.
  • An emptied class whose recorded value is null is now removed. This is the small difference fix(php-transformer): recognize the current capture disclosure and gallery runtimes #2729 noted.
  • The ancestor is found by tag plus closed class tokens, as before. Among those candidates, it prefers the one whose other recorded attributes hold their closed values, so a row with no class finds the right div. Each trigger resolves its ancestors once, at mount.

Attribute allowlist. CapturedDialogBlockGenerator::ANCESTOR_ATTRIBUTE_PATTERN = ^(?:class|style|hidden|aria-[a-z0-9-]+|data-(?!blocks-engine-)[a-z0-9_.:-]+)$. The same constant is used by the projector, CapturedDialogConverter and the view script (as a RegExp), through one sanitizer, CapturedDialogBlockGenerator::ancestorState().

  • It matches the producer. It is DLA's DROPDOWN_ANCESTOR_ATTRIBUTE_PATTERN, and the runtime replays only what was recorded. So this is exactly the set the runtime can toggle.
  • No handlers or URL attributes. on*, href, src, srcset, action and similar never match, because the producer never records them and the runtime never sets them. No URL policy is needed.
  • The engine namespace is excluded. data-blocks-engine-* holds the native dialog's own state (data-blocks-engine-triggers, data-blocks-engine-mounted, ...) and capture evidence. Replaying it would let input rewire the native runtime. This plays the role the producer's (?!lib-|dla-) exclusion played for its own former namespaces.
  • Defense in depth. The converter used to pass data-blocks-engine-ancestor-state through unfiltered. It now uses the same sanitizer, and the view script checks names again before calling setAttribute.
  • Rows outside the allowlist are dropped, as before. Existing bounds are kept: 8 rows, 2 KiB values, and now at most 32 attributes per side, which is the producer's own limit.

Proof. The new capture-runtime-attribute-parity{.php,-browser.mjs} drives the published runtime and the native dialog through initial → open → close → reopen → Escape → open again. Its rows are attribute-only: header data-section-theme, scope aria-expanded null→true and data-state, and body data-menu null→open.

  • After every step it compares each recorded attribute, the trigger's aria-expanded and the header paint that is keyed on the attribute.
  • It also asserts that no other ancestor received a recorded attribute.
  • On trunk (with the gap 2 fix applied, to isolate gap 1) it fails at initial: data-section-theme is null natively and dark in the source. The PHP half fails on trunk because the rows don't reach the dialog.
  • It is added to the visual-parity job.

2. Text-labelled triggers becoming core/details

Root cause. The projector replaces the panel with a native dialog (data-blocks-engine-captured-dialog="true") and retires the runtime. During conversion, the trigger and that dialog have the shape of a source aria disclosure. DetailsPattern::matchDisclosure runs on the parent and claims them before CapturedDialogConverter sees the dialog. An icon-only trigger escaped only because its summary text was empty.

Decision rule (now in docs/contracts/capture-evidence.md):

  • A projected captured dialog always stays a native dialog block.
  • The retired runtime closes on Escape, keeps aria-expanded and replays ancestor bindings. core/details expresses none of these, so it would lose something even when no ancestor binding is present.
  • core/details is still chosen for disclosures that no capture runtime drives (source aria disclosures, native <details>), where it loses nothing.

Proof. The new captured-dialog-text-trigger.php covers three cases:

  • A text trigger with an ancestor binding becomes a native dialog that carries the binding.
  • A text trigger with no binding also becomes a native dialog.
  • An uncaptured aria disclosure still becomes core/details.

The first two fail on trunk. The browser parity test above also uses a text trigger.

3. Runtime retirement without a digest check

Root cause. Only canReplaceScopedDisclosure (wired dropdowns in a declared document scope) compared digests. removeDisclosureRuntime() removed any marked script on three paths:

  • navigation submenu, with or without an interaction report;
  • native dialogs projected outside a document scope, a path the issue did not list;
  • the gallery loop, which checked only the marker.

Fix.

  • One helper. CapturedDialogProjector::isRecognizedRuntimeScript($script, $marker) maps each marker to its CaptureEvidence::*_RUNTIME_SHA256. Every check and every removal goes through it: canReplaceScopedDisclosure, and retireRecognizedRuntime(), which replaces removeDisclosureRuntime() and the gallery loop.
  • Page-level rule. If a page carries a marked script whose body is not recognized, that runtime keeps running. Its wiring is therefore left untouched too: no projection, no retirement (projectPage, retireOwnedDisclosureWithoutProjection). Checking only at removal would leave a kept runtime toggling hidden on a native <dialog> that the view script also drives.
  • The docs' "How Blocks Engine verifies them" section now describes all paths.

Proof. Each test checks a published body and the same body with one byte changed. The altered-body assertions fail on trunk.

  • captured-navigation-runtime.php: the published helper replaces a stand-in body. The altered body is kept on all three navigation paths (observed states, no observed states, no interaction report), in both whole and staged compilation.
  • navigation-button-dropdown-submenu.php: the menu-only capture keeps an altered body.
  • captured-dialog-projector.php: unscoped close-helper and wired-panel projection with the published body. With an altered body the page is left byte-identical, with no replacement claim.
  • captured-gallery-contract.php and head-script-retirement: the published gallery body is retired with a proof that carries its digest. An altered body ships with the page and gets no native lightbox.

Equivalence gate

The gate passes 349/349. 36 expectations are re-recorded deliberately. For each one, I compared this branch's normalized output with trunk's, and replayed with this branch's PHP plus trunk's view script to separate the two causes. The 36 case IDs are listed in tools/capture-evidence-equivalence/README.md, which also explains how to regenerate them. Gap 2 changes no corpus case.

  • 24 cases: unrecognized runtime body (gap 3). All 24 corpus inputs carry short stand-in bodies (window.captureGallery=true;, document.querySelectorAll(…)), not the published ones. Their pages are now left as captured.
    • 7 navigation-submenu retirements of a stand-in disclosure body (07f8a0bd 1e87fd71 28e1a0f1 2f545afa 3e283668 647b8b53 bab702ca): the stand-in is no longer retired.
    • 4 unscoped native-dialog projections plus retirement (3317930e 461f0a83 5eccb541 f67e5ca6): the wiring and runtime are kept and no dialog is projected.
    • 7 gallery projections plus helper retirement (0bf7a2ea 12d3ff94 3b0aed43 62d3b1f8 8d155e3b 90115e60 bcd4e29b): the gallery is kept as captured.
    • 6 cases whose trunk output differed only by per-state projector warnings (captured_dialog_trigger_unmatched, captured_dialog_unsafe_or_truncated; 6cdada86 6ed86c41 b913b3b4 eaad29ef fcc75319 feef8efc). The page is skipped, so those warnings are no longer emitted.
  • 12 cases: view script text only (gap 1). These cases emit the captured-dialog block definition. After masking the view script and the hashes and byte sizes derived from it, their outputs are identical to trunk's: 0a9c1bea 15082c2d 21e5cdbf 2915d21f 37284b0e 55b724ac 7a7af898 7c2d1217 927aa368 944dd63d df9c24a8 e5ea5743.

The gate's success line now says that the expectations include these deliberate changes. The throw-classification guard is unchanged: 623/623 throw sites classified, and no throw sites were added or moved.

Not changed (observed)

  • Retirement still checks only the marker for data-blocks-engine-capture-collection-runtime and data-blocks-engine-capture-local-disclosure-runtime (CapturedCollectionProjector). Neither has a published body or digest yet.
  • style rows are still replayed per declaration, while the runtime replaces the whole style attribute.
  • Two dialogs bound to the same ancestor attribute: the runtime's close-time hand-off to another open owner is not reproduced.
  • A text trigger lowered to core/button keeps its id on the wrapper. The view script drives the inner button's aria-expanded, but aria-controls and aria-haspopup are not carried.

Verification (local, macOS, PHP 8.5)

  • composer install, composer validate --strict, composer lint (PHPStan clean) and composer test:lint: pass.
  • composer test: pass, including the equivalence gate (349/349), the throw-classification guard and both new unit tests.
  • Repo root php tests/contract/production-acceptance-matrix.php: pass.
  • Visual-parity job: npm ci, npm run lint and npm test (with pre/posttest) pass, as does every browser step in the job, including the new attribute-parity step.
  • layout-media-tracks, default and responsive, passed against the local WordPress 7.1 install instead of the 7.0.4 tarball CI downloads, because I used local fixtures only.
  • Each new or changed assertion was also run against an origin/trunk checkout and fails there as described above.
  • Not run locally: the WordPress site plan integration (MySQL) and Docker stylesheet-activation jobs. CI runs them.

AI disclosure: written by Claude in OpenCode with Chris Huber (direct implementation authorized by the operator); Claude implemented, tested and opened this PR.

…fully in native dialogs

- Carry aria-* and data-* ancestor bindings to the native dialog and replay
  them like the disclosure runtime (set or remove, closed state on load),
  through one shared attribute allowlist.
- Keep a projected captured dialog as a native dialog block instead of
  core/details, which cannot express Escape, aria-expanded or ancestor
  bindings.
- Verify the published digest on every runtime retirement path (navigation
  submenu, gallery helper, projected dialogs); a page carrying an
  unrecognized runtime body keeps that runtime and its wiring.
- Re-record the equivalence cases these changes affect and document them.
@chubes4
chubes4 merged commit ecb831f into trunk Oct 13, 2026
11 checks passed
@chubes4
chubes4 deleted the fix/native-dialog-fidelity branch October 13, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant