diff --git a/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php b/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php
index c9c57c60..872203fb 100644
--- a/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php
+++ b/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php
@@ -134,20 +134,27 @@ private function rewriteStyleRule(string $prelude, string $body, AuthorStyleshee
return null !== $mixedButtonProjection ? $mixedButtonProjection : $projectedPrelude . '{' . $body . '}';
}
[ $geometry, $inner ] = $this->splitDirectButtonGeometryDeclarations($body);
+ $nonButtonGeometryPrelude = $this->withoutButtonPresentationProjectionSelectors($projectedPrelude, $directWrapperPrelude);
+ $nonButtonGeometryDeclarations = array_filter(array( $geometry, $this->collapsedButtonKeywordWidthDeclarations($body) ));
+ $nonButtonGeometry = '' === $nonButtonGeometryPrelude || array() === $nonButtonGeometryDeclarations
+ ? ''
+ : $nonButtonGeometryPrelude . '{' . implode(';', $nonButtonGeometryDeclarations) . '}';
if ( '' === $geometry ) {
- return '' === $inner ? '' : $projectedPrelude . '{' . $inner . '}';
+ return ( '' === $inner ? '' : $projectedPrelude . '{' . $inner . '}' ) . $nonButtonGeometry;
}
- return $this->withButtonWrapperInnerFill($directWrapperPrelude, $geometry, '' === $inner ? '' : $projectedPrelude . '{' . $inner . '}');
+ return $this->withButtonWrapperInnerFill($directWrapperPrelude, $geometry, ( '' === $inner ? '' : $projectedPrelude . '{' . $inner . '}' ) . $nonButtonGeometry);
}
[ $layout, $control ] = $this->splitButtonPresentationDeclarations($body);
+ $nonButtonLayoutPrelude = $this->withoutButtonPresentationProjectionSelectors($projectedPrelude, $wrapperPrelude);
+ $nonButtonLayout = '' === $nonButtonLayoutPrelude ? '' : $nonButtonLayoutPrelude . '{' . $layout . '}';
if ( '' === $layout ) {
return '' === $control ? '' : $projectedPrelude . '{' . $control . '}';
}
if ( '' === $control ) {
- return $this->withButtonWrapperInnerFill($wrapperPrelude, $layout);
+ return $this->withButtonWrapperInnerFill($wrapperPrelude, $layout, $nonButtonLayout);
}
- return $this->withButtonWrapperInnerFill($wrapperPrelude, $layout, $projectedPrelude . '{' . $control . '}');
+ return $this->withButtonWrapperInnerFill($wrapperPrelude, $layout, $projectedPrelude . '{' . $control . '}' . $nonButtonLayout);
}
/**
@@ -344,7 +351,7 @@ private function buttonPresentationWrapperPrelude(string $prelude, AuthorStylesh
? $context->selectorProjections->controlMarker($path)
: '';
if ( '' === $marker ) {
- continue 2;
+ continue;
}
$markers[] = $marker;
}
@@ -376,7 +383,7 @@ private function directButtonGeometryWrapperPrelude(string $prelude, AuthorStyle
$path = $element->getNodePath() ?? '';
$marker = $context->selectorProjections->controlMarker($path);
if ( '' === $marker || $context->selectorProjections->isButtonPresentationPath($path) ) {
- continue 2;
+ continue;
}
$rewritten[] = $this->projectControlSelector($selector, $parsed, $marker, $context, true);
}
@@ -449,15 +456,60 @@ private function splitButtonPresentationDeclarations(string $body): array
return array( implode(';', $layout), implode(';', $control) );
}
+ /**
+ * Geometry moved to a presentation wrapper must remain on other source
+ * elements matched by the same shared selector.
+ */
+ private function withoutButtonPresentationProjectionSelectors(string $projectedPrelude, string $wrapperPrelude): string
+ {
+ $selectors = CssStylesheetTransformer::splitSelectorList($projectedPrelude);
+ if ( null === $selectors || '' === $wrapperPrelude ) {
+ return $projectedPrelude;
+ }
+ preg_match_all('/:where\(\.([^)]*)\)/', $wrapperPrelude, $matches);
+ $markers = array_unique($matches[1] ?? array());
+ if ( array() === $markers ) {
+ return $projectedPrelude;
+ }
+ return implode(',', array_filter($selectors, static function (string $selector) use ($markers): bool {
+ foreach ( $markers as $marker ) {
+ $markerSelector = ':where(.' . $marker . ')';
+ if ( str_contains($selector, $markerSelector) && ! str_contains($selector, ':not(' . $markerSelector . ')') ) {
+ return false;
+ }
+ }
+ return true;
+ }));
+ }
+
private function withButtonWrapperInnerFill(string $wrapperPrelude, string $layoutCss, string $rest = ''): string
{
$css = $wrapperPrelude . '{' . $layoutCss . '}';
- if ( CssValueInspector::hasDefiniteWidth($layoutCss) ) {
+ $hasDefiniteWidth = CssValueInspector::hasDefiniteWidth($layoutCss);
+ $hasDefiniteHeight = CssValueInspector::hasDefiniteHeight($layoutCss);
+ $hasAutoHeight = CssValueInspector::hasAutoHeight($layoutCss);
+ $hasMinimumHeight = CssValueInspector::hasAuthoredMinimumHeight($layoutCss);
+ if ( $hasDefiniteWidth || $hasDefiniteHeight || $hasAutoHeight || $hasMinimumHeight ) {
$selectors = CssStylesheetTransformer::splitSelectorList($wrapperPrelude) ?? array( $wrapperPrelude );
$button = implode(',', array_map(static fn (string $selector): string => rtrim($selector) . '> :where(.wp-block-button)', $selectors));
$link = implode(',', array_map(static fn (string $selector): string => rtrim($selector) . '> :where(.wp-block-button)> :where(.wp-block-button__link)', $selectors));
- $css .= $button . '{width:100%!important}'
- . $link . '{width:100%!important;max-width:100%!important}';
+ if ( $hasDefiniteWidth ) {
+ $css .= $button . '{width:100%!important}'
+ . $link . '{width:100%!important;max-width:100%!important}';
+ }
+ if ( $hasDefiniteHeight ) {
+ $css .= $button . '{height:100%!important}'
+ . $link . '{height:100%!important}';
+ } elseif ( $hasAutoHeight ) {
+ $css .= $button . '{height:auto!important}'
+ . $link . '{height:auto!important}';
+ }
+ if ( $hasMinimumHeight ) {
+ // Percentage heights cannot resolve through an auto-height wrapper.
+ // Inherit the wrapper's authored computed minimum on both carriers.
+ $css .= $button . '{min-height:inherit!important}'
+ . $link . '{min-height:inherit!important}';
+ }
}
return $css . $rest;
}
@@ -481,6 +533,20 @@ private function isCollapsedButtonKeywordWidth(string $property, string $value):
|| (str_starts_with($property, '--') && str_contains($property, 'width'));
}
+ private function collapsedButtonKeywordWidthDeclarations(string $body): string
+ {
+ $collapsed = array();
+ foreach ( CssValueSplitter::splitTopLevel($body, array( ';' )) as $declaration ) {
+ $colon = strpos($declaration, ':');
+ $name = strtolower(trim(false === $colon ? $declaration : substr($declaration, 0, $colon)));
+ $value = false === $colon ? '' : trim(substr($declaration, $colon + 1));
+ if ( false !== $colon && $this->isCollapsedButtonKeywordWidth($name, $value) ) {
+ $collapsed[] = $declaration;
+ }
+ }
+ return implode(';', $collapsed);
+ }
+
private function isButtonWrapperLayoutProperty(string $property): bool
{
return in_array($property, array(
@@ -963,7 +1029,7 @@ private function rewriteSourceTagTypes(string $selector, array $parsed, AuthorSt
private function projectControlSelector(string $selector, array $parsed, string $marker, AuthorStylesheetProjectionContext $context, bool $wrapper = false): string
{
$suffix = null === $parsed['pseudo_state_suffix_span'] ? '' : substr($selector, $parsed['pseudo_state_suffix_span']['start']);
- return ':where(.' . $marker . ')' . ($wrapper ? ':where(.wp-block-buttons)' : $this->selectorSpecificityShims($parsed, $context) . '> :where(.wp-block-button__link)') . $suffix;
+ return ':where(.' . $marker . ')' . $this->selectorSpecificityShims($parsed, $context) . ($wrapper ? ':where(.wp-block-buttons)' : '> :where(.wp-block-button__link)') . $suffix;
}
/** @param array $parsed */
diff --git a/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php b/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php
index ad1d3874..e7099497 100644
--- a/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php
+++ b/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php
@@ -56,4 +56,50 @@ public static function hasDefiniteWidth(string $css): bool
}
return false;
}
+
+ public static function hasDefiniteHeight(string $css): bool
+ {
+ return self::hasAuthoredLengthProperty($css, 'height');
+ }
+
+ public static function hasAuthoredMinimumHeight(string $css): bool
+ {
+ return self::hasAuthoredLengthProperty($css, 'min-height');
+ }
+
+ private static function hasAuthoredLengthProperty(string $css, string $property): bool
+ {
+ foreach ( CssValueSplitter::splitTopLevel($css, array( ';' )) as $declaration ) {
+ $colon = strpos($declaration, ':');
+ if ( false === $colon || $property !== strtolower(trim(substr($declaration, 0, $colon))) ) {
+ continue;
+ }
+ $value = strtolower(self::withoutImportant(substr($declaration, $colon + 1)));
+ if ( '' === $value || in_array($value, array( 'auto', 'inherit', 'initial', 'unset', 'none', 'min-content', 'max-content', 'fit-content', 'content' ), true) ) {
+ continue;
+ }
+ // A bare custom property may resolve to a keyword such as auto. CSS math
+ // functions, including ones containing vars, remain authored lengths.
+ if ( str_contains($value, 'var(')
+ && 1 !== preg_match('/^(?:calc|min|max|clamp|round|mod|rem|sin|cos|tan|asin|acos|atan|atan2|pow|sqrt|hypot|log|exp|abs|sign)\(/', $value) ) {
+ continue;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public static function hasAutoHeight(string $css): bool
+ {
+ foreach ( CssValueSplitter::splitTopLevel($css, array( ';' )) as $declaration ) {
+ $colon = strpos($declaration, ':');
+ if ( false !== $colon
+ && 'height' === strtolower(trim(substr($declaration, 0, $colon)))
+ && 'auto' === strtolower(self::withoutImportant(substr($declaration, $colon + 1)))
+ ) {
+ return true;
+ }
+ }
+ return false;
+ }
}
diff --git a/php-transformer/tests/unit/button-wrapper-inner-fill.php b/php-transformer/tests/unit/button-wrapper-inner-fill.php
index e53f07c6..6ae1cc24 100644
--- a/php-transformer/tests/unit/button-wrapper-inner-fill.php
+++ b/php-transformer/tests/unit/button-wrapper-inner-fill.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
/**
- * A definite width on `.wp-block-buttons` must fill the inner link (issue #1303).
+ * Definite source-owned core/buttons geometry must fill the native inner carriers.
*/
require dirname(__DIR__, 2) . '/vendor/autoload.php';
@@ -47,6 +47,77 @@
$css
);
+$height = ( new HtmlTransformer() )->transform(
+ ''
+ . 'GET A QUOTE'
+)->toArray();
+$heightCss = '';
+foreach ( $height['assets'] ?? array() as $asset ) {
+ if ( is_array($asset) && 'css' === ( $asset['kind'] ?? '' ) ) {
+ $heightCss .= (string) ( $asset['content'] ?? '' );
+ }
+}
+
+$assert(
+ (bool) preg_match('/wp-block-buttons[^}]*\{[^}]*height:45\.8594px/', $heightCss),
+ '4: source-authored height stays on the outer core/buttons carrier',
+ $heightCss
+);
+$assert(
+ (bool) preg_match('/wp-block-buttons\)> :where\(\.wp-block-button\)\{height:100%!important\}[^\n]*wp-block-button__link\)\{height:100%!important\}/', $heightCss),
+ '5: a definite outer height fills the nested core/button and link from the authored carrier rule',
+ $heightCss
+);
+
+$autoHeight = ( new HtmlTransformer() )->transform(
+ ''
+ . 'GET A QUOTE'
+)->toArray();
+$autoHeightCss = implode('', array_map(static fn (array $asset): string => 'css' === ( $asset['kind'] ?? '' ) ? (string) ( $asset['content'] ?? '' ) : '', $autoHeight['assets'] ?? array()));
+$assert(
+ ! str_contains($autoHeightCss, 'height:100%!important'),
+ '6: auto-height does not opt into inner carrier fill',
+ $autoHeightCss
+);
+
+$responsiveHeight = ( new HtmlTransformer() )->transform(
+ ''
+ . 'GET A QUOTE'
+)->toArray();
+$responsiveHeightCss = implode('', array_map(static fn (array $asset): string => 'css' === ( $asset['kind'] ?? '' ) ? (string) ( $asset['content'] ?? '' ) : '', $responsiveHeight['assets'] ?? array()));
+$assert(
+ (bool) preg_match('/@media\(max-width:600px\)\{[^}]*height:auto[^}]*\}[^@]*height:auto!important/', $responsiveHeightCss),
+ '7: responsive auto-height explicitly clears the nested carrier fill in the same condition',
+ $responsiveHeightCss
+);
+
+$mathMinimumHeight = ( new HtmlTransformer() )->transform(
+ ''
+ . 'GET A QUOTE'
+)->toArray();
+$mathMinimumHeightCss = implode('', array_map(static fn (array $asset): string => 'css' === ( $asset['kind'] ?? '' ) ? (string) ( $asset['content'] ?? '' ) : '', $mathMinimumHeight['assets'] ?? array()));
+$assert(
+ str_contains($mathMinimumHeightCss, 'min-height:max(.5px,.1175977*(var(--scaling-factor) - var(--scrollbar-width)))')
+ && 4 === substr_count($mathMinimumHeightCss, 'min-height:inherit!important'),
+ '8: variable-backed math minimum height stays on the outer carrier and is inherited by both native carriers',
+ $mathMinimumHeightCss
+);
+$assert(
+ (bool) preg_match('/@media\(max-width:600px\)\{[^}]*min-height:0[^}]*\}[^@]*min-height:inherit!important/', $mathMinimumHeightCss),
+ '9: responsive minimum-height reset reaches both native carriers in the same condition',
+ $mathMinimumHeightCss
+);
+
+$pureVariableHeight = ( new HtmlTransformer() )->transform(
+ 'GET A QUOTE'
+)->toArray();
+$pureVariableHeightCss = implode('', array_map(static fn (array $asset): string => 'css' === ( $asset['kind'] ?? '' ) ? (string) ( $asset['content'] ?? '' ) : '', $pureVariableHeight['assets'] ?? array()));
+$assert(
+ ! str_contains($pureVariableHeightCss, 'height:100%!important'),
+ '10: a bare custom-property height remains conservative because it can resolve to auto',
+ $pureVariableHeightCss
+);
+
if ( $failures > 0 ) {
fwrite(STDERR, PHP_EOL . "button wrapper inner fill tests: {$passes} passed, {$failures} FAILED" . PHP_EOL);
exit(1);
diff --git a/php-transformer/tests/unit/engine-support-css-asset.php b/php-transformer/tests/unit/engine-support-css-asset.php
index 4e649d97..296d21ce 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%!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,
+ '@layercontract;.contract-author-only{color:#123456}.desktop-nava{color:#fff}:where(.blocks-engine-control-6494fb2a0d77-3):not(.blocks-engine-specificity-class-6494fb2a0d77-1):where(.wp-block-buttons){width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):not(.blocks-engine-specificity-class-6494fb2a0d77-1):where(.wp-block-buttons)>:where(.wp-block-button){width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):not(.blocks-engine-specificity-class-6494fb2a0d77-1):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');
diff --git a/php-transformer/tools/visual-parity/package.json b/php-transformer/tools/visual-parity/package.json
index 1fd3d72a..bcf1153a 100644
--- a/php-transformer/tools/visual-parity/package.json
+++ b/php-transformer/tools/visual-parity/package.json
@@ -11,7 +11,7 @@
},
"scripts": {
"install:browsers": "playwright install chromium",
- "test": "node tests/smoke.mjs && node tests/blockquote-margin-reset.mjs && node tests/exact-fit-inline-flow.mjs && node tests/layout-shell-editor-geometry.mjs && node tests/custom-video-host-editor-geometry.mjs && node tests/issue-1493-reduced-public-fixture.mjs && node tests/responsive-document-variants.mjs && node tests/image-custom-host-promotion.mjs"
+ "test": "node tests/smoke.mjs && node tests/blockquote-margin-reset.mjs && node tests/exact-fit-inline-flow.mjs && node tests/layout-shell-editor-geometry.mjs && node tests/custom-video-host-editor-geometry.mjs && node tests/issue-1493-reduced-public-fixture.mjs && node tests/responsive-document-variants.mjs && node tests/image-custom-host-promotion.mjs && node tests/button-height-ownership.mjs"
},
"devDependencies": {
"playwright": "^1.56.0"
diff --git a/php-transformer/tools/visual-parity/tests/button-height-ownership.mjs b/php-transformer/tools/visual-parity/tests/button-height-ownership.mjs
new file mode 100644
index 00000000..306e8f6b
--- /dev/null
+++ b/php-transformer/tools/visual-parity/tests/button-height-ownership.mjs
@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { chromium } from 'playwright';
+
+const transformerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
+const sourceFixture = `Unrelated shared skin
GET A QUOTE`;
+const transformed = JSON.parse(execFileSync('php', ['-r', `
+require $argv[1] . '/vendor/autoload.php';
+$result = (new \\Automattic\\BlocksEngine\\PhpTransformer\\HtmlToBlocks\\HtmlTransformer())->transform(base64_decode($argv[2]))->toArray();
+$css = array_filter($result['assets'] ?? array(), static fn(array $asset): bool => 'css' === ($asset['kind'] ?? ''));
+echo json_encode(array('serializedBlocks' => (string) ($result['serialized_blocks'] ?? ''), 'css' => implode("\\n", array_column($css, 'content'))));
+`, transformerRoot, Buffer.from(sourceFixture).toString('base64')], { encoding: 'utf8' }));
+
+// WordPress core button CSS that competes with carried author declarations.
+const wordpressButtonCss = `.wp-block-buttons{box-sizing:border-box}.wp-block-button{box-sizing:border-box}.wp-block-button__link{box-sizing:border-box;cursor:pointer;display:inline-block;min-height:10px;padding:calc(.667em + 2px) calc(1.333em + 2px);text-align:center;word-break:break-word}`;
+const browser = await chromium.launch({ headless: true });
+try {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 400 } });
+ await page.setContent(`${transformed.serializedBlocks}`);
+ const geometry = () => page.locator('.wp-block-buttons, .wp-block-button, .wp-block-button__link').evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().height));
+
+ const desktop = await geometry();
+ assert.ok(Math.abs(desktop[0] - 52.2085) < 0.02, `desktop outer minimum height: ${desktop[0]}`);
+ assert.ok(desktop.every((height) => Math.abs(height - desktop[0]) < 0.02), `desktop carriers share the authored minimum: ${desktop}`);
+
+ await page.setViewportSize({ width: 600, height: 400 });
+ const mobile = await geometry();
+ assert.ok(Math.abs(mobile[0] - 46) < 0.02, `mobile explicit math height: ${mobile[0]}`);
+ assert.ok(mobile.every((height) => Math.abs(height - mobile[0]) < 0.02), `mobile carriers fill the authored height: ${mobile}`);
+ console.log(`Button height ownership geometry: desktop=${desktop.join(',')} mobile=${mobile.join(',')}`);
+} finally {
+ await browser.close();
+}
+
+console.log('Button height ownership geometry passed');