diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 8b1001087..07ffb3ea9 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -188,6 +188,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 58ad75191..49acaa744 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -1031,7 +1031,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 6a7934339..d8d4ba77c 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 f530dd381..609a082e4 100644 --- a/php-transformer/tools/capture-evidence-equivalence/README.md +++ b/php-transformer/tools/capture-evidence-equivalence/README.md @@ -72,6 +72,11 @@ replay by case: the attribute in `46f79246` and `e2e35d0c`. The extra display lookups only move the style-rule candidate cache counters in `2448c3c2`, `2890e8f1`, `448ead36`, `a5a08a86`, `b550beb0`, `c7fff215` and `f025cf28`. +- **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.