diff --git a/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php b/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php
index a3d58f763..c93fc81eb 100644
--- a/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php
+++ b/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php
@@ -284,8 +284,14 @@ public function registerNativeButtonStyleRule(
// width; an intrinsic one would shrink it to its label.
} elseif ( ! $hasAuthoredWidth && in_array(CssValueInspector::comparable((string) ($sourceDeclarations['display'] ?? '')), array( 'flex', 'inline-flex', 'inline-block', 'inline-grid', 'inline-table' ), true) ) {
if ( 'center' === $inheritedTextAlignment ) {
- $outerWrapperDeclarations[] = 'width:100%';
- $outerWrapperDeclarations[] = 'max-width:100%';
+ // A flex or grid item shrinks to its content unless told to
+ // fill. In block flow the wrapper already spans its line,
+ // inside the margins the control's rules may give it; a
+ // 100% width would push it past them and off center.
+ if ( ! $this->sourceParentIsBlockContainer($sourceControl) ) {
+ $outerWrapperDeclarations[] = 'width:100%';
+ $outerWrapperDeclarations[] = 'max-width:100%';
+ }
} else {
$outerWrapperDeclarations[] = 'width:max-content';
$outerWrapperDeclarations[] = 'max-width:100%';
@@ -817,6 +823,17 @@ private function sourceControlFillsFlowLine(DOMElement $sourceControl, array $so
return in_array($parentDisplay, array( '', 'block', 'flow-root', 'list-item' ), true);
}
+ private function sourceParentIsBlockContainer(DOMElement $sourceControl): bool
+ {
+ $parent = $sourceControl->parentNode;
+ if ( ! $parent instanceof DOMElement ) {
+ return false;
+ }
+ $display = CssValueInspector::comparable((string) ($this->styleResolver->cssDeclarations($this->styleResolver->specificityResolvedPresentationStyle($parent))['display'] ?? ''));
+
+ return in_array($display, array( '', 'block', 'flow-root', 'list-item', 'inline-block', 'table-cell' ), true);
+ }
+
private function sourceControlFillsGridCell(DOMElement $sourceControl, array $sourceDeclarations): bool
{
if ( ! in_array(CssValueInspector::comparable((string) ($sourceDeclarations['display'] ?? '')), array( 'flex', 'grid', 'table' ), true) ) {
diff --git a/php-transformer/tests/fixtures/centered-button-margins.html b/php-transformer/tests/fixtures/centered-button-margins.html
new file mode 100644
index 000000000..5f4a7eefa
--- /dev/null
+++ b/php-transformer/tests/fixtures/centered-button-margins.html
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/php-transformer/tests/unit/inherited-text-align-button-justification.php b/php-transformer/tests/unit/inherited-text-align-button-justification.php
index 826ae7291..9876512b9 100644
--- a/php-transformer/tests/unit/inherited-text-align-button-justification.php
+++ b/php-transformer/tests/unit/inherited-text-align-button-justification.php
@@ -6,7 +6,7 @@
* `text-align` becomes a core/buttons flex wrapper whose default main-axis
* alignment is left. The inherited alignment must be restated as
* layout.justifyContent or the source's centering is lost. The restated
- * justification only has room to act when the wrapper fills its parent, so
+ * justification only has room to act when the wrapper spans its line, so
* the generated wrapper rule must not shrink-wrap a centered wrapper.
*
* Regression for Automattic/blocks-engine#2150: the wrapper consulted only the
@@ -93,8 +93,8 @@
$centeredButtons
);
-// The restated justification has no room to act on a shrink-wrapped wrapper:
-// the generated wrapper rule must fill the parent instead, the way the source
+// The restated justification has no room to act on a shrink-wrapped wrapper.
+// In block flow the flex wrapper spans its line on its own, the way the source
// block the control sat in did, while the button itself still hugs its label.
$centeredMarker = $controlMarkerOf($centeredButtons);
$centeredCss = $cssFor($centeredOut);
@@ -103,11 +103,6 @@
'the centered control carries a generated control marker',
$centeredButtons
);
-$assert(
- 1 === preg_match('/' . preg_quote($centeredMarker, '/') . '\.' . preg_quote($centeredMarker, '/') . '\.wp-block-buttons\{width:100%;max-width:100%\}/', $centeredCss),
- 'a centered inline control fills its parent buttons wrapper instead of shrink-wrapping it',
- $centeredCss
-);
$assert(
! preg_match('/' . preg_quote($centeredMarker, '/') . '\.' . preg_quote($centeredMarker, '/') . '\.wp-block-buttons\{width:max-content/', $centeredCss),
'the centered buttons wrapper is not pinned to max-content',
diff --git a/php-transformer/tools/visual-parity/package.json b/php-transformer/tools/visual-parity/package.json
index d601e952f..79da4bf27 100644
--- a/php-transformer/tools/visual-parity/package.json
+++ b/php-transformer/tools/visual-parity/package.json
@@ -22,7 +22,7 @@
"test:navigation-cluster-control": "node tests/navigation-cluster-trailing-control.mjs",
"test:conditional-native-button": "node tests/conditional-native-button.mjs",
"test:declared-body": "node tests/declared-document-role-button.mjs",
- "test": "node tests/smoke.mjs && node tests/blockquote-margin-reset.mjs && node tests/synthetic-inline-image-percentage.mjs && node tests/linked-flex-item-image.mjs && node tests/inline-block-item-spacing.mjs && node tests/exact-fit-inline-flow.mjs && node tests/layout-shell-editor-dom.mjs && node tests/layout-shell-editor-geometry.mjs && node tests/layout-shell-editor-selectability.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/empty-group-editor-height.mjs && node tests/button-height-ownership.mjs && node tests/button-padding-height.mjs && node tests/positioned-button-containing-block.mjs && node tests/positioned-button-translate-once.mjs && node tests/positioned-button-shrink-to-fit.mjs && node tests/inline-control-line-box.mjs && node tests/flex-button-row-shrink-to-fit.mjs && node tests/navigation-layout-default.mjs && node tests/navigation-hover-focus-projection.mjs && node tests/navigation-overlay-duplicate-collapse.mjs && node tests/navigation-responsive-desktop-justification.mjs && node tests/navigation-overlay-source-breakpoint.mjs && node tests/navigation-open-button-source-placement.mjs && node tests/form-row-two-column-geometry.mjs && node tests/color-scheme-projection.mjs && node tests/shared-shell-route-cascade.mjs && node tests/responsive-counterpart-text-sync.mjs && node tests/positioned-carousel-visibility.mjs && node tests/authored-carousel-source-scope.mjs && node tests/empty-overlay-opacity.mjs && node tests/authored-carousel-stage-geometry.mjs && node tests/gallery-stage-controls.mjs && node tests/nested-responsive-font-size.mjs && node tests/responsive-layered-block-margin.mjs && node tests/svg-checkable-control.mjs && node tests/source-root-includes.mjs && node tests/captured-choice-group-runtime.mjs && node tests/differential-corpus.mjs",
+ "test": "node tests/smoke.mjs && node tests/blockquote-margin-reset.mjs && node tests/synthetic-inline-image-percentage.mjs && node tests/linked-flex-item-image.mjs && node tests/centered-button-margins.mjs && node tests/inline-block-item-spacing.mjs && node tests/exact-fit-inline-flow.mjs && node tests/layout-shell-editor-dom.mjs && node tests/layout-shell-editor-geometry.mjs && node tests/layout-shell-editor-selectability.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/empty-group-editor-height.mjs && node tests/button-height-ownership.mjs && node tests/button-padding-height.mjs && node tests/positioned-button-containing-block.mjs && node tests/positioned-button-translate-once.mjs && node tests/positioned-button-shrink-to-fit.mjs && node tests/inline-control-line-box.mjs && node tests/flex-button-row-shrink-to-fit.mjs && node tests/navigation-layout-default.mjs && node tests/navigation-hover-focus-projection.mjs && node tests/navigation-overlay-duplicate-collapse.mjs && node tests/navigation-responsive-desktop-justification.mjs && node tests/navigation-overlay-source-breakpoint.mjs && node tests/navigation-open-button-source-placement.mjs && node tests/form-row-two-column-geometry.mjs && node tests/color-scheme-projection.mjs && node tests/shared-shell-route-cascade.mjs && node tests/responsive-counterpart-text-sync.mjs && node tests/positioned-carousel-visibility.mjs && node tests/authored-carousel-source-scope.mjs && node tests/empty-overlay-opacity.mjs && node tests/authored-carousel-stage-geometry.mjs && node tests/gallery-stage-controls.mjs && node tests/nested-responsive-font-size.mjs && node tests/responsive-layered-block-margin.mjs && node tests/svg-checkable-control.mjs && node tests/source-root-includes.mjs && node tests/captured-choice-group-runtime.mjs && node tests/differential-corpus.mjs",
"test:differential": "node tests/differential-corpus.mjs"
},
"devDependencies": {
diff --git a/php-transformer/tools/visual-parity/tests/centered-button-margins.mjs b/php-transformer/tools/visual-parity/tests/centered-button-margins.mjs
new file mode 100644
index 000000000..b14472667
--- /dev/null
+++ b/php-transformer/tools/visual-parity/tests/centered-button-margins.mjs
@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { chromium } from 'playwright';
+
+// A centered inline-block control with side margins keeps those margins
+// inside its block-flow item: the item is as wide as in the source and the
+// control sits at the same offset within it.
+const root = new URL('../../..', import.meta.url).pathname;
+const source = readFileSync(`${root}/tests/fixtures/centered-button-margins.html`, 'utf8');
+const result = JSON.parse(execFileSync('php', ['-r', 'require $argv[1] . "/vendor/autoload.php"; echo json_encode((new Automattic\\BlocksEngine\\PhpTransformer\\HtmlToBlocks\\HtmlTransformer())->transform(base64_decode($argv[2]))->toArray());', root, Buffer.from(source).toString('base64')], { encoding: 'utf8' }));
+const css = result.assets.filter(asset => asset.kind === 'css').map(asset => asset.content).join('\n');
+// WordPress renders a flex layout's justifyContent as a justification class.
+const blocks = result.serialized_blocks.replace(/\s*${blocks}`;
+const browser = await chromium.launch({ headless: true });
+try {
+ const measurements = [];
+ for (const html of [source, target]) {
+ const page = await browser.newPage({ viewport: { width: 1024, height: 600 } });
+ await page.setContent(html);
+ measurements.push(await page.evaluate(() => [...document.querySelectorAll('li')].map(item => {
+ const box = item.getBoundingClientRect();
+ return [Math.round(box.width), Math.round(item.querySelector('button').getBoundingClientRect().x - box.x)];
+ })));
+ await page.close();
+ }
+ console.log(JSON.stringify({ source: measurements[0], converted: measurements[1] }));
+ assert.deepEqual(measurements[1], measurements[0]);
+} finally {
+ await browser.close();
+}