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');