diff --git a/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php b/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php
index 6af71f65..c1dfb8c5 100644
--- a/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php
+++ b/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php
@@ -115,6 +115,7 @@ private function marginSelectorPrelude(string $prelude, AuthorStylesheetProjecti
private function rewriteStyleRule(string $prelude, string $body, AuthorStylesheetProjectionContext $context): string
{
$projectedPrelude = $this->rewriteSelectorPrelude($prelude, $context);
+ $body = $this->buttonLinkCompatDeclarations($prelude, $projectedPrelude, $body, $context);
$wrapperPrelude = $this->buttonPresentationWrapperPrelude($prelude, $context);
if ( '' === $wrapperPrelude ) {
$directWrapperPrelude = $this->directButtonGeometryWrapperPrelude($prelude, $context);
@@ -139,6 +140,67 @@ private function rewriteStyleRule(string $prelude, string $body, AuthorStyleshee
return $this->withButtonWrapperInnerFill($wrapperPrelude, $layout, $projectedPrelude . '{' . $control . '}');
}
+ /**
+ * core/button defaults and support styles are emitted after carried author CSS.
+ * Keep source button declarations authoritative after their selector is lowered
+ * to a generated marker, including media-query overrides.
+ */
+ private function buttonLinkCompatDeclarations(string $prelude, string $projectedPrelude, string $body, AuthorStylesheetProjectionContext $context): string
+ {
+ if ( ! str_contains($projectedPrelude, '.wp-block-button__link') || ! $this->projectsAnchorButtonControl($prelude, $context) ) {
+ return $body;
+ }
+
+ $declarations = array();
+ foreach ( CssValueSplitter::splitTopLevel($body, array( ';' )) as $declaration ) {
+ $colon = strpos($declaration, ':');
+ if ( false === $colon ) {
+ $declarations[] = $declaration;
+ continue;
+ }
+ $name = trim(substr($declaration, 0, $colon));
+ $value = trim(substr($declaration, $colon + 1));
+ if ( '' === $name || '' === $value || ! $this->isButtonLinkLayoutProperty($name) || preg_match('/\s*!important\s*$/i', $value) ) {
+ $declarations[] = $declaration;
+ continue;
+ }
+ $declarations[] = $name . ':' . $value . '!important';
+ }
+ return implode(';', $declarations);
+ }
+
+ private function projectsAnchorButtonControl(string $prelude, AuthorStylesheetProjectionContext $context): bool
+ {
+ foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) {
+ $parsed = $context->sourceStyles->parsedSelector($selector);
+ if ( ! $parsed['supported'] ) {
+ continue;
+ }
+ foreach ( $this->matchingSourceElements($selector, $parsed, $context) as $element ) {
+ if ( 'a' === strtolower($element->tagName)
+ && '' !== $context->selectorProjections->controlMarker($element->getNodePath() ?? '') ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private function isButtonLinkLayoutProperty(string $property): bool
+ {
+ return 'display' === $property
+ || 'gap' === $property
+ || str_starts_with($property, 'flex-')
+ || str_starts_with($property, 'align-')
+ || str_starts_with($property, 'justify-')
+ || 'width' === $property
+ || 'height' === $property
+ || str_starts_with($property, 'min-')
+ || str_starts_with($property, 'max-')
+ || 'padding' === $property
+ || str_starts_with($property, 'padding-');
+ }
+
private function withoutCollapsedButtonProjectedWidths(string $prelude, string $body): ?string
{
$selectors = CssStylesheetTransformer::splitSelectorList($prelude);
diff --git a/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php b/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php
index c5cd7c65..9242e1b5 100644
--- a/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php
+++ b/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php
@@ -174,6 +174,9 @@ public function registerNativeButtonStyleRule(
$wrapperDeclarations = array();
$outerWrapperDeclarations = array();
$intrinsicWrapperDeclarations = array();
+ $responsiveAuthoredProperties = $sourceControl instanceof DOMElement
+ ? $this->responsiveAuthoredProperties($sourceControl)
+ : array();
foreach ( array(
'background-color' => $style['color']['background'] ?? '',
'color' => $style['color']['text'] ?? '',
@@ -191,6 +194,9 @@ public function registerNativeButtonStyleRule(
'padding-bottom' => $style['spacing']['padding']['bottom'] ?? '',
'padding-left' => $style['spacing']['padding']['left'] ?? '',
) as $property => $value ) {
+ if ( isset($responsiveAuthoredProperties[$property]) ) {
+ continue;
+ }
$value = trim((string) $value);
if ( '' !== $value && ! preg_match('/[{}<>;]/', $value) ) {
$declarations[] = $property . ':' . $value . '!important';
@@ -277,6 +283,54 @@ public function registerNativeButtonStyleRule(
$generatedStyles->registerNativeButton($marker, $outerWrapperRule . $wrapperRule . $intrinsicWrapperRule . '.' . $marker . '.' . $marker . '>.wp-block-button__link{' . implode(';', $declarations) . '}');
}
+ /**
+ * Block support values are resolved before responsive author CSS is replayed.
+ * Do not let the resolved value override a later authored breakpoint.
+ *
+ * @return array
+ */
+ private function responsiveAuthoredProperties(DOMElement $sourceControl): array
+ {
+ $propertySources = array(
+ 'background-color' => array( 'background', 'background-color' ),
+ 'color' => array( 'color' ),
+ 'border-color' => array( 'border', 'border-color' ),
+ 'border-style' => array( 'border', 'border-style' ),
+ 'border-width' => array( 'border', 'border-width' ),
+ 'border-radius' => array( 'border-radius' ),
+ 'font-size' => array( 'font-size' ),
+ 'font-weight' => array( 'font-weight' ),
+ 'letter-spacing' => array( 'letter-spacing' ),
+ 'line-height' => array( 'line-height' ),
+ 'text-transform' => array( 'text-transform' ),
+ 'padding-top' => array( 'padding', 'padding-top' ),
+ 'padding-right' => array( 'padding', 'padding-right' ),
+ 'padding-bottom' => array( 'padding', 'padding-bottom' ),
+ 'padding-left' => array( 'padding', 'padding-left' ),
+ );
+ $properties = array_values(array_unique(array_merge(...array_values($propertySources))));
+ $declared = $this->styleResolver->authorDeclaredPropertyValues(
+ $sourceControl,
+ $properties
+ );
+ $conditional = $this->styleResolver->conditionalAuthorDeclaredPropertyValues($sourceControl, $properties);
+ $responsive = array();
+ foreach ( $propertySources as $property => $sources ) {
+ $values = array();
+ $hasConditionalDeclaration = false;
+ foreach ( $sources as $source ) {
+ foreach ( $declared[$source] ?? array() as $value ) {
+ $values[CssValueInspector::comparable($value)] = true;
+ }
+ $hasConditionalDeclaration = $hasConditionalDeclaration || isset($conditional[$source]);
+ }
+ if ( $hasConditionalDeclaration && count($values) > 1 ) {
+ $responsive[$property] = true;
+ }
+ }
+ return $responsive;
+ }
+
public function registerDirectFlexButton(string $marker, DOMElement $control, GeneratedSupportStylesheetState $generatedStyles): void
{
$parent = $control->parentNode;
diff --git a/php-transformer/src/HtmlToBlocks/Style/SourceBlockAttributeProjector.php b/php-transformer/src/HtmlToBlocks/Style/SourceBlockAttributeProjector.php
index 278bd861..f44b5867 100644
--- a/php-transformer/src/HtmlToBlocks/Style/SourceBlockAttributeProjector.php
+++ b/php-transformer/src/HtmlToBlocks/Style/SourceBlockAttributeProjector.php
@@ -166,7 +166,7 @@ private function projectButtonAttributes(
if ( $facts->isDirectChildOfAuthorFlexLayout ) {
$this->generatedStyleProjector->registerDirectFlexButton($controlMarker, $logicalControl, $context->generatedStyles);
}
- self::registerButtonWidth($attrs, $controlMarker, $context, $this->generatedStyleProjector);
+ $this->registerButtonWidth($attrs, $controlMarker, $logicalControl, $context);
}
}
if ( '' !== $controlMarker && '' !== $presentationPath && $presentationPath !== $logicalControlPath ) {
@@ -190,7 +190,7 @@ private function projectButtonAttributes(
: 'blocks-engine-native-button-alignment-' . $nativeButtonTextAlignment;
$attrs['className'] = SourceDom::mergeClassNames((string) ($attrs['className'] ?? ''), $nativeButtonMarker);
$this->generatedStyleProjector->registerNativeButtonStyleRule($nativeButtonMarker, $hasNativeButtonColor ? $attrs : array(), $context->generatedStyles, $nativeButtonTextAlignment);
- self::registerButtonWidth($attrs, $nativeButtonMarker, $context, $this->generatedStyleProjector);
+ $this->registerButtonWidth($attrs, $nativeButtonMarker, $logicalControl, $context);
}
return $attrs;
}
@@ -261,12 +261,20 @@ private function buttonLabelHasAuthoredColor(
}
/** @param array $attrs */
- private static function registerButtonWidth(array $attrs, string $marker, SourceBlockAttributeProjectionContext $context, GeneratedBlockStyleProjector $generatedStyleProjector): void
+ private function registerButtonWidth(array $attrs, string $marker, DOMElement $sourceControl, SourceBlockAttributeProjectionContext $context): void
{
$buttonWidth = (int) ($attrs['width'] ?? 0);
- if ( in_array($buttonWidth, array( 25, 50, 75, 100 ), true) ) {
- $generatedStyleProjector->registerButtonWidth($marker, $buttonWidth, $context->generatedStyles);
+ if ( ! in_array($buttonWidth, array( 25, 50, 75, 100 ), true) ) {
+ return;
}
+ if ( 100 === $buttonWidth ) {
+ foreach ( $this->styleResolver->authorDeclaredPropertyValues($sourceControl, array( 'width' ))['width'] ?? array() as $value ) {
+ if ( '100%' !== CssValueInspector::comparable($value) ) {
+ return;
+ }
+ }
+ }
+ $this->generatedStyleProjector->registerButtonWidth($marker, $buttonWidth, $context->generatedStyles);
}
/** @param array> $innerBlocks */
diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php
index a2a33ca1..ea97bf52 100644
--- a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php
+++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php
@@ -1004,6 +1004,30 @@ public function authorDeclaredPropertyValues(DOMElement $element, array $propert
return $declared;
}
+ /**
+ * Author-declared values from conditional rules, such as media queries.
+ *
+ * @param array $properties
+ * @return array>
+ */
+ public function conditionalAuthorDeclaredPropertyValues(DOMElement $element, array $properties): array
+ {
+ sort($properties, SORT_STRING);
+ $wanted = array_fill_keys($properties, true);
+ $declared = array();
+ foreach ( $this->styleRuleCandidates($element, 'conditional') as $rule ) {
+ if ( ! $this->matchesCssSelector($element, $rule['selector']) ) {
+ continue;
+ }
+ foreach ( $rule['declarations'] as $property => $value ) {
+ if ( isset($wanted[ strtolower((string) $property) ]) ) {
+ $declared[ strtolower((string) $property) ][] = $this->context->cssComparableValue((string) $value);
+ }
+ }
+ }
+ return $declared;
+ }
+
/**
* One `text-align` declaration on a container carrier restores its whole
* subtree, which is the source's own inheritance semantics: it covers block
diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php
index 6305e5e0..4044964a 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -1353,7 +1353,7 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter
$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'] ?? ''), '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($flexAnchorButtonCss, '> :where(.wp-block-button__link){display:flex!important;align-items:center!important;gap:1rem!important') && str_contains($flexAnchorButtonCss, 'blocks-engine-richtext-marker') && str_contains($flexAnchorButtonCss, '{flex:1}'), 'styled anchor root and descendant selectors project through the generated marker with priority over core button defaults');
$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');
@@ -1429,6 +1429,18 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter
$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');
+$responsiveFullWidthButton = ( new HtmlTransformer() )->transform(
+ ''
+)->toArray();
+$responsiveFullWidthButtonMarkup = (string) ($responsiveFullWidthButton['serialized_blocks'] ?? '');
+$responsiveFullWidthButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $responsiveFullWidthButton['assets'] ?? array()));
+$responsiveFullWidthButtonMarker = preg_match('/\bblocks-engine-control-[^\s"]+/', $responsiveFullWidthButtonMarkup, $matches) ? $matches[0] : '';
+$assert(str_contains($responsiveFullWidthButtonCss, '@media(min-width:992px)') && str_contains($responsiveFullWidthButtonCss, 'width:auto'), 'responsive full-width button retains its desktop intrinsic-width author rule');
+$assert(str_contains($responsiveFullWidthButtonMarkup, '
Learn more'
)->toArray();
diff --git a/php-transformer/tests/fixtures/parity/html-responsive-grid-cta-intrinsic-width.json b/php-transformer/tests/fixtures/parity/html-responsive-grid-cta-intrinsic-width.json
index c6bcebda..2e09c5ed 100644
--- a/php-transformer/tests/fixtures/parity/html-responsive-grid-cta-intrinsic-width.json
+++ b/php-transformer/tests/fixtures/parity/html-responsive-grid-cta-intrinsic-width.json
@@ -34,7 +34,7 @@
{ "path": "assets.2.content", "assert": "contains", "value": ".wp-block-buttons{width:max-content;max-width:100%}" },
{ "path": "assets.2.content", "assert": "contains", "value": ".wp-block-button{width:max-content;max-width:100%}" },
{ "path": "assets.2.content", "assert": "contains", "value": "box-sizing:border-box;width:max-content;max-width:100%" },
- { "path": "assets.1.content", "assert": "contains", "value": "padding:12px 24px;border-radius:50px" },
+ { "path": "assets.1.content", "assert": "contains", "value": "padding:12px 24px!important;border-radius:50px" },
{ "path": "assets.1.content", "assert": "contains", "value": "@media(min-width:601px){.mobile-variant{display:none}.desktop-variant{display:grid}}" },
{ "path": "fallbacks", "assert": "count", "count": 0 }
]
diff --git a/php-transformer/tests/unit/button-signal-classifier.php b/php-transformer/tests/unit/button-signal-classifier.php
index fbf67f1a..166358b0 100644
--- a/php-transformer/tests/unit/button-signal-classifier.php
+++ b/php-transformer/tests/unit/button-signal-classifier.php
@@ -82,7 +82,7 @@
$streamRow = ( new HtmlTransformer() )->transform('', array())->toArray();
$streamRowCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $streamRow['assets'] ?? array()));
$assert('core/buttons' === ($streamRow['blocks'][0]['blockName'] ?? '') && 2 === count($streamRow['blocks'][0]['innerBlocks'] ?? array()), '22: direct anchor CTA rows group explicit stylesheet surfaces as buttons', json_encode($streamRow['blocks'] ?? array()));
-$assert(2 === substr_count($streamRowCss, '> :where(.wp-block-button__link)') && str_contains($streamRowCss, '{display:inline-flex;padding:10px 16px;background:#135e96'), '23: direct anchor CTA stylesheet selectors remain on both rendered button links', $streamRowCss);
+$assert(2 === substr_count($streamRowCss, '> :where(.wp-block-button__link)') && str_contains($streamRowCss, '{display:inline-flex!important;padding:10px 16px!important;background:#135e96'), '23: direct anchor CTA stylesheet selectors remain on both rendered button links', $streamRowCss);
$buttonResult = ( new HtmlTransformer() )->transform('', array())->toArray();
$nativeButton = $buttonResult['blocks'][0]['innerBlocks'][0] ?? array();
diff --git a/php-transformer/tests/unit/engine-support-css-asset.php b/php-transformer/tests/unit/engine-support-css-asset.php
index 3c881894..59d858a8 100644
--- a/php-transformer/tests/unit/engine-support-css-asset.php
+++ b/php-transformer/tests/unit/engine-support-css-asset.php
@@ -54,7 +54,7 @@
$assert(1 === count($authorAssets), 'G2: transform emits exactly one author-css asset');
$normalizedAuthorCss = preg_replace('/\s+/', '', (string) ($authorAssets[0]['content'] ?? '')) ?? '';
$assert(
- '@layercontract;.contract-author-only{color:#123456}.desktop-nava{color:#fff}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons){width:100%}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons)>:where(.wp-block-button){width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons)>:where(.wp-block-button)>:where(.wp-block-button__link){width:100%!important;max-width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):not(.blocks-engine-specificity-class-6494fb2a0d77-1)>:where(.wp-block-button__link){display:inline-flex;padding:1rem;background:#123456}@media(max-width:700px){.desktop-nav{display:none}.mobile-nav{background:rgba(0,0,0,.9)}}' === $normalizedAuthorCss,
+ '@layercontract;.contract-author-only{color:#123456}.desktop-nava{color:#fff}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons){width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons)>:where(.wp-block-button){width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons)>:where(.wp-block-button)>:where(.wp-block-button__link){width:100%!important;max-width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):not(.blocks-engine-specificity-class-6494fb2a0d77-1)>:where(.wp-block-button__link){display:inline-flex!important;padding:1rem!important;background:#123456}@media(max-width:700px){.desktop-nav{display:none}.mobile-nav{background:rgba(0,0,0,.9)}}' === $normalizedAuthorCss,
'G2: author-css contains only its leading at-rule preamble and rewritten author stylesheet'
);
$assert('author' === ($authorAssets[0]['stylesheet_placement'] ?? ''), 'G4: author-css record declares author placement');