From 85ee2658bc2eaf37c150f24f421898531f3608a0 Mon Sep 17 00:00:00 2001 From: Ellen Bauer Date: Tue, 13 Oct 2026 22:37:07 +0700 Subject: [PATCH] fix(php-transformer): keep the source panel box on a projected modal dialog A captured phone menu panel is held twice: the capture's hidden wired copy keeps the panel's id, and the captured state becomes the native dialog under a generated id. On a Wix phone menu that lost two things: - Author rules that name the panel by id (its left offset, width and the --bg-overlay-color its background layer paints with) reached only the hidden copy. The dialog now carries the held id as its editor anchor class, and the author stylesheet projector keeps those id selectors live through that class, as it already does for document variant ids. - The dialog kept the user agent's box geometry (1em padding, a border, auto margins, insets and size caps), which the source div never had. A modal whose source panel positions itself (position fixed or absolute from its own rules) is now marked placement "source", and the block stylesheet resets that geometry under zero-specificity rules so the source rules place it. Canvas paint stays as the backdrop for the panel's own layers. Refs #2726 Co-Authored-By: Claude Opus 5.5 --- php-transformer/composer.json | 1 + .../CapturedDialogProjector.php | 28 +++++- .../Elements/CapturedDialogConverter.php | 22 ++++- .../CapturedDialogBlockGenerator.php | 7 +- .../src/HtmlToBlocks/HtmlCompilation.php | 3 +- .../Style/ScopedAnchorSelectorProjection.php | 3 + .../expected.json | 24 ++--- .../unit/captured-dialog-source-panel-box.php | 89 +++++++++++++++++++ .../capture-evidence-equivalence/README.md | 5 ++ 9 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 php-transformer/tests/unit/captured-dialog-source-panel-box.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 49d97cf82..a2657be36 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -185,6 +185,7 @@ "php tests/unit/responsive-layered-block-margin.php", "php tests/unit/responsive-counterpart-contracts.php", "php tests/unit/captured-dialog-projector.php", + "php tests/unit/captured-dialog-source-panel-box.php", "php tests/unit/captured-dialog-dropdown-panel.php", "php tests/unit/scoped-mixed-dropdown.php", "php tests/unit/capture-runtime-ancestor-parity.php", diff --git a/php-transformer/src/ArtifactCompiler/CapturedDialogProjector.php b/php-transformer/src/ArtifactCompiler/CapturedDialogProjector.php index e4e1d1f03..1ce4cd431 100644 --- a/php-transformer/src/ArtifactCompiler/CapturedDialogProjector.php +++ b/php-transformer/src/ArtifactCompiler/CapturedDialogProjector.php @@ -7,6 +7,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\CapturedDialogBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\NavigationPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom; +use Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker; use DOMDocument; use DOMElement; use DOMNode; @@ -356,7 +357,7 @@ private function prepareGalleries(DOMDocument $document, array $states, string $ } /** - * @param array{nodes:array, class:string, aria_label:string, aria_labelledby:string, aria_describedby:string, has_close_control:bool, self_positioned:bool} $fragment + * @param array{nodes:array, class:string, id:string, aria_label:string, aria_labelledby:string, aria_describedby:string, has_close_control:bool, self_positioned:bool} $fragment * @param array $triggers * @param array $triggerIds */ @@ -377,6 +378,16 @@ private function appendProjectedDialog(DOMDocument $document, array $fragment, a $dialogElement->setAttribute('data-blocks-engine-captured-dialog', 'true'); $dialogElement->setAttribute('data-blocks-engine-triggers', implode(' ', $triggerIds)); if (is_string($fragment['class']) && '' !== $fragment['class']) $dialogElement->setAttribute('class', $fragment['class']); + // The dialog copies a source panel whose id another element of the + // document still holds (the capture's own hidden copy), so it cannot + // take that id. Author rules that name the panel by id (its width, + // offset, paint variables) would then reach only the hidden copy. + // Carry the id as the editor anchor class the author stylesheet + // projector already keeps such id selectors live through. + $sourceId = $fragment['id']; + if ('' !== $sourceId && $sourceId !== $dialogId && $this->documentHoldsId($document, $sourceId)) { + $dialogElement->setAttribute('class', trim($dialogElement->getAttribute('class') . ' ' . EngineMarker::editorAnchorClass($sourceId))); + } // The producer observed whether the source panel was modal or a // dropdown. A dropdown opens without trapping the page and keeps the // source trigger as its toggle. Where it sits follows the evidence: @@ -1100,7 +1111,7 @@ private function insideProjectedDialog(DOMElement $element): bool return false; } - /** @return array{nodes:array, class:string, aria_label:string, aria_labelledby:string, aria_describedby:string, has_close_control:bool, self_positioned:bool}|null */ + /** @return array{nodes:array, class:string, id:string, aria_label:string, aria_labelledby:string, aria_describedby:string, has_close_control:bool, self_positioned:bool}|null */ private function safeDialogFragment(string $html): ?array { $previous = libxml_use_internal_errors(true); @@ -1153,6 +1164,7 @@ private function safeDialogFragment(string $html): ?array return array( 'nodes' => iterator_to_array($container->childNodes), 'class' => $sourceRoot instanceof DOMElement ? trim($sourceRoot->getAttribute('class')) : '', + 'id' => $sourceRoot instanceof DOMElement ? SourceDom::safeAnchor($sourceRoot->getAttribute('id')) : '', 'aria_label' => $sourceRoot instanceof DOMElement ? trim($sourceRoot->getAttribute('aria-label')) : '', 'aria_labelledby' => $sourceRoot instanceof DOMElement ? trim($sourceRoot->getAttribute('aria-labelledby')) : '', 'aria_describedby' => $sourceRoot instanceof DOMElement ? trim($sourceRoot->getAttribute('aria-describedby')) : '', @@ -1164,6 +1176,18 @@ private function safeDialogFragment(string $html): ?array ); } + /** + * Whether a source element still holds the id. The capture's own panel + * envelope does not count: its id is the wiring key, not the source + * panel's identity, and no author rule names it. + */ + private function documentHoldsId(DOMDocument $document, string $id): bool + { + $matches = (new DOMXPath($document))->query('//*[@id=' . $this->xpathLiteral($id) . '][not(@' . CaptureEvidence::DIALOG_PANEL . ')]'); + + return $matches instanceof \DOMNodeList && 0 < $matches->length; + } + /** @param array> $files @return array|null */ private function jsonFile(array $files, string $path): ?array { diff --git a/php-transformer/src/HtmlToBlocks/Elements/CapturedDialogConverter.php b/php-transformer/src/HtmlToBlocks/Elements/CapturedDialogConverter.php index ce966b31b..63f37d197 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/CapturedDialogConverter.php +++ b/php-transformer/src/HtmlToBlocks/Elements/CapturedDialogConverter.php @@ -17,10 +17,12 @@ final class CapturedDialogConverter implements ElementConverter { /** * @param Closure(DOMElement, array>&): array> $convertChildren + * @param (Closure(DOMElement): string)|null $resolvedStyle The element's authored, cascade-resolved declarations. */ public function __construct( private readonly HtmlTransformerSession $session, - private readonly Closure $convertChildren + private readonly Closure $convertChildren, + private readonly ?Closure $resolvedStyle = null ) { } @@ -54,6 +56,22 @@ private function isSupersededByNativeNavigation(DOMElement $dialog): bool return false; } + /** + * A modal whose source panel positions itself (`position:fixed` or + * `absolute` from its own author rules) drew its own box: the source panel + * was not a ``, so it had no user agent padding, border, auto + * margins, insets or size caps. Mark it so the block stylesheet drops that + * geometry and the source rules alone place the panel. + */ + private function modalPlacement(DOMElement $element): string + { + if ( 'dialog' !== strtolower($element->tagName) || '' !== SourceDom::attr($element, 'data-blocks-engine-presentation') || null === $this->resolvedStyle ) { + return ''; + } + + return 1 === preg_match('/(?:^|;)\s*position\s*:\s*(?:fixed|absolute)\b/i', ($this->resolvedStyle)($element)) ? 'source' : ''; + } + /** * @param array> $fallbacks * @return array @@ -76,7 +94,7 @@ public function block(DOMElement $element, array &$fallbacks): array 'ariaDescribedby' => trim(SourceDom::attr($element, 'aria-describedby')), 'className' => trim(SourceDom::attr($element, 'class')), 'presentation' => 'dropdown' === SourceDom::attr($element, 'data-blocks-engine-presentation') ? 'dropdown' : '', - 'placement' => in_array(SourceDom::attr($element, 'data-blocks-engine-placement'), array('in-place', 'under-header', 'source'), true) ? SourceDom::attr($element, 'data-blocks-engine-placement') : '', + 'placement' => in_array(SourceDom::attr($element, 'data-blocks-engine-placement'), array('in-place', 'under-header', 'source'), true) ? SourceDom::attr($element, 'data-blocks-engine-placement') : $this->modalPlacement($element), 'addCloseButton' => 'true' === SourceDom::attr($element, 'data-blocks-engine-add-close'), 'gallerySelection' => json_decode(SourceDom::attr($element, 'data-blocks-engine-gallery-selection'), true) ?: array(), 'ancestorState' => CapturedDialogBlockGenerator::ancestorState(json_decode(SourceDom::attr($element, 'data-blocks-engine-ancestor-state'), true)), diff --git a/php-transformer/src/HtmlToBlocks/Generators/CapturedDialogBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/CapturedDialogBlockGenerator.php index 29d8a5cd4..4ffb29786 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/CapturedDialogBlockGenerator.php +++ b/php-transformer/src/HtmlToBlocks/Generators/CapturedDialogBlockGenerator.php @@ -298,10 +298,15 @@ function mountAll() { document.querySelectorAll( 'dialog[data-blocks-engine-trig // rules have no specificity, so the source classes still win. // A dropdown kept at its observed source place flows there like the // source panel did: only the user agent's dialog box is reset. + // A modal whose source panel positions itself keeps the source's own + // placement: the user agent's box geometry (padding, border, auto + // margins, insets and size caps) is reset under its rules. Canvas + // paint stays as the backdrop for panel layers that paint over it. $style = 'dialog[data-blocks-engine-triggers]:not([open]){display:none!important}' . 'div[data-blocks-engine-presentation="dropdown"][data-blocks-engine-triggers]:not([open]){display:none!important}' . ':where(dialog[data-blocks-engine-presentation="dropdown"][data-blocks-engine-placement="under-header"]){position:fixed;top:var(--blocks-engine-dropdown-top,0px);left:0;width:100%;max-width:none;max-height:calc(100vh - var(--blocks-engine-dropdown-top,0px));margin:0;overflow-y:auto;z-index:var(--blocks-engine-dropdown-layer,auto);background-color:var(--blocks-engine-dropdown-background,Canvas);-webkit-backdrop-filter:var(--blocks-engine-dropdown-backdrop,none);backdrop-filter:var(--blocks-engine-dropdown-backdrop,none);color:inherit}' - . ':where(dialog[data-blocks-engine-presentation="dropdown"][data-blocks-engine-placement="in-place"]){position:static;inset:auto;width:auto;height:auto;max-width:none;max-height:none;margin:0;padding:0;border-width:0;background-color:transparent;color:inherit;overflow:visible}'; + . ':where(dialog[data-blocks-engine-presentation="dropdown"][data-blocks-engine-placement="in-place"]){position:static;inset:auto;width:auto;height:auto;max-width:none;max-height:none;margin:0;padding:0;border-width:0;background-color:transparent;color:inherit;overflow:visible}' + . ':where(dialog:not([data-blocks-engine-presentation])[data-blocks-engine-placement="source"]){inset:auto;max-width:none;max-height:none;margin:0;padding:0;border-width:0}'; return array( 'name' => self::LOCAL_NAME, diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 69a494b7e..e8059b7f6 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -1023,7 +1023,8 @@ function (DOMElement $element, array &$fallbacks): ?array { $this->session, function (DOMElement $element, array &$fallbacks) use ($convertChildren): array { return $convertChildren($element, $fallbacks, true); - } + }, + fn (DOMElement $element): string => $this->styleResolver->specificityResolvedPresentationStyle($element) ); $this->wrapperCoalescer = new WrapperCoalescer( $this->sourceElementClassifier, diff --git a/php-transformer/src/HtmlToBlocks/Style/ScopedAnchorSelectorProjection.php b/php-transformer/src/HtmlToBlocks/Style/ScopedAnchorSelectorProjection.php index 32d32e14f..d9567705a 100644 --- a/php-transformer/src/HtmlToBlocks/Style/ScopedAnchorSelectorProjection.php +++ b/php-transformer/src/HtmlToBlocks/Style/ScopedAnchorSelectorProjection.php @@ -17,6 +17,9 @@ public static function identities(AuthorStyleAnalysis $source): array foreach ($source->sourceElementIds() as $id) { $id = (string) $id; if ('' === SourceDom::safeAnchor($id)) continue; + // A projected captured dialog copies an element whose id stays on + // that hidden copy; the dialog carries the id as its anchor class. + if (array() !== $source->sourceElementsByClass(EngineMarker::editorAnchorClass($id))) { $ids[$id] = true; continue; } $elements = $source->sourceElementsById($id); if (count($elements) < 2) continue; foreach ($elements as $element) { diff --git a/php-transformer/tests/fixtures/capture-evidence-equivalence/expected.json b/php-transformer/tests/fixtures/capture-evidence-equivalence/expected.json index 100c50af6..f4c74bdcd 100644 --- a/php-transformer/tests/fixtures/capture-evidence-equivalence/expected.json +++ b/php-transformer/tests/fixtures/capture-evidence-equivalence/expected.json @@ -83,7 +83,7 @@ { "id": "0a9c1bea54021ba60093b14cc124cefca0e60616", "entry": "ArtifactCompiler::compile", - "expected_sha256": "011a8609d40356a62eced84729322d578e5826a1df060ade41004e98c59aedc0", + "expected_sha256": "a4b5a631f9867987cb18ce03a825ad9cb56a018d2819a88de96527192e25e992", "outcome": "output" }, { @@ -143,7 +143,7 @@ { "id": "15082c2dc6a6f26471c6b1b16a41446c0c3ed22a", "entry": "ArtifactCompiler::compile", - "expected_sha256": "2c55d1a448a18ca91b31ad842a0895c4873a7ea69aaee4590329d3fbd9703dbd", + "expected_sha256": "5caf57647fc1a20efcb6857446b24d39846d2a908f79ff7542c25238976482f5", "outcome": "output" }, { @@ -245,7 +245,7 @@ { "id": "21e5cdbf5ec5e6081029d84c0c34ccac47171153", "entry": "ArtifactCompiler::compile", - "expected_sha256": "7bdc637e0ac9c24bca6331d62f4eb2ab299a59baf0701fc2be91b3afb70c537b", + "expected_sha256": "fb9786813c92f8ac79f6110c4a8b16cde5d7ca93ed1f60df258c7580dcfaa617", "outcome": "output" }, { @@ -341,7 +341,7 @@ { "id": "2915d21f717a45ad97cc901e84ead8debcb5e07b", "entry": "ArtifactCompiler::compile", - "expected_sha256": "0f6cd685ea08990e95421490ebf96d0c302848d03650578c60693e3714d066d4", + "expected_sha256": "3bbd431f4618ff388971fd7062932e4ef9ec2736f34bc15677f7161567ba8b18", "outcome": "output" }, { @@ -437,7 +437,7 @@ { "id": "37284b0ef56612a5ff0cda0976e9baf91af033cb", "entry": "ArtifactCompiler::compile", - "expected_sha256": "2103fa98642d8db5447e68d5a2642d27165f74f20e511e9120b958b23bf9bd0e", + "expected_sha256": "f1e057f69529822b43b9a6d8248f1571a80175c704ce8755ba8c2ab832e9bde1", "outcome": "output" }, { @@ -653,7 +653,7 @@ { "id": "55b724ac2c76d2fde1959aeee3370f76b3d8129d", "entry": "ArtifactCompiler::compile", - "expected_sha256": "60ac73a9c8f5740223f04e57fc8702354a374ff301a3c969daea8538b0823d5a", + "expected_sha256": "3d168b155493f0b6bbb643e1bcfe25fe645b8b4ff1e285034798a7077b5af83f", "outcome": "output" }, { @@ -1019,7 +1019,7 @@ { "id": "7a7af8982960e28f4cf964953c85b58a510699e0", "entry": "HtmlTransformer::transform", - "expected_sha256": "d7a21036360daea3b22782bbff2274b410450f5e3ec0342901d0c13691532b4f", + "expected_sha256": "ce5d6431831c016aad5641f3baf9bf9c703dfc848a4d73c1a02036ef14ce933c", "outcome": "output" }, { @@ -1043,7 +1043,7 @@ { "id": "7c2d1217f68f1eeed57e660c70936247d937c414", "entry": "ArtifactCompiler::compile", - "expected_sha256": "aff5b81437d0f8acbd64bd33825fbd05d7e2d4c51213f1817df47d5a083f4a8a", + "expected_sha256": "82fbfff9b3531804790645d27f7588504498728ee69af0dea5f10426e977b1e1", "outcome": "output" }, { @@ -1241,7 +1241,7 @@ { "id": "927aa36875da93af28e278f37359f0c3fbbee7d8", "entry": "ArtifactCompiler::compile", - "expected_sha256": "803cea008023246f4a4086946263d6cf62b3a79531740656e851f9a1917a01ca", + "expected_sha256": "1683437b9d2eb2289844a1fdece79f215cd4a80050558e6d8be39c826d3811c3", "outcome": "output" }, { @@ -1259,7 +1259,7 @@ { "id": "944dd63db631afc5664d39d5f49658f016e89e27", "entry": "ArtifactCompiler::compile", - "expected_sha256": "5a2c5d37daa05c773bab41b6df4ddf59293242bc8ddda3ae04f23616b77959f3", + "expected_sha256": "6388768329781c3053f604325298a120e62e85d9d1e54e8dd1d1d1bf6504d0ad", "outcome": "output" }, { @@ -1793,7 +1793,7 @@ { "id": "df9c24a89732ac84a5fb55a4fd84cb89492722c3", "entry": "ArtifactCompiler::compile", - "expected_sha256": "160ab577d01f47b915fd211a2db22cfd6c1d81eec701818206f92dd5483fa93c", + "expected_sha256": "0e64f64ffc6891e29c60d401a765f44e846ce4bece7c0e23db006ad2b3d1dfe0", "outcome": "output" }, { @@ -1841,7 +1841,7 @@ { "id": "e5ea57430e2ac12b29b8351684acc9fde5ba6431", "entry": "ArtifactCompiler::compile", - "expected_sha256": "5738d685415c7655edcf23f8c7d9e6854120b630e27bb2d8031e6dd996473119", + "expected_sha256": "248d9110c659b2bc4572ef5ded089d7502d350081457cde10f3ae458e69fe5f3", "outcome": "output" }, { diff --git a/php-transformer/tests/unit/captured-dialog-source-panel-box.php b/php-transformer/tests/unit/captured-dialog-source-panel-box.php new file mode 100644 index 000000000..94455a93f --- /dev/null +++ b/php-transformer/tests/unit/captured-dialog-source-panel-box.php @@ -0,0 +1,89 @@ +
'; +$files = static function (string $wiredCopy) use ($panel): array { + $html = '' + . '
Home' + . $wiredCopy . '

Body

'; + $state = array( + 'status' => 'captured', + 'trigger' => array('selector' => '#menu', 'tag' => 'button', 'label' => 'Open menu', 'ariaHaspopup' => 'dialog', 'dataBindings' => array()), + 'dialog' => array('selector' => '#panel', 'tag' => 'div', 'role' => 'dialog', 'presentation' => 'modal', 'html' => $panel, 'htmlBytes' => strlen($panel), 'htmlTruncated' => false), + ); + + return array( + array('path' => 'website/index.html', 'content' => $html), + array('path' => 'capture-receipt.json', 'content' => json_encode(array('schema' => 'blocks-engine-capture/capture-receipt/v1', 'routes' => array(array('url' => 'https://example.test/', 'path' => 'website/index.html'))), JSON_UNESCAPED_SLASHES)), + array('path' => 'interaction-states.json', 'content' => json_encode(array('schema' => 'blocks-engine-capture/captured-interactions/v1', 'pages' => array(array('sourceUrl' => 'https://example.test/', 'states' => array($state)))), JSON_UNESCAPED_SLASHES)), + ); +}; +$wired = ''; + +// Projection: the dialog carries the held id as its editor anchor class. +$projected = (string) ((new CapturedDialogProjector())->project($files($wired))['files'][0]['content'] ?? ''); +$assert(1 === preg_match('/]*class="[^"]*\bblocks-engine-editor-anchor-panel\b/', $projected), 'a dialog whose source id another copy holds carries it as its anchor class', $projected); +$free = (string) ((new CapturedDialogProjector())->project($files(''))['files'][0]['content'] ?? ''); +$assert(! str_contains($free, 'blocks-engine-editor-anchor-panel'), 'a dialog whose source id no element holds gets no anchor class', $free); + +// Compilation: the id rules reach the dialog and the modal keeps the source box. +$artifact = array('entrypoint' => 'website/index.html', 'files' => $files($wired)); +$compiler = new ArtifactCompiler(); +$shared = $compiler->prepareShared($artifact); +$result = $compiler->compose($shared, $compiler->compilePreparedPages($shared, $compiler->preparePages($artifact, $shared)))->toArray(); +$plan = $result['source_reports']['wordpress_site_plan'] ?? array(); +$markup = implode('', array_column($plan['pages'] ?? array(), 'canonical_block_markup')); +foreach ( $plan['template_parts'] ?? array() as $part ) { + $markup .= (string) ($part['content'] ?? $part['canonical_block_markup'] ?? ''); +} +$css = json_encode($result, JSON_UNESCAPED_SLASHES) ?: ''; +$assert(1 === preg_match('/:is\(#panel,\.blocks-engine-editor-anchor-panel\)/', stripslashes($css)), 'the id rule keeps the anchor class alternative', ''); +$assert(str_contains(stripslashes($css), 'data-blocks-engine-placement="source"'), 'the dialog is marked as placing itself', ''); + +$style = (string) ((new CapturedDialogBlockGenerator())->definition('test/captured-dialog')['assets']['style.css'] ?? ''); +$assert( + str_contains($style, ':where(dialog:not([data-blocks-engine-presentation])[data-blocks-engine-placement="source"]){inset:auto;max-width:none;max-height:none;margin:0;padding:0;border-width:0}'), + 'a self-placed modal drops the user agent box geometry under zero-specificity rules', + $style +); + +if ( 0 < $failures ) { + fwrite(STDERR, "Captured dialog source panel box: {$failures} failed, {$passes} passed\n"); + exit(1); +} + +echo "Captured dialog source panel box passed: {$passes} assertions\n"; diff --git a/php-transformer/tools/capture-evidence-equivalence/README.md b/php-transformer/tools/capture-evidence-equivalence/README.md index 61da2d0c9..21a9447f5 100644 --- a/php-transformer/tools/capture-evidence-equivalence/README.md +++ b/php-transformer/tools/capture-evidence-equivalence/README.md @@ -53,6 +53,11 @@ replay by case: hashes and sizes derived from it change in `0a9c1bea`, `15082c2d`, `21e5cdbf`, `2915d21f`, `37284b0e`, `55b724ac`, `7a7af898`, `7c2d1217`, `927aa368`, `944dd63d`, `df9c24a8`, `e5ea5743`. +- **Self-placed modal box reset** (#2726). The captured-dialog block + stylesheet gains the zero-specificity reset for a modal whose source panel + positions itself. Only the emitted `style.css` text and the hashes derived + from it change again in the twelve native dialog cases above (`7a7af898` + also gains one style-rule candidate cache hit from the position lookup). When regenerating from the pre-move commit, re-record these cases from the checkout that introduced them, and only after checking the difference again.