From 4025b76cb59ba7e488528ec399a77be381b07bcf Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 06:56:05 -0400 Subject: [PATCH 1/9] fix(transformer): preserve flex anchor button classes [AI: OpenAI GPT-5.6 Terra via OpenCode] --- .../src/HtmlToBlocks/BlockFactory.php | 5 ++- .../src/HtmlToBlocks/HtmlTransformer.php | 8 +++- .../HtmlToBlocks/Patterns/ButtonsPattern.php | 42 +++++++++++++++---- php-transformer/tests/contract/run.php | 11 +++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index 3e3fdab7..97af0adf 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -218,6 +218,9 @@ private function normalizeClassNameAttr(array $attrs): array private function commentAttrs(string $name, array $attrs): array { unset($attrs['inlineGeometryStyle']); + if ( 'core/button' === $name ) { + unset($attrs['linkClass']); + } if ( 'core/paragraph' === $name && preg_match('/^\s* $this->mergeClassNames('wp-block-button__link', $support['classes'], 'wp-element-button'), + 'class' => $this->mergeClassNames('wp-block-button__link', $support['classes'], (string) ($attrs['linkClass'] ?? ''), 'wp-element-button'), 'style' => $support['style'], 'title' => (string) ($attrs['title'] ?? ''), ); diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 265ae819..42f807f4 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -1545,7 +1545,7 @@ function (DOMElement $element) use ($shellTags): string { $hasNonProjected = false; foreach ( $matches as $element ) { $path = $element->getNodePath() ?? ''; - if ( $this->requiresStandaloneInlineLayoutLeaf($element) ) { + if ( $this->requiresStandaloneInlineLayoutLeaf($element) && ! $this->isDirectChildOfLoweredAuthorControl($element) ) { $inlineLayoutCarriers = true; } elseif ( isset($this->sourceControlMarkers[$path]) ) { $controls[] = $this->sourceControlMarkers[$path]; @@ -3969,6 +3969,12 @@ private function isDirectChildOfStructuralLayout(DOMElement $element): bool return $element->parentNode instanceof DOMElement && $this->isStructuralLayoutElement($element->parentNode); } + private function isDirectChildOfLoweredAuthorControl(DOMElement $element): bool + { + return $element->parentNode instanceof DOMElement + && isset($this->sourceControlPaths[$element->parentNode->getNodePath() ?? '']); + } + private function requiresStandaloneInlineLayoutLeaf(DOMElement $element): bool { if ( ! $this->isInlineContentElement(strtolower($element->tagName)) diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php index 13a6a68a..405f15d2 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php @@ -131,19 +131,18 @@ private function buttonBlockFromAnchor(DOMElement $anchor, callable $presentatio $attrs['style']['border']['radius'] = '0'; } } - // The canonical core/button wrapper is structural. A source control's - // classes would otherwise let an unprojected stylesheet paint that outer - // div instead of the link that Gutenberg actually renders as the button. - if ( $hasAuthoredStyleRules && ($presentationElement === $anchor || $presentationElement->parentNode === $anchor) ) { + $preserveSourceClasses = $hasAuthoredStyleRules && $this->hasFlexLayout($resolvedPresentation) && $this->hasClassedDescendant($anchor); + if ( $hasAuthoredStyleRules && ($presentationElement === $anchor || $presentationElement->parentNode === $anchor) && ! $preserveSourceClasses ) { $this->removeSourceControlClasses($attrs, $presentationElement); } - $text = $this->buttonText($anchor, $innerHtml($anchor), $materializeSvgImages); + $sourceClasses = $preserveSourceClasses ? $this->retainedSourceClasses($anchor, $attrs) : ''; return $createBlock('core/button', array_filter(array_merge($attrs, array( - 'text' => $text, - 'url' => $attr($anchor, 'href'), - 'title' => $this->buttonAccessibleTitle($anchor, $text), + 'text' => $text, + 'url' => $attr($anchor, 'href'), + 'title' => $this->buttonAccessibleTitle($anchor, $text), + 'linkClass' => $sourceClasses, )), static fn ($value): bool => is_array($value) ? array() !== $value : '' !== $value), array(), $presentationElement, $anchor); } @@ -404,6 +403,33 @@ private function hasRuntimeBehaviorSignal(DOMElement $element): bool return false; } + /** @param array $attrs */ + private function retainedSourceClasses(DOMElement $element, array $attrs): string + { + $sourceClasses = preg_split('/\s+/', trim($element->getAttribute('class'))) ?: array(); + $retainedClasses = preg_split('/\s+/', trim((string) ($attrs['className'] ?? ''))) ?: array(); + return $this->mergeClassNames(...array_values(array_filter( + $sourceClasses, + static fn (string $class): bool => in_array($class, $retainedClasses, true) + ))); + } + + private function hasFlexLayout(string $style): bool + { + return preg_match('/(?:^|;)\s*display\s*:\s*(?:inline-)?flex\b/i', $style) === 1; + } + + private function hasClassedDescendant(DOMElement $element): bool + { + foreach ( $element->getElementsByTagName('*') as $descendant ) { + if ( $descendant instanceof DOMElement && '' !== trim($descendant->getAttribute('class')) ) { + return true; + } + } + + return false; + } + /** @param array $attrs */ private function removeSourceControlClasses(array &$attrs, DOMElement $element): void { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index c3453966..241f8d2b 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -856,6 +856,17 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(str_contains($descendantSurfaceButtonCss, '> :where(.wp-block-button__link)') && str_contains($descendantSurfaceButtonCss, 'min-width:170px') && str_contains($descendantSurfaceButtonCss, 'padding:22px 26px'), 'composite button descendant selectors project their complete painted geometry onto the native link'); $assert('pass' === ($descendantSurfaceButton['source_reports']['wp_block_validity']['status'] ?? ''), 'composite button surface conversion remains editor-valid'); +$flexAnchorButton = ( new HtmlTransformer() )->transform( + '
Product$25
' +)->toArray(); +$flexAnchorButtonAttrs = $flexAnchorButton['blocks'][0]['innerBlocks'][0]['innerBlocks'][0]['attrs'] ?? array(); +$flexAnchorButtonMarkup = (string) ($flexAnchorButton['serialized_blocks'] ?? ''); +$flexAnchorButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $flexAnchorButton['assets'] ?? array())); +$assert(str_contains((string) ($flexAnchorButtonAttrs['className'] ?? ''), 'product-row'), 'styled anchor button preserves its safe source class in core/button attributes'); +$assert(str_contains($flexAnchorButtonMarkup, ' :where(.wp-block-button__link){display:flex;align-items:center;gap:1rem') && str_contains($flexAnchorButtonCss, 'blocks-engine-richtext-marker') && str_contains($flexAnchorButtonCss, '{flex:1}'), 'styled anchor button retains matched flex and child selector applicability after lowering'); +$assert('pass' === ($flexAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled anchor button remains editor-valid with composed source classes'); + $contextualSurfaceButton = ( new HtmlTransformer() )->transform( '
Learn more
' )->toArray(); From b96fb15dcb282791b307d20632dfb146bb776300 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 07:11:10 -0400 Subject: [PATCH 2/9] fix(transformer): keep flex button classes save-compatible [AI: OpenAI GPT-5.6 Terra via OpenCode] --- .../wp/__tests__/button-save-validity.test.ts | 41 +++++++++++++++++++ .../src/HtmlToBlocks/BlockFactory.php | 5 +-- .../HtmlToBlocks/Patterns/ButtonsPattern.php | 19 ++------- php-transformer/tests/contract/run.php | 2 +- 4 files changed, 46 insertions(+), 21 deletions(-) create mode 100644 packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts diff --git a/packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts b/packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts new file mode 100644 index 00000000..57d9d3c1 --- /dev/null +++ b/packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts @@ -0,0 +1,41 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import { setupDomGlobals } from '../dom-globals.js'; + +const require = createRequire(import.meta.url); + +type WpRuntime = { + registerCoreBlocks(): void; + createBlock(name: string, attributes: Record): unknown; + serialize(blocks: unknown[]): string; + parse(markup: string): unknown[]; + validateBlock(block: unknown): [boolean, unknown[]]; +}; + +describe('core/button save validity', () => { + let wp: WpRuntime; + + beforeAll(() => { + setupDomGlobals(); + wp = require('@wordpress/blocks') as WpRuntime; + const library = require('@wordpress/block-library') as Pick; + library.registerCoreBlocks(); + }); + + it('keeps authored flex carriers on the supported block wrapper', () => { + const button = wp.createBlock('core/button', { + className: 'product-row blocks-engine-control-fixture', + text: 'Product$25', + url: '/product', + style: { color: { background: '#123456' } }, + }); + const persisted = wp.serialize([button]); + const reloaded = wp.parse(persisted); + + expect(persisted).toContain('
'); + expect(persisted).toContain(' $this->mergeClassNames('wp-block-button__link', $support['classes'], (string) ($attrs['linkClass'] ?? ''), 'wp-element-button'), + 'class' => $this->mergeClassNames('wp-block-button__link', $support['classes'], 'wp-element-button'), 'style' => $support['style'], 'title' => (string) ($attrs['title'] ?? ''), ); diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php index 405f15d2..63d10446 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php @@ -136,13 +136,11 @@ private function buttonBlockFromAnchor(DOMElement $anchor, callable $presentatio $this->removeSourceControlClasses($attrs, $presentationElement); } $text = $this->buttonText($anchor, $innerHtml($anchor), $materializeSvgImages); - $sourceClasses = $preserveSourceClasses ? $this->retainedSourceClasses($anchor, $attrs) : ''; return $createBlock('core/button', array_filter(array_merge($attrs, array( - 'text' => $text, - 'url' => $attr($anchor, 'href'), - 'title' => $this->buttonAccessibleTitle($anchor, $text), - 'linkClass' => $sourceClasses, + 'text' => $text, + 'url' => $attr($anchor, 'href'), + 'title' => $this->buttonAccessibleTitle($anchor, $text), )), static fn ($value): bool => is_array($value) ? array() !== $value : '' !== $value), array(), $presentationElement, $anchor); } @@ -403,17 +401,6 @@ private function hasRuntimeBehaviorSignal(DOMElement $element): bool return false; } - /** @param array $attrs */ - private function retainedSourceClasses(DOMElement $element, array $attrs): string - { - $sourceClasses = preg_split('/\s+/', trim($element->getAttribute('class'))) ?: array(); - $retainedClasses = preg_split('/\s+/', trim((string) ($attrs['className'] ?? ''))) ?: array(); - return $this->mergeClassNames(...array_values(array_filter( - $sourceClasses, - static fn (string $class): bool => in_array($class, $retainedClasses, true) - ))); - } - private function hasFlexLayout(string $style): bool { return preg_match('/(?:^|;)\s*display\s*:\s*(?:inline-)?flex\b/i', $style) === 1; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 241f8d2b..f3e12235 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -863,7 +863,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $flexAnchorButtonMarkup = (string) ($flexAnchorButton['serialized_blocks'] ?? ''); $flexAnchorButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $flexAnchorButton['assets'] ?? array())); $assert(str_contains((string) ($flexAnchorButtonAttrs['className'] ?? ''), 'product-row'), 'styled anchor button preserves its safe source class in core/button attributes'); -$assert(str_contains($flexAnchorButtonMarkup, ' :where(.wp-block-button__link){display:flex;align-items:center;gap:1rem') && str_contains($flexAnchorButtonCss, 'blocks-engine-richtext-marker') && str_contains($flexAnchorButtonCss, '{flex:1}'), 'styled anchor button retains matched flex and child selector applicability after lowering'); $assert('pass' === ($flexAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled anchor button remains editor-valid with composed source classes'); From 878fcacbc41fff69fb3982d66337ebfbe4bb8ee9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 07:34:20 -0400 Subject: [PATCH 3/9] fix(transformer): project flex anchor selectors through markers [AI: OpenAI GPT-5.6 Terra via OpenCode] --- .../wp/__tests__/button-save-validity.test.ts | 7 ++++--- .../HtmlToBlocks/Patterns/ButtonsPattern.php | 21 +++---------------- php-transformer/tests/contract/run.php | 9 ++++---- 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts b/packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts index 57d9d3c1..9311d338 100644 --- a/packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts +++ b/packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts @@ -22,9 +22,9 @@ describe('core/button save validity', () => { library.registerCoreBlocks(); }); - it('keeps authored flex carriers on the supported block wrapper', () => { + it('keeps the generated control carrier on the supported block wrapper', () => { const button = wp.createBlock('core/button', { - className: 'product-row blocks-engine-control-fixture', + className: 'blocks-engine-control-fixture', text: 'Product$25', url: '/product', style: { color: { background: '#123456' } }, @@ -32,8 +32,9 @@ describe('core/button save validity', () => { const persisted = wp.serialize([button]); const reloaded = wp.parse(persisted); - expect(persisted).toContain('
'); + expect(persisted).toContain('
'); expect(persisted).toContain('hasFlexLayout($resolvedPresentation) && $this->hasClassedDescendant($anchor); - if ( $hasAuthoredStyleRules && ($presentationElement === $anchor || $presentationElement->parentNode === $anchor) && ! $preserveSourceClasses ) { + // core/button only saves className on its wrapper. Anchor-root selectors + // are projected through the generated control marker onto the saved link. + if ( $hasAuthoredStyleRules && ($presentationElement === $anchor || $presentationElement->parentNode === $anchor) ) { $this->removeSourceControlClasses($attrs, $presentationElement); } $text = $this->buttonText($anchor, $innerHtml($anchor), $materializeSvgImages); @@ -401,22 +402,6 @@ private function hasRuntimeBehaviorSignal(DOMElement $element): bool return false; } - private function hasFlexLayout(string $style): bool - { - return preg_match('/(?:^|;)\s*display\s*:\s*(?:inline-)?flex\b/i', $style) === 1; - } - - private function hasClassedDescendant(DOMElement $element): bool - { - foreach ( $element->getElementsByTagName('*') as $descendant ) { - if ( $descendant instanceof DOMElement && '' !== trim($descendant->getAttribute('class')) ) { - return true; - } - } - - return false; - } - /** @param array $attrs */ private function removeSourceControlClasses(array &$attrs, DOMElement $element): void { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index f3e12235..87829829 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -862,10 +862,11 @@ public function match(DOMElement $element, PatternContext $context): ?array $flexAnchorButtonAttrs = $flexAnchorButton['blocks'][0]['innerBlocks'][0]['innerBlocks'][0]['attrs'] ?? array(); $flexAnchorButtonMarkup = (string) ($flexAnchorButton['serialized_blocks'] ?? ''); $flexAnchorButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $flexAnchorButton['assets'] ?? array())); -$assert(str_contains((string) ($flexAnchorButtonAttrs['className'] ?? ''), 'product-row'), 'styled anchor button preserves its safe source class in core/button attributes'); -$assert(str_contains($flexAnchorButtonMarkup, '
:where(.wp-block-button__link){display:flex;align-items:center;gap:1rem') && str_contains($flexAnchorButtonCss, 'blocks-engine-richtext-marker') && str_contains($flexAnchorButtonCss, '{flex:1}'), 'styled anchor button retains matched flex and child selector applicability after lowering'); -$assert('pass' === ($flexAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled anchor button remains editor-valid with composed source classes'); +$assert(str_contains((string) ($flexAnchorButtonAttrs['className'] ?? ''), 'blocks-engine-control-') && ! str_contains((string) ($flexAnchorButtonAttrs['className'] ?? ''), 'product-row'), 'styled anchor button uses a generated control marker instead of its source anchor class'); +$assert(! str_contains($flexAnchorButtonMarkup, 'wp-block-button product-row') && ! str_contains($flexAnchorButtonMarkup, 'wp-element-button product-row'), 'styled anchor button keeps source anchor classes out of canonical core/button markup'); +$assert(str_contains($flexAnchorButtonCss, '> :where(.wp-block-button__link){display:flex;align-items:center;gap:1rem') && str_contains($flexAnchorButtonCss, 'blocks-engine-richtext-marker') && str_contains($flexAnchorButtonCss, '{flex:1}'), 'styled anchor root and descendant selectors project through the generated marker after lowering'); +$assert(str_contains($flexAnchorButtonMarkup, 'class="product-row__name"'), 'styled anchor button preserves descendant classes in its RichText content'); +$assert('pass' === ($flexAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled anchor button remains editor-valid with marker-projected source selectors'); $contextualSurfaceButton = ( new HtmlTransformer() )->transform( '' From 417ba56554bc0c6d57ec9c7d7c4829dda6415329 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 07:48:37 -0400 Subject: [PATCH 4/9] fix(transformer): bridge direct flex button wrappers [AI: OpenAI GPT-5.6 Terra via OpenCode] --- .../src/HtmlToBlocks/HtmlTransformer.php | 31 +++++++++++++++++++ php-transformer/tests/contract/run.php | 9 ++++++ 2 files changed, 40 insertions(+) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 42f807f4..7aa16dc8 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -425,6 +425,9 @@ final class HtmlTransformer /** @var array Source control DOM paths mapped to core/button wrapper classes. */ private array $sourceControlMarkers = array(); + /** @var array Direct flex-child controls mapped to synthetic wrapper bridge CSS. */ + private array $directFlexButtonStyleRules = array(); + /** @var array Source wrapper paths promoted into core/button. */ private array $sourceButtonPresentationMarkers = array(); @@ -575,6 +578,7 @@ public function transform(string $html, array $options = array()): TransformerRe $this->gutenbergIncompatibilities = array(); $this->sourceTagMarkers = array(); $this->sourceControlMarkers = array(); + $this->directFlexButtonStyleRules = array(); $this->sourceButtonPresentationMarkers = array(); $this->sourceControlPaths = array(); $this->sourceSemanticMarkers = array(); @@ -1034,6 +1038,9 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo if ( array() !== $this->nativeButtonStyleRules ) { $cssParts[] = implode("\n", $this->nativeButtonStyleRules); } + if ( array() !== $this->directFlexButtonStyleRules ) { + $cssParts[] = implode("\n", $this->directFlexButtonStyleRules); + } $css = trim(implode("\n\n", $cssParts)); if ( '' === $css ) { @@ -3451,6 +3458,9 @@ private function createBlock(string $name, array $attrs = array(), array $innerB $attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), $this->sourceControlMarkers[$logicalControlPath]); if ( 'core/button' === $name ) { $this->registerNativeButtonStyleRule($this->sourceControlMarkers[$logicalControlPath], $attrs, $nativeButtonTextAlignment); + if ( $this->isDirectChildOfAuthorFlexLayout($logicalControl) ) { + $this->directFlexButtonStyleRules[$this->sourceControlMarkers[$logicalControlPath]] = $this->directFlexButtonStyleRule($this->sourceControlMarkers[$logicalControlPath], $logicalControl); + } } } $presentationPath = $sourceElement->getNodePath() ?? ''; @@ -3964,6 +3974,27 @@ private function isDirectChildOfAuthorOwnedLayout(DOMElement $element): bool return $element->parentNode instanceof DOMElement && $this->isAuthorOwnedLayout($element->parentNode); } + private function isDirectChildOfAuthorFlexLayout(DOMElement $element): bool + { + return $element->parentNode instanceof DOMElement + && in_array($this->authoredDisplay($element->parentNode), array( 'flex', 'inline-flex' ), true); + } + + private function directFlexButtonStyleRule(string $marker, DOMElement $control): string + { + $parent = $control->parentNode; + $parentStyle = $parent instanceof DOMElement ? $this->structuralPresentationDeclarations($parent) : array(); + $isColumn = str_starts_with(strtolower(trim((string) ($parentStyle['flex-direction'] ?? 'row'))), 'column'); + $wrapper = ':where(.' . $marker . '.wp-block-buttons)'; + $button = ':where(.' . $marker . '.wp-block-buttons)>:where(.' . $marker . '.wp-block-button)'; + $link = $button . '>:where(.wp-block-button__link)'; + $columnGeometry = $isColumn ? ';width:100%!important' : ''; + + return $wrapper . '{display:block!important;gap:0!important;margin:0!important;min-width:0' . $columnGeometry . '}' + . $button . '{display:block!important;margin:0!important;min-width:0' . $columnGeometry . '}' + . $link . '{box-sizing:border-box' . ($isColumn ? ';width:100%!important' : '') . '}'; + } + private function isDirectChildOfStructuralLayout(DOMElement $element): bool { return $element->parentNode instanceof DOMElement && $this->isStructuralLayoutElement($element->parentNode); diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 87829829..de3db5e6 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -868,6 +868,15 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(str_contains($flexAnchorButtonMarkup, 'class="product-row__name"'), 'styled anchor button preserves descendant classes in its RichText content'); $assert('pass' === ($flexAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled anchor button remains editor-valid with marker-projected source selectors'); +$flexChainButton = ( new HtmlTransformer() )->transform( + '
' +)->toArray(); +$flexChainButtonMarkup = (string) ($flexChainButton['serialized_blocks'] ?? ''); +$flexChainButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $flexChainButton['assets'] ?? array())); +$assert(str_contains($flexChainButtonMarkup, 'wp-block-buttons blocks-engine-control-') && str_contains($flexChainButtonMarkup, 'wp-block-button blocks-engine-control-'), 'direct flex-child anchor carries one generated marker across both synthetic wrappers'); +$assert(str_contains($flexChainButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;min-width:0;width:100%!important}') && str_contains($flexChainButtonCss, '.wp-block-button){display:block!important;margin:0!important;min-width:0;width:100%!important}') && str_contains($flexChainButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'direct column flex-child anchor bridges wrapper sizing and neutralizes synthetic gap and margin'); +$assert('pass' === ($flexChainButton['source_reports']['wp_block_validity']['status'] ?? ''), 'direct flex-child wrapper chain remains editor-valid'); + $contextualSurfaceButton = ( new HtmlTransformer() )->transform( '' )->toArray(); From 6dba1295033cd31b12dbeaa7bd103172fa528ac5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 08:25:32 -0400 Subject: [PATCH 5/9] fix(transformer): bridge full-width button wrappers [AI: OpenAI GPT-5.6 Terra via OpenCode] --- .../src/HtmlToBlocks/HtmlTransformer.php | 21 +++++++++++++++++++ php-transformer/tests/contract/run.php | 10 +++++++++ 2 files changed, 31 insertions(+) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 7aa16dc8..3fbf1d92 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -428,6 +428,9 @@ final class HtmlTransformer /** @var array Direct flex-child controls mapped to synthetic wrapper bridge CSS. */ private array $directFlexButtonStyleRules = array(); + /** @var array Full-width controls mapped to synthetic wrapper bridge CSS. */ + private array $fullWidthButtonStyleRules = array(); + /** @var array Source wrapper paths promoted into core/button. */ private array $sourceButtonPresentationMarkers = array(); @@ -579,6 +582,7 @@ public function transform(string $html, array $options = array()): TransformerRe $this->sourceTagMarkers = array(); $this->sourceControlMarkers = array(); $this->directFlexButtonStyleRules = array(); + $this->fullWidthButtonStyleRules = array(); $this->sourceButtonPresentationMarkers = array(); $this->sourceControlPaths = array(); $this->sourceSemanticMarkers = array(); @@ -1041,6 +1045,9 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo if ( array() !== $this->directFlexButtonStyleRules ) { $cssParts[] = implode("\n", $this->directFlexButtonStyleRules); } + if ( array() !== $this->fullWidthButtonStyleRules ) { + $cssParts[] = implode("\n", $this->fullWidthButtonStyleRules); + } $css = trim(implode("\n\n", $cssParts)); if ( '' === $css ) { @@ -3461,6 +3468,9 @@ private function createBlock(string $name, array $attrs = array(), array $innerB if ( $this->isDirectChildOfAuthorFlexLayout($logicalControl) ) { $this->directFlexButtonStyleRules[$this->sourceControlMarkers[$logicalControlPath]] = $this->directFlexButtonStyleRule($this->sourceControlMarkers[$logicalControlPath], $logicalControl); } + if ( 100 === (int) ($attrs['width'] ?? 0) ) { + $this->fullWidthButtonStyleRules[$this->sourceControlMarkers[$logicalControlPath]] = $this->fullWidthButtonStyleRule($this->sourceControlMarkers[$logicalControlPath]); + } } } $presentationPath = $sourceElement->getNodePath() ?? ''; @@ -3995,6 +4005,17 @@ private function directFlexButtonStyleRule(string $marker, DOMElement $control): . $link . '{box-sizing:border-box' . ($isColumn ? ';width:100%!important' : '') . '}'; } + private function fullWidthButtonStyleRule(string $marker): string + { + $wrapper = ':where(.' . $marker . '.wp-block-buttons)'; + $button = ':where(.' . $marker . '.wp-block-buttons)>:where(.' . $marker . '.wp-block-button)'; + $link = $button . '>:where(.wp-block-button__link)'; + + return $wrapper . '{display:block!important;gap:0!important;margin:0!important;width:100%!important}' + . $button . '{display:block!important;margin:0!important;width:100%!important}' + . $link . '{box-sizing:border-box;width:100%!important}'; + } + private function isDirectChildOfStructuralLayout(DOMElement $element): bool { return $element->parentNode instanceof DOMElement && $this->isStructuralLayoutElement($element->parentNode); diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index de3db5e6..b65681b4 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -877,6 +877,16 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(str_contains($flexChainButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;min-width:0;width:100%!important}') && str_contains($flexChainButtonCss, '.wp-block-button){display:block!important;margin:0!important;min-width:0;width:100%!important}') && str_contains($flexChainButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'direct column flex-child anchor bridges wrapper sizing and neutralizes synthetic gap and margin'); $assert('pass' === ($flexChainButton['source_reports']['wp_block_validity']['status'] ?? ''), 'direct flex-child wrapper chain remains editor-valid'); +$fullWidthAnchorButton = ( new HtmlTransformer() )->transform( + '
Submit
' +)->toArray(); +$fullWidthAnchorButtonMarkup = (string) ($fullWidthAnchorButton['serialized_blocks'] ?? ''); +$fullWidthAnchorButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $fullWidthAnchorButton['assets'] ?? array())); +$assert(str_contains($fullWidthAnchorButtonMarkup, 'has-custom-width wp-block-button__width-100') && str_contains($fullWidthAnchorButtonMarkup, 'blocks-engine-control-'), 'styled full-width anchor preserves native core/button width support and its generated marker'); +$assert(! str_contains($fullWidthAnchorButtonMarkup, 'wp-block-button selector-submit') && ! str_contains($fullWidthAnchorButtonMarkup, 'wp-element-button selector-submit'), 'styled full-width anchor without descendants keeps authored root classes out of canonical button markup'); +$assert(str_contains($fullWidthAnchorButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;width:100%!important}') && str_contains($fullWidthAnchorButtonCss, '.wp-block-button){display:block!important;margin:0!important;width:100%!important}') && str_contains($fullWidthAnchorButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'styled full-width anchor bridges width through every synthetic wrapper to the canonical link'); +$assert('pass' === ($fullWidthAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled full-width anchor wrapper chain remains editor-valid'); + $contextualSurfaceButton = ( new HtmlTransformer() )->transform( '' )->toArray(); From 9e492ba1c813e9d5962f1484a0da6343c7e76f45 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 08:34:02 -0400 Subject: [PATCH 6/9] fix(transformer): project full-width native button roots [AI: OpenAI GPT-5.6 Terra via OpenCode] --- .../src/HtmlToBlocks/Patterns/ButtonsPattern.php | 4 ++++ php-transformer/tests/contract/run.php | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php index bb87370f..d171410a 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php @@ -58,10 +58,14 @@ public function matchAnchor(DOMElement $anchor, callable $fileBlockFromAnchor, c */ public function matchButton(DOMElement $button, callable $presentationAttributes, callable $resolvedStyle, callable $innerHtml, callable $materializeSvgImages, callable $isGridItem, callable $createBlock): array { + $resolvedButtonStyle = trim((string) $resolvedStyle($button)); $attrs = $this->buttonPresentationAttributes($button, $presentationAttributes, $resolvedStyle); if ( $isGridItem($button) ) { $attrs['width'] = 100; } + if ( 100 === (int) ($attrs['width'] ?? 0) && $resolvedButtonStyle !== trim($button->getAttribute('style')) ) { + $this->removeSourceControlClasses($attrs, $button); + } $text = $this->buttonText($button, $innerHtml($button), $materializeSvgImages); return $createBlock('core/buttons', $this->buttonWrapperAttributes($button, $presentationAttributes, $resolvedStyle), array( diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index b65681b4..a3403b07 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -887,6 +887,17 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(str_contains($fullWidthAnchorButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;width:100%!important}') && str_contains($fullWidthAnchorButtonCss, '.wp-block-button){display:block!important;margin:0!important;width:100%!important}') && str_contains($fullWidthAnchorButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'styled full-width anchor bridges width through every synthetic wrapper to the canonical link'); $assert('pass' === ($fullWidthAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled full-width anchor wrapper chain remains editor-valid'); +$fullWidthNativeButton = ( new HtmlTransformer() )->transform( + '
' +)->toArray(); +$fullWidthNativeButtonMarkup = (string) ($fullWidthNativeButton['serialized_blocks'] ?? ''); +$fullWidthNativeButtonAttrs = $fullWidthNativeButton['blocks'][0]['innerBlocks'][0]['attrs'] ?? array(); +$fullWidthNativeButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $fullWidthNativeButton['assets'] ?? array())); +$assert(100 === ($fullWidthNativeButtonAttrs['width'] ?? null) && str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'blocks-engine-control-') && ! str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'selector-submit'), 'styled full-width native button uses native width support and a generated marker instead of source root classes'); +$assert(! str_contains($fullWidthNativeButtonMarkup, 'wp-block-button selector-submit') && ! str_contains($fullWidthNativeButtonMarkup, 'wp-element-button selector-submit'), 'styled full-width native button keeps source root classes out of canonical markup'); +$assert(str_contains($fullWidthNativeButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;width:100%!important}') && str_contains($fullWidthNativeButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'styled full-width native button projects root geometry through the wrapper chain'); +$assert('pass' === ($fullWidthNativeButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled full-width native button wrapper chain remains editor-valid'); + $contextualSurfaceButton = ( new HtmlTransformer() )->transform( '' )->toArray(); From 7825a6c21f73d8028cb4aaff92014e662e30f5ac Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 08:42:25 -0400 Subject: [PATCH 7/9] fix(transformer): retain filled button variants [AI: OpenAI GPT-5.6 Terra via OpenCode] --- .../src/HtmlToBlocks/Patterns/ButtonsPattern.php | 14 +++++++++++--- php-transformer/tests/contract/run.php | 3 ++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php index d171410a..2fba01e7 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php @@ -320,7 +320,10 @@ private function buttonPresentationAttributes(DOMElement $element, callable $pre // belongs on the parent core/buttons, not each button). Emitting it here // produces an unsupported attribute and invalid block markup, so drop it. unset($attrs['layout']); - $isOutline = $this->hasOutlineSignal($element, $resolvedStyle); + // Resolve native paint before classifying an outline: a generic reset such + // as `button { background: none }` can precede a filled button variant. + $native = $this->styleResolver->nativeAttributes($resolvedStyle); + $isOutline = $this->hasOutlineSignal($element, $resolvedStyle, $native); if ( $isOutline ) { $attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), 'is-style-outline'); } @@ -330,7 +333,6 @@ private function buttonPresentationAttributes(DOMElement $element, callable $pre // button renders with its source colors/border instead of the theme default. // A button with no paintable styling resolves to no native attributes and // stays a default button. - $native = $this->styleResolver->nativeAttributes($resolvedStyle); if ( array() !== $native ) { $attrs = array_merge($attrs, $native); } @@ -422,12 +424,18 @@ private function removeSourceControlClasses(array &$attrs, DOMElement $element): $attrs['className'] = implode(' ', $classes); } - private function hasOutlineSignal(DOMElement $element, string $style): bool + /** @param array $native */ + private function hasOutlineSignal(DOMElement $element, string $style, array $native = array()): bool { if ( $this->hasAnyToken($element, array( 'outline', 'ghost', 'hollow', 'bordered' )) ) { return true; } + $background = trim((string) ($native['style']['color']['background'] ?? '')); + if ( '' !== $background && ! in_array(strtolower($background), array( 'transparent', 'none' ), true) ) { + return false; + } + $normalized = strtolower($style); if ( ! preg_match('/(?:^|;)\s*border(?:-[a-z-]+)?\s*:\s*[^;]+/', $normalized) ) { return false; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index a3403b07..21e1de56 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -888,13 +888,14 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert('pass' === ($fullWidthAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled full-width anchor wrapper chain remains editor-valid'); $fullWidthNativeButton = ( new HtmlTransformer() )->transform( - '
' + '
' )->toArray(); $fullWidthNativeButtonMarkup = (string) ($fullWidthNativeButton['serialized_blocks'] ?? ''); $fullWidthNativeButtonAttrs = $fullWidthNativeButton['blocks'][0]['innerBlocks'][0]['attrs'] ?? array(); $fullWidthNativeButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $fullWidthNativeButton['assets'] ?? array())); $assert(100 === ($fullWidthNativeButtonAttrs['width'] ?? null) && str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'blocks-engine-control-') && ! str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'selector-submit'), 'styled full-width native button uses native width support and a generated marker instead of source root classes'); $assert(! str_contains($fullWidthNativeButtonMarkup, 'wp-block-button selector-submit') && ! str_contains($fullWidthNativeButtonMarkup, 'wp-element-button selector-submit'), 'styled full-width native button keeps source root classes out of canonical markup'); +$assert(! str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'is-style-outline') && '#123456' === ($fullWidthNativeButtonAttrs['style']['color']['background'] ?? null), 'a filled button variant overrides an earlier native-button background reset without becoming an outline control'); $assert(str_contains($fullWidthNativeButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;width:100%!important}') && str_contains($fullWidthNativeButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'styled full-width native button projects root geometry through the wrapper chain'); $assert('pass' === ($fullWidthNativeButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled full-width native button wrapper chain remains editor-valid'); From 5eadc18e7d8229b8887f97c41b05992ad78b7691 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 18:24:03 -0400 Subject: [PATCH 8/9] fix(transformer): preserve flex button auto margins [AI: OpenAI GPT-5.6 Terra via OpenCode; generic bridge repair and contract] --- .../src/HtmlToBlocks/HtmlTransformer.php | 6 ++++-- php-transformer/tests/contract/run.php | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 4c71c8fe..88366c3a 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -4003,7 +4003,9 @@ private function directFlexButtonStyleRule(string $marker, DOMElement $control): $link = $button . '>:where(.wp-block-button__link)'; $columnGeometry = $isColumn ? ';width:100%!important' : ''; - return $wrapper . '{display:block!important;gap:0!important;margin:0!important;min-width:0' . $columnGeometry . '}' + // The outer core/buttons wrapper is the lowered source flex item, so its + // authored margins must remain intact. Only core/button is synthetic. + return $wrapper . '{display:block!important;gap:0!important;min-width:0' . $columnGeometry . '}' . $button . '{display:block!important;margin:0!important;min-width:0' . $columnGeometry . '}' . $link . '{box-sizing:border-box' . ($isColumn ? ';width:100%!important' : '') . '}'; } @@ -4014,7 +4016,7 @@ private function fullWidthButtonStyleRule(string $marker): string $button = ':where(.' . $marker . '.wp-block-buttons)>:where(.' . $marker . '.wp-block-button)'; $link = $button . '>:where(.wp-block-button__link)'; - return $wrapper . '{display:block!important;gap:0!important;margin:0!important;width:100%!important}' + return $wrapper . '{display:block!important;gap:0!important;width:100%!important}' . $button . '{display:block!important;margin:0!important;width:100%!important}' . $link . '{box-sizing:border-box;width:100%!important}'; } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 024fbdff..8bc3e7da 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -874,9 +874,18 @@ public function match(DOMElement $element, PatternContext $context): ?array $flexChainButtonMarkup = (string) ($flexChainButton['serialized_blocks'] ?? ''); $flexChainButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $flexChainButton['assets'] ?? array())); $assert(str_contains($flexChainButtonMarkup, 'wp-block-buttons blocks-engine-control-') && str_contains($flexChainButtonMarkup, 'wp-block-button blocks-engine-control-'), 'direct flex-child anchor carries one generated marker across both synthetic wrappers'); -$assert(str_contains($flexChainButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;min-width:0;width:100%!important}') && str_contains($flexChainButtonCss, '.wp-block-button){display:block!important;margin:0!important;min-width:0;width:100%!important}') && str_contains($flexChainButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'direct column flex-child anchor bridges wrapper sizing and neutralizes synthetic gap and margin'); +$assert(str_contains($flexChainButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;min-width:0;width:100%!important}') && str_contains($flexChainButtonCss, '.wp-block-button){display:block!important;margin:0!important;min-width:0;width:100%!important}') && str_contains($flexChainButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'direct column flex-child anchor bridges wrapper sizing while only the synthetic inner wrapper has neutral margin'); $assert('pass' === ($flexChainButton['source_reports']['wp_block_validity']['status'] ?? ''), 'direct flex-child wrapper chain remains editor-valid'); +$flexAnchorAutoMargin = ( new HtmlTransformer() )->transform( + '
' +)->toArray(); +$flexAnchorAutoMarginMarkup = (string) ($flexAnchorAutoMargin['serialized_blocks'] ?? ''); +$flexAnchorAutoMarginCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $flexAnchorAutoMargin['assets'] ?? array())); +$assert(str_contains($flexAnchorAutoMarginMarkup, 'margin-right:auto') && 2 === substr_count($flexAnchorAutoMarginMarkup, 'wp-block-buttons'), 'direct flex anchor preserves its authored auto margin on the lowered source flex-item wrapper beside navigation and button siblings'); +$assert(str_contains($flexAnchorAutoMarginCss, '.wp-block-buttons){display:block!important;gap:0!important;min-width:0}') && ! str_contains($flexAnchorAutoMarginCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;min-width:0}') && str_contains($flexAnchorAutoMarginCss, '.wp-block-button){display:block!important;margin:0!important;min-width:0}'), 'direct flex bridge leaves source wrapper margins intact while neutralizing only the synthetic inner wrapper'); +$assert('pass' === ($flexAnchorAutoMargin['source_reports']['wp_block_validity']['status'] ?? ''), 'direct flex anchor with auto margin remains editor-valid beside navigation and button siblings'); + $fullWidthAnchorButton = ( new HtmlTransformer() )->transform( '
Submit
' )->toArray(); @@ -884,7 +893,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $fullWidthAnchorButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $fullWidthAnchorButton['assets'] ?? array())); $assert(str_contains($fullWidthAnchorButtonMarkup, 'has-custom-width wp-block-button__width-100') && str_contains($fullWidthAnchorButtonMarkup, 'blocks-engine-control-'), 'styled full-width anchor preserves native core/button width support and its generated marker'); $assert(! str_contains($fullWidthAnchorButtonMarkup, 'wp-block-button selector-submit') && ! str_contains($fullWidthAnchorButtonMarkup, 'wp-element-button selector-submit'), 'styled full-width anchor without descendants keeps authored root classes out of canonical button markup'); -$assert(str_contains($fullWidthAnchorButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;width:100%!important}') && str_contains($fullWidthAnchorButtonCss, '.wp-block-button){display:block!important;margin:0!important;width:100%!important}') && str_contains($fullWidthAnchorButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'styled full-width anchor bridges width through every synthetic wrapper to the canonical link'); +$assert(str_contains($fullWidthAnchorButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;width:100%!important}') && str_contains($fullWidthAnchorButtonCss, '.wp-block-button){display:block!important;margin:0!important;width:100%!important}') && str_contains($fullWidthAnchorButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'styled full-width anchor bridges width through every synthetic wrapper while preserving source wrapper margins'); $assert('pass' === ($fullWidthAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled full-width anchor wrapper chain remains editor-valid'); $fullWidthNativeButton = ( new HtmlTransformer() )->transform( @@ -896,7 +905,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(100 === ($fullWidthNativeButtonAttrs['width'] ?? null) && str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'blocks-engine-control-') && ! str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'selector-submit'), 'styled full-width native button uses native width support and a generated marker instead of source root classes'); $assert(! str_contains($fullWidthNativeButtonMarkup, 'wp-block-button selector-submit') && ! str_contains($fullWidthNativeButtonMarkup, 'wp-element-button selector-submit'), 'styled full-width native button keeps source root classes out of canonical markup'); $assert(! str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'is-style-outline') && '#123456' === ($fullWidthNativeButtonAttrs['style']['color']['background'] ?? null), 'a filled button variant overrides an earlier native-button background reset without becoming an outline control'); -$assert(str_contains($fullWidthNativeButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;width:100%!important}') && str_contains($fullWidthNativeButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'styled full-width native button projects root geometry through the wrapper chain'); +$assert(str_contains($fullWidthNativeButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;width:100%!important}') && str_contains($fullWidthNativeButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'styled full-width native button projects root geometry through the wrapper chain without overriding source wrapper margins'); $assert('pass' === ($fullWidthNativeButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled full-width native button wrapper chain remains editor-valid'); $contextualSurfaceButton = ( new HtmlTransformer() )->transform( From 6090f816e9e3cc5f6a6f43fe3503bf957be7ed2b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 18:34:16 -0400 Subject: [PATCH 9/9] test(transformer): cover anchor button margin ownership [AI: OpenAI GPT-5.6 Terra via OpenCode; added generic margin lowering contracts] --- php-transformer/tests/contract/run.php | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 8bc3e7da..f17ff76c 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -886,6 +886,40 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(str_contains($flexAnchorAutoMarginCss, '.wp-block-buttons){display:block!important;gap:0!important;min-width:0}') && ! str_contains($flexAnchorAutoMarginCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;min-width:0}') && str_contains($flexAnchorAutoMarginCss, '.wp-block-button){display:block!important;margin:0!important;min-width:0}'), 'direct flex bridge leaves source wrapper margins intact while neutralizing only the synthetic inner wrapper'); $assert('pass' === ($flexAnchorAutoMargin['source_reports']['wp_block_validity']['status'] ?? ''), 'direct flex anchor with auto margin remains editor-valid beside navigation and button siblings'); +$anchorButtonMarginCases = array( + 'directional' => array( + 'source' => 'margin-left:2rem;margin-right:3rem', + 'expected' => array( 'right' => '3rem', 'left' => '2rem' ), + 'css' => 'margin-left:2rem;margin-right:3rem', + ), + 'shorthand' => array( + 'source' => 'margin:1rem 2rem 3rem 4rem', + 'expected' => array( 'top' => '1rem', 'right' => '2rem', 'bottom' => '3rem', 'left' => '4rem' ), + 'css' => 'margin:1rem 2rem 3rem 4rem', + ), +); +foreach ( $anchorButtonMarginCases as $marginCase => $margin ) { + $directFlexMarginButton = ( new HtmlTransformer() )->transform( + '
' + )->toArray(); + $directFlexMarginWrapper = $directFlexMarginButton['blocks'][0]['innerBlocks'][0] ?? array(); + $directFlexMarginInner = $directFlexMarginWrapper['innerBlocks'][0] ?? array(); + $directFlexMarginCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $directFlexMarginButton['assets'] ?? array())); + $assert($margin['expected'] === ($directFlexMarginWrapper['attrs']['style']['spacing']['margin'] ?? null), 'direct-flex ' . $marginCase . ' anchor margin stays on the outer core/buttons source flex item'); + $assert(! isset($directFlexMarginInner['attrs']['style']['spacing']['margin']) && str_contains($directFlexMarginCss, '.wp-block-button){display:block!important;margin:0!important;min-width:0;width:100%!important}'), 'direct-flex ' . $marginCase . ' anchor keeps the synthetic inner core/button margin-neutral'); + $assert(str_contains($directFlexMarginCss, $margin['css']) && ! str_contains($directFlexMarginCss, $margin['css'] . '!important'), 'direct-flex ' . $marginCase . ' anchor preserves authored outer margin priority without !important'); + + $fullWidthMarginButton = ( new HtmlTransformer() )->transform( + '
Start
' + )->toArray(); + $fullWidthMarginWrapper = $fullWidthMarginButton['blocks'][0] ?? array(); + $fullWidthMarginInner = $fullWidthMarginWrapper['innerBlocks'][0] ?? array(); + $fullWidthMarginCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $fullWidthMarginButton['assets'] ?? array())); + $assert($margin['expected'] === ($fullWidthMarginWrapper['attrs']['style']['spacing']['margin'] ?? null), 'full-width ' . $marginCase . ' anchor margin stays on the outer core/buttons wrapper'); + $assert(! isset($fullWidthMarginInner['attrs']['style']['spacing']['margin']) && str_contains($fullWidthMarginCss, '.wp-block-button){display:block!important;margin:0!important;width:100%!important}'), 'full-width ' . $marginCase . ' anchor keeps the synthetic inner core/button margin-neutral'); + $assert(str_contains($fullWidthMarginCss, $margin['css']) && ! str_contains($fullWidthMarginCss, $margin['css'] . '!important'), 'full-width ' . $marginCase . ' anchor preserves authored outer margin priority without !important'); +} + $fullWidthAnchorButton = ( new HtmlTransformer() )->transform( '
Submit
' )->toArray();