From eb723c45140f934d79044e279033db475b9ea4c8 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 14 Oct 2026 09:27:33 -0400 Subject: [PATCH 1/5] fix: describe the element that paints an off-screen native submit --- .../Classification/FormControlClassifier.php | 61 +++++++++++++++++++ .../HtmlToBlocks/Style/CssValueInspector.php | 22 +++++++ .../Style/FormLayoutGraphBuilder.php | 5 +- .../Style/FormPresentationGraphBuilder.php | 8 ++- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php b/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php index ebe9c690f..b771d7d0c 100644 --- a/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php +++ b/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php @@ -4,6 +4,8 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Classification; use Automattic\BlocksEngine\PhpTransformer\Contract\CaptureEvidence; +use Automattic\BlocksEngine\PhpTransformer\Css\CssRuleAnalyzer; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueInspector; use DOMElement; final class FormControlClassifier @@ -85,6 +87,65 @@ public static function controlElements(DOMElement $form): array return $controls; } + /** + * The shared control enumeration, with each control replaced by the element + * that paints it. Layout and presentation describe these boxes; control + * semantics stay on `controlElements()`, index for index. + * + * @return array + */ + public static function presentedControlElements(DOMElement $form): array + { + return array_map(self::presentingElement(...), self::controlElements($form)); + } + + /** + * The element that paints a native submit control. + * + * A site builder may move its native submit off-screen with inline styles + * and paint an adjacent sibling carrying the same caption in its place (a + * styled link with no destination). That sibling is the submit a visitor + * sees and the box a provider button reproduces. Any other control, or an + * ambiguous match, paints itself. + */ + public static function presentingElement(DOMElement $control): DOMElement + { + $parent = $control->parentNode; + if ( ! $parent instanceof DOMElement || ! in_array(self::controlType($control), array( 'submit', 'image' ), true) ) { + return $control; + } + $declarations = array_column(CssRuleAnalyzer::declarations($control->getAttribute('style'), array( 'position', 'left', 'top' )), 'value', 'name'); + if ( ! CssValueInspector::isMovedOffscreenBox($declarations) ) { + return $control; + } + $caption = self::normalizedCaption('input' === strtolower($control->tagName) ? ( '' !== trim($control->getAttribute('value')) ? $control->getAttribute('value') : $control->getAttribute('aria-label') ) : (string) $control->textContent); + if ( '' === $caption ) { + return $control; + } + $presenter = null; + foreach ( $parent->childNodes as $sibling ) { + if ( ! $sibling instanceof DOMElement || $sibling === $control || $sibling->hasAttribute('href') || self::isControlElement($sibling) || self::normalizedCaption((string) $sibling->textContent) !== $caption ) { + continue; + } + foreach ( self::CONTROL_TAGS as $tag ) { + if ( 0 < $sibling->getElementsByTagName($tag)->length ) { + continue 2; + } + } + if ( null !== $presenter ) { + return $control; + } + $presenter = $sibling; + } + + return $presenter ?? $control; + } + + private static function normalizedCaption(string $text): string + { + return strtolower(trim(preg_replace('/\s+/u', ' ', $text) ?? $text)); + } + /** * Hidden honeypots, template scaffolding, and accessibility-hidden traps * are not authored fields. aria-hidden outside a native form is closed diff --git a/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php b/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php index 9b7232211..e421f9931 100644 --- a/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php +++ b/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php @@ -204,6 +204,28 @@ public static function hidesText(string $property, string $value): bool return false; } + /** + * Whether a declaration set moves its box out of sight: an absolutely + * positioned box shifted by a negative `left` or `top` offset of at least + * 999px, the same off-screen threshold `hidesText()` applies to + * `text-indent`. + * + * @param array $declarations + */ + public static function isMovedOffscreenBox(array $declarations): bool + { + if ( ! in_array(self::comparable((string) ($declarations['position'] ?? '')), array( 'absolute', 'fixed' ), true) ) { + return false; + } + foreach ( array( 'left', 'top' ) as $property ) { + if ( 1 === preg_match('/^-(\d+(?:\.\d+)?)(?:px)?$/D', self::comparable((string) ($declarations[$property] ?? '')), $match) && (float) $match[1] >= 999 ) { + return true; + } + } + + return false; + } + /** * Whether a declaration set clips its box out of sight: the screen-reader * text pattern (an absolutely positioned box of at most 1px, overflow diff --git a/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php index 61da1aeb9..a60d99dae 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php @@ -445,8 +445,9 @@ private function controls(DOMElement $form): array { // One shared enumeration keeps `control-N` graph ids aligned with the // reported controls, and keeps non-authored controls (and the wrappers - // that exist only to hide them) out of the projected layout. - return FormControlClassifier::controlElements($form); + // that exist only to hide them) out of the projected layout. Each id + // describes the box that paints its control. + return FormControlClassifier::presentedControlElements($form); } /** diff --git a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php index 2fe437082..83e07aa18 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php @@ -490,7 +490,8 @@ function (array $selector) use ($controlsForCustomProperties): bool { foreach (FormCustomPropertyResolver::conditionsChanging($fact['value'], $element, $customPropertyAnalysis['rules'], true) as $condition) $conditional[json_encode($condition)][$property] ??= $fact; } $carried = array(); - $buttonCaption = 'control' === $role && 'button' === strtolower($control->tagName); + // A button, or the element painting a hidden native submit, carries its caption. + $buttonCaption = 'control' === $role && ( 'button' === strtolower($control->tagName) || ! FormControlClassifier::isControlElement($control) ); if ( 'label' === $role || $buttonCaption ) { // The label's text is painted by its sole text carrier (a `

` // or `` inside it). Typography that carrier declares wins @@ -891,8 +892,9 @@ private function controls(DOMElement $form): array // One shared enumeration keeps presentation-graph indexes aligned with // reported controls. A raw tag walk counted aria-hidden honeypots the // control manifest omits, so layered author CSS on the submit button - // landed on an unmapped index and providers declined the form. - return FormControlClassifier::controlElements($form); + // landed on an unmapped index and providers declined the form. Each + // index describes the element that paints its control. + return FormControlClassifier::presentedControlElements($form); } /** @return list */ From f89e4885cbf73e5326637d7a8ddd5a6dd18de4f0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 14 Oct 2026 09:38:47 -0400 Subject: [PATCH 2/5] test: cover the off-screen submit presenter --- php-transformer/composer.json | 1 + .../unit/form-offscreen-submit-presenter.php | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 php-transformer/tests/unit/form-offscreen-submit-presenter.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index ebdba3b8d..6295acf0e 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -160,6 +160,7 @@ "php tests/unit/semantic-heading-brand.php", "php tests/unit/conditional-native-button.php", "php tests/unit/form-physical-box-ownership.php", + "php tests/unit/form-offscreen-submit-presenter.php", "php tests/unit/runtime-declarations-streaming-hash.php", "php tests/unit/external-metric-declarations.php", "php tests/unit/runtime-script-evidence-analyzer.php", diff --git a/php-transformer/tests/unit/form-offscreen-submit-presenter.php b/php-transformer/tests/unit/form-offscreen-submit-presenter.php new file mode 100644 index 000000000..06c299669 --- /dev/null +++ b/php-transformer/tests/unit/form-offscreen-submit-presenter.php @@ -0,0 +1,78 @@ +loadHTML('

' . $submit . $siblings . '
', LIBXML_NOERROR | LIBXML_NOWARNING); + return $document->getElementsByTagName('form')->item(0); +}; +$presenter = 'Send'; + +// The off-screen submit is presented by its captioned sibling. +$source = $form('', $presenter); +$controls = FormControlClassifier::controlElements($source); +$presented = FormControlClassifier::presentedControlElements($source); +$assert(2 === count($controls) && 'input' === $controls[1]->tagName, 'control semantics keep the native submit', (string) count($controls)); +$assert(2 === count($presented) && 'a' === $presented[1]->tagName && $presented[0] === $controls[0], 'presentation describes the visible sibling at the same index'); + +$layout = array_column((new FormLayoutGraphBuilder())->build($source, array(), $css)['nodes'], null, 'id'); +$assert('a' === ($layout['control-1']['source']['tag'] ?? null) && 'inline-block' === ($layout['control-1']['layout']['display'] ?? null), 'the layout graph sizes the presenter box', json_encode($layout['control-1'] ?? null)); + +$presentation = (new FormPresentationGraphBuilder())->build($source, array(), $css); +$styles = array_column($presentation['controls'], null, 'index')[1]['control']['styles'] ?? array(); +$assert('#f5a623' === ($styles['background_color'] ?? null), 'the presenter paint reaches the submit', json_encode($styles)); +$assert('#ffffff' === ($styles['color'] ?? null) && 'Montserrat' === ($styles['font_family'] ?? null) && '700' === ($styles['font_weight'] ?? null), 'the presenter caption typography reaches the submit', json_encode($styles)); +$assert(! isset($styles['left']) && ! isset($styles['width']), 'the hidden input geometry stays off the submit', json_encode($styles)); + +// A visible submit, a captionless match, an ambiguous match, and a real link all keep the native control. +$cases = array( + 'visible submit' => $form('', $presenter), + 'clip-only hiding' => $form('', $presenter), + 'different caption' => $form('', 'Cancel'), + 'two presenters' => $form('', $presenter . $presenter), + 'navigating link' => $form('', 'Send'), + 'non-submit control' => $form('', $presenter), +); +foreach ( $cases as $name => $case ) { + $assert(FormControlClassifier::controlElements($case) === FormControlClassifier::presentedControlElements($case), $name . ' paints itself'); +} + +$button = $form('', 'Send'); +$assert('span' === (FormControlClassifier::presentedControlElements($button)[1]->tagName ?? null), 'a hidden button is presented by its captioned sibling'); + +$assert(CssValueInspector::isMovedOffscreenBox(array( 'position' => 'absolute', 'top' => '-9999px' )), 'a negative top offset moves the box off-screen'); +$assert(! CssValueInspector::isMovedOffscreenBox(array( 'position' => 'relative', 'left' => '-9999px' )), 'a relative box stays in flow'); +$assert(! CssValueInspector::isMovedOffscreenBox(array( 'position' => 'absolute', 'left' => '-20px' )), 'a small negative offset is still on-screen'); + +if ( 0 < $failures ) { + fwrite(STDERR, "form off-screen submit presenter FAILED: {$passes} passed, {$failures} failed\n"); + exit(1); +} +echo "form off-screen submit presenter passed: {$passes} assertions\n"; From 22d8117b9cd42320540f0a7e3ce3c8e43901d2dd Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 14 Oct 2026 09:40:51 -0400 Subject: [PATCH 3/5] fix: carry a block caption carrier's padding and case onto its submit --- php-transformer/composer.json | 1 + .../Style/FormPresentationGraphBuilder.php | 38 +++++++++++-- .../tests/unit/form-submit-caption-box.php | 53 +++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 php-transformer/tests/unit/form-submit-caption-box.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 6295acf0e..2268825f1 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -161,6 +161,7 @@ "php tests/unit/conditional-native-button.php", "php tests/unit/form-physical-box-ownership.php", "php tests/unit/form-offscreen-submit-presenter.php", + "php tests/unit/form-submit-caption-box.php", "php tests/unit/runtime-declarations-streaming-hash.php", "php tests/unit/external-metric-declarations.php", "php tests/unit/runtime-script-evidence-analyzer.php", diff --git a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php index 83e07aa18..1bfcc6bf1 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php @@ -36,6 +36,15 @@ final class FormPresentationGraphBuilder private const TYPOGRAPHY_PROPERTIES = array( 'color', 'font', 'font-family', 'font-size', 'font-weight', 'letter-spacing', 'line-height', ); + /** A button caption carrier also sets the caption's case. */ + private const CAPTION_PROPERTIES = array( + 'color', 'font', 'font-family', 'font-size', 'font-weight', 'letter-spacing', 'line-height', 'text-transform', + ); + /** A block-level caption carrier fills its button, so its padding sizes the button box. */ + private const CAPTION_BOX_PROPERTIES = array( + 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'padding-block', 'padding-inline', 'padding-block-start', 'padding-block-end', 'padding-inline-start', 'padding-inline-end', + ); /** How far a text-carrier search may walk before the search, not the cascade, gives up. */ private const MAX_CARRIER_CANDIDATES = 256; private const CONTROL_CONTAINER_PROPERTIES = array( @@ -497,7 +506,7 @@ function (array $selector) use ($controlsForCustomProperties): bool { // or `` inside it). Typography that carrier declares wins // over the label's own for that text, property by property, so a // provider label that renders the text directly keeps it. - $carried = $this->carrierTypography($buttonCaption ? array() : $roles, $element, $analysis['rules'], $customPropertyAnalysis['rules'], $conditional); + $carried = $this->carrierTypography($buttonCaption ? array() : $roles, $element, $analysis['rules'], $customPropertyAnalysis['rules'], $conditional, $buttonCaption && ! $this->hasPadding($styles)); if ( array() !== $carried ) { $overridden = array_map(static fn (string $key): string => str_replace('_', '-', $key), array_keys(array_intersect_key($styles, $carried['styles']))); foreach ( $provenance as $factIndex => $fact ) { @@ -698,8 +707,9 @@ private static function assertRole(mixed $role, ?array $condition, bool $allowEm * @param list> $customPropertyRules * @return array */ - private function carrierTypography(array $roles, DOMElement $label, array $rules, array $customPropertyRules, array $labelConditional = array()): array + private function carrierTypography(array $roles, DOMElement $label, array $rules, array $customPropertyRules, array $labelConditional = array(), bool $captionBox = false): array { + $properties = array_flip(array() === $roles ? self::CAPTION_PROPERTIES : self::TYPOGRAPHY_PROPERTIES); $excluded = array_values(array_filter( array( $roles['control'] ?? null, $roles['required_marker'] ?? null ), static fn (mixed $element): bool => $element instanceof DOMElement @@ -709,13 +719,19 @@ private function carrierTypography(array $roles, DOMElement $label, array $rules $conditions = array_fill_keys(array_keys($labelConditional), true); for ( $element = $carrier; $element instanceof DOMElement && ! $element->isSameNode($label); $element = $element->parentNode instanceof DOMElement ? $element->parentNode : null ) { $matched = $this->matched($element, $rules); - $base = array_intersect_key($matched['base'], array_flip(self::TYPOGRAPHY_PROPERTIES)); + $carried = $properties; + // The caption carrier's own padding sizes an unpadded button only + // when the carrier is block-level; inline padding leaves the line box. + if ( $captionBox && $element->isSameNode($carrier) && self::isBlockLevelDisplay((string) ($matched['base']['display']['value'] ?? '')) ) { + $carried += array_flip(self::CAPTION_BOX_PROPERTIES); + } + $base = array_intersect_key($matched['base'], $carried); $conditional = $this->effectiveConditional($matched['conditional'], $matched['base']); foreach ($base as $property => $fact) { foreach (FormCustomPropertyResolver::conditionsChanging($fact['value'], $element, $customPropertyRules, true) as $condition) $conditional[json_encode($condition)][$property] ??= $fact; } foreach ( $conditional as $encoded => &$facts ) { - $facts = array_intersect_key($facts, array_flip(self::TYPOGRAPHY_PROPERTIES)); + $facts = array_intersect_key($facts, $carried); if ( array() !== $facts ) $conditions[$encoded] = true; } unset($facts); @@ -759,6 +775,20 @@ private function carrierTypography(array $roles, DOMElement $label, array $rules * @param list> $rules * @param list> $customPropertyRules */ + private static function isBlockLevelDisplay(string $display): bool + { + return in_array(CssValueInspector::comparable($display), array( 'block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline-grid', 'flow-root' ), true); + } + + /** @param array $styles */ + private function hasPadding(array $styles): bool + { + foreach ( $styles as $key => $value ) { + if ( str_starts_with($key, 'padding') && 1 !== preg_match('/^(?:0(?:\.0+)?(?:px|em|rem|%)?\s*)+$/', trim($value)) ) return true; + } + return false; + } + private function carryFieldGroupGapOntoLabel(array &$row, DOMElement $control, array $ancestors, array $rules, array $customPropertyRules): void { if ( isset($row['label']['styles']['margin_block_end']) || isset($row['label']['styles']['margin_bottom']) ) { diff --git a/php-transformer/tests/unit/form-submit-caption-box.php b/php-transformer/tests/unit/form-submit-caption-box.php new file mode 100644 index 000000000..53bf76cf0 --- /dev/null +++ b/php-transformer/tests/unit/form-submit-caption-box.php @@ -0,0 +1,53 @@ +` around a padded block + * `` is as tall as the span. The presentation graph carries the + * carrier's padding and text case onto the button so a provider button, + * which renders the caption directly, keeps the source box and case. + */ + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; + +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormPresentationGraphBuilder; + +$failures = 0; +$passes = 0; +$assert = static function (bool $ok, string $message, string $detail = '') use (&$failures, &$passes): void { + if ( $ok ) { + ++$passes; + return; + } + ++$failures; + fwrite(STDERR, 'FAIL: ' . $message . ( '' !== $detail ? ' - ' . $detail : '' ) . PHP_EOL); +}; + +$submit = static function (string $css): array { + $document = new DOMDocument(); + $document->loadHTML('
', LIBXML_NOERROR | LIBXML_NOWARNING); + $graph = (new FormPresentationGraphBuilder())->build($document->getElementsByTagName('form')->item(0), array(), $css); + return array( 'styles' => array_column($graph['controls'], null, 'index')[1]['control']['styles'] ?? array(), 'variants' => $graph['variants'] ?? array() ); +}; + +$block = $submit('.btn { padding: 0; background-color: #f5a623; } .btn .inner { display: block; padding: .625em 1.9375em; text-transform: uppercase; font-weight: 700; }'); +$assert('.625em 1.9375em' === ($block['styles']['padding'] ?? null), 'a block carrier pads the unpadded button', json_encode($block['styles'])); +$assert('uppercase' === ($block['styles']['text_transform'] ?? null) && '700' === ($block['styles']['font_weight'] ?? null), 'the carrier sets the caption case and weight', json_encode($block['styles'])); + +$inline = $submit('.btn { padding: 0; } .btn .inner { padding: 4px 8px; text-transform: uppercase; }'); +$assert('0' === ($inline['styles']['padding'] ?? null), 'an inline carrier leaves the button box alone', json_encode($inline['styles'])); +$assert('uppercase' === ($inline['styles']['text_transform'] ?? null), 'an inline carrier still sets the caption case', json_encode($inline['styles'])); + +$padded = $submit('.btn { padding: 10px 20px; } .btn .inner { display: block; padding: 4px; }'); +$assert('10px 20px' === ($padded['styles']['padding'] ?? null), 'a padded button keeps its own box', json_encode($padded['styles'])); + +$responsive = $submit('.btn { padding: 0; } .btn .inner { display: block; padding: 4px; } @media (min-width: 600px) { .btn .inner { padding: 12px; } }'); +$patches = array_map(static fn (array $variant): array => $variant['style_patch'] ?? array(), array_filter($responsive['variants'], static fn (array $variant): bool => 1 === ($variant['index'] ?? null) && 'control' === ($variant['role'] ?? null))); +$assert('4px' === ($responsive['styles']['padding'] ?? null) && in_array('12px', array_column($patches, 'padding'), true), 'a conditional carrier padding becomes a variant', json_encode(array( $responsive['styles'], $patches ))); + +if ( 0 < $failures ) { + fwrite(STDERR, "form submit caption box FAILED: {$passes} passed, {$failures} failed\n"); + exit(1); +} +echo "form submit caption box passed: {$passes} assertions\n"; From 66b9c37f14349b43d6bf69c5a051fc15ecf22dc1 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 14 Oct 2026 09:51:48 -0400 Subject: [PATCH 4/5] fix: share the caption box between the graph and the control style --- .../Elements/FormControlMetadataBuilder.php | 28 ++++++++++--- .../src/HtmlToBlocks/HtmlCompilation.php | 8 +++- .../Style/FormPresentationGraphBuilder.php | 42 ++++++++++++++++--- .../tests/unit/form-submit-caption-box.php | 17 ++++++++ 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php b/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php index a77c866d3..0c9d6bba4 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php @@ -33,12 +33,15 @@ final class FormControlMetadataBuilder * facts for one element, through the form presentation graph's cascade. * @param Closure(DOMElement, DOMElement): (array|null)|null $hiddenState The declaration * that hides an element below a form boundary, if the source hides it unconditionally. + * @param Closure(DOMElement, bool): array|null $captionStyles Resolved snake_case facts a + * button caption carrier gives its button; the flag allows the carrier's padding to size the box. */ public function __construct( private readonly Closure $elementSelector, private readonly ?Closure $presentationAttributes = null, private readonly ?Closure $typographyStyles = null, - private readonly ?Closure $hiddenState = null + private readonly ?Closure $hiddenState = null, + private readonly ?Closure $captionStyles = null ) { } @@ -265,6 +268,17 @@ private function inFormContextItem(DOMElement $node, DOMElement $form): ?array * * @return array */ + /** @param mixed $padding A block style `spacing.padding` value. */ + private static function hasPadding(mixed $padding): bool + { + foreach ( is_array($padding) ? $padding : array( $padding ) as $side ) { + if ( is_string($side) && 1 !== preg_match('/^0(?:\.0+)?(?:px|em|rem|%)?$/', trim($side)) && '' !== trim($side) ) { + return true; + } + } + return false; + } + private function contextTypography(DOMElement $node): array { if ( null === $this->typographyStyles ) { @@ -420,11 +434,15 @@ public function control(DOMElement $control): array } } // A provider flattens the editable label independently from its box. - // Read the existing whole-text carrier through the form typography - // cascade so a styled paragraph/div chain does not become button defaults. - $carrier = FormPresentationGraphBuilder::soleTextCarrier($control); + // Read the whole-text carrier of the element that paints the submit + // through the form presentation cascade, so a styled caption keeps its + // typography, case, and the padding a block-level carrier gives an + // unpadded box. + $carrier = FormPresentationGraphBuilder::soleTextCarrier(FormControlClassifier::presentingElement($control)); if ($carrier instanceof DOMElement) { - $typography = $this->contextTypography($carrier); + $typography = null !== $this->captionStyles + ? ($this->captionStyles)($carrier, ! self::hasPadding($metadata['presentation']['style']['spacing']['padding'] ?? array())) + : $this->contextTypography($carrier); $declarations = array(); foreach ($typography as $property => $value) { $declarations[str_replace('_', '-', $property)] = $value; diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 683b0ec42..2ef2a8328 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -598,7 +598,13 @@ public function __construct( fn (DOMElement $element): string => $this->elementSelector($element), fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element), fn (DOMElement $element): array => $this->formContextTypography($element), - fn (DOMElement $element, DOMElement $boundary): ?array => $this->formContextHiddenState($element, $boundary) + fn (DOMElement $element, DOMElement $boundary): ?array => $this->formContextHiddenState($element, $boundary), + fn (DOMElement $carrier, bool $sizesBox): array => ($this->formContextTypographyBuilder ??= new FormPresentationGraphBuilder())->captionStyles( + $carrier, + $sizesBox, + $this->authorStyles()->stylesheetAssets(), + $this->sourceStyles()->formLayoutCss() + ) ); $this->pseudoFormAnalyzer = new PseudoFormAnalyzer($this->formControlMetadataBuilder, fn (DOMElement $element): string => $this->elementSelector($element)); $this->runtimeIslands = new RuntimeIslandAnalyzer($this->createRuntimeIslandContext(), $this->pseudoFormAnalyzer); diff --git a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php index 1bfcc6bf1..ebb0d321f 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php @@ -197,6 +197,41 @@ public static function assertElement(array $presentation): void * * @param list> $stylesheets @return array */ + /** + * The styles a button caption carrier gives the button a provider renders + * the caption into: its typography and case, plus its padding when the + * carrier is block-level and so fills an otherwise unpadded box. + * + * @param list $stylesheets + * @return array + */ + public function captionStyles(DOMElement $carrier, bool $sizesBox, array $stylesheets, string $inlineCss = ''): array + { + $this->typographyAnalysis ??= $this->typographyAnalysis($stylesheets, $inlineCss); + if ( $this->typographyAnalysis['truncated'] ) { + return array(); + } + $matched = $this->matched($carrier, $this->typographyAnalysis['rules']); + return $this->styles( + array_intersect_key($matched['base'], array_flip(self::captionProperties($matched['base'], $sizesBox))), + $carrier, + null, + $this->typographyAnalysis['customProperties'] + ); + } + + /** + * @param array> $base The carrier's matched base facts. + * @return list + */ + private static function captionProperties(array $base, bool $sizesBox): array + { + // Inline padding leaves the line box, so only a block-level carrier sizes the button. + return $sizesBox && self::isBlockLevelDisplay((string) ($base['display']['value'] ?? '')) + ? array_merge(self::CAPTION_PROPERTIES, self::CAPTION_BOX_PROPERTIES) + : self::CAPTION_PROPERTIES; + } + public function typographyStyles(DOMElement $element, array $stylesheets, string $inlineCss = ''): array { $this->typographyAnalysis ??= $this->typographyAnalysis($stylesheets, $inlineCss); @@ -719,12 +754,7 @@ private function carrierTypography(array $roles, DOMElement $label, array $rules $conditions = array_fill_keys(array_keys($labelConditional), true); for ( $element = $carrier; $element instanceof DOMElement && ! $element->isSameNode($label); $element = $element->parentNode instanceof DOMElement ? $element->parentNode : null ) { $matched = $this->matched($element, $rules); - $carried = $properties; - // The caption carrier's own padding sizes an unpadded button only - // when the carrier is block-level; inline padding leaves the line box. - if ( $captionBox && $element->isSameNode($carrier) && self::isBlockLevelDisplay((string) ($matched['base']['display']['value'] ?? '')) ) { - $carried += array_flip(self::CAPTION_BOX_PROPERTIES); - } + $carried = $captionBox && $element->isSameNode($carrier) ? array_flip(self::captionProperties($matched['base'], true)) : $properties; $base = array_intersect_key($matched['base'], $carried); $conditional = $this->effectiveConditional($matched['conditional'], $matched['base']); foreach ($base as $property => $fact) { diff --git a/php-transformer/tests/unit/form-submit-caption-box.php b/php-transformer/tests/unit/form-submit-caption-box.php index 53bf76cf0..15d04815d 100644 --- a/php-transformer/tests/unit/form-submit-caption-box.php +++ b/php-transformer/tests/unit/form-submit-caption-box.php @@ -46,6 +46,23 @@ $patches = array_map(static fn (array $variant): array => $variant['style_patch'] ?? array(), array_filter($responsive['variants'], static fn (array $variant): bool => 1 === ($variant['index'] ?? null) && 'control' === ($variant['role'] ?? null))); $assert('4px' === ($responsive['styles']['padding'] ?? null) && in_array('12px', array_column($patches, 'padding'), true), 'a conditional carrier padding becomes a variant', json_encode(array( $responsive['styles'], $patches ))); +// The control manifest describes the same caption box, through the element +// painting an off-screen native submit, so a consumer that layers a control's +// own style over the graph keeps the carrier's padding and case. +$css = ''; +foreach ( array( + 'native button' => '', + 'off-screen submit presenter' => 'Send', +) as $name => $submitMarkup ) { + $result = (new Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer())->transform('
' . $css . '
' . $submitMarkup . '
')->toArray(); + $controls = array(); + foreach ( array_merge($result['fallbacks'] ?? array(), array_column($result['source_reports']['wordpress_site_plan']['runtime_declarations'] ?? array(), 'payload')) as $entry ) { + $controls = $entry['controls'] ?? ($entry['entities'][0]['controls'] ?? $controls); + } + $style = end($controls)['presentation']['style'] ?? array(); + $assert('.625em' === ($style['spacing']['padding']['top'] ?? null) && '1.9375em' === ($style['spacing']['padding']['left'] ?? null) && 'uppercase' === ($style['typography']['textTransform'] ?? null), $name . ' control style carries the caption box', json_encode($style)); +} + if ( 0 < $failures ) { fwrite(STDERR, "form submit caption box FAILED: {$passes} passed, {$failures} failed\n"); exit(1); From d64cfb0e51e630eb61c06eeba2ffb3f93d0f6a82 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 14 Oct 2026 09:56:04 -0400 Subject: [PATCH 5/5] refactor: keep the caption box on the presentation graph only --- .../Elements/FormControlMetadataBuilder.php | 28 +++---------- .../src/HtmlToBlocks/HtmlCompilation.php | 8 +--- .../Style/FormPresentationGraphBuilder.php | 42 +++---------------- .../tests/unit/form-submit-caption-box.php | 17 -------- 4 files changed, 12 insertions(+), 83 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php b/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php index 0c9d6bba4..a77c866d3 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php @@ -33,15 +33,12 @@ final class FormControlMetadataBuilder * facts for one element, through the form presentation graph's cascade. * @param Closure(DOMElement, DOMElement): (array|null)|null $hiddenState The declaration * that hides an element below a form boundary, if the source hides it unconditionally. - * @param Closure(DOMElement, bool): array|null $captionStyles Resolved snake_case facts a - * button caption carrier gives its button; the flag allows the carrier's padding to size the box. */ public function __construct( private readonly Closure $elementSelector, private readonly ?Closure $presentationAttributes = null, private readonly ?Closure $typographyStyles = null, - private readonly ?Closure $hiddenState = null, - private readonly ?Closure $captionStyles = null + private readonly ?Closure $hiddenState = null ) { } @@ -268,17 +265,6 @@ private function inFormContextItem(DOMElement $node, DOMElement $form): ?array * * @return array */ - /** @param mixed $padding A block style `spacing.padding` value. */ - private static function hasPadding(mixed $padding): bool - { - foreach ( is_array($padding) ? $padding : array( $padding ) as $side ) { - if ( is_string($side) && 1 !== preg_match('/^0(?:\.0+)?(?:px|em|rem|%)?$/', trim($side)) && '' !== trim($side) ) { - return true; - } - } - return false; - } - private function contextTypography(DOMElement $node): array { if ( null === $this->typographyStyles ) { @@ -434,15 +420,11 @@ public function control(DOMElement $control): array } } // A provider flattens the editable label independently from its box. - // Read the whole-text carrier of the element that paints the submit - // through the form presentation cascade, so a styled caption keeps its - // typography, case, and the padding a block-level carrier gives an - // unpadded box. - $carrier = FormPresentationGraphBuilder::soleTextCarrier(FormControlClassifier::presentingElement($control)); + // Read the existing whole-text carrier through the form typography + // cascade so a styled paragraph/div chain does not become button defaults. + $carrier = FormPresentationGraphBuilder::soleTextCarrier($control); if ($carrier instanceof DOMElement) { - $typography = null !== $this->captionStyles - ? ($this->captionStyles)($carrier, ! self::hasPadding($metadata['presentation']['style']['spacing']['padding'] ?? array())) - : $this->contextTypography($carrier); + $typography = $this->contextTypography($carrier); $declarations = array(); foreach ($typography as $property => $value) { $declarations[str_replace('_', '-', $property)] = $value; diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 2ef2a8328..683b0ec42 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -598,13 +598,7 @@ public function __construct( fn (DOMElement $element): string => $this->elementSelector($element), fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element), fn (DOMElement $element): array => $this->formContextTypography($element), - fn (DOMElement $element, DOMElement $boundary): ?array => $this->formContextHiddenState($element, $boundary), - fn (DOMElement $carrier, bool $sizesBox): array => ($this->formContextTypographyBuilder ??= new FormPresentationGraphBuilder())->captionStyles( - $carrier, - $sizesBox, - $this->authorStyles()->stylesheetAssets(), - $this->sourceStyles()->formLayoutCss() - ) + fn (DOMElement $element, DOMElement $boundary): ?array => $this->formContextHiddenState($element, $boundary) ); $this->pseudoFormAnalyzer = new PseudoFormAnalyzer($this->formControlMetadataBuilder, fn (DOMElement $element): string => $this->elementSelector($element)); $this->runtimeIslands = new RuntimeIslandAnalyzer($this->createRuntimeIslandContext(), $this->pseudoFormAnalyzer); diff --git a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php index ebb0d321f..1bfcc6bf1 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php @@ -197,41 +197,6 @@ public static function assertElement(array $presentation): void * * @param list> $stylesheets @return array */ - /** - * The styles a button caption carrier gives the button a provider renders - * the caption into: its typography and case, plus its padding when the - * carrier is block-level and so fills an otherwise unpadded box. - * - * @param list $stylesheets - * @return array - */ - public function captionStyles(DOMElement $carrier, bool $sizesBox, array $stylesheets, string $inlineCss = ''): array - { - $this->typographyAnalysis ??= $this->typographyAnalysis($stylesheets, $inlineCss); - if ( $this->typographyAnalysis['truncated'] ) { - return array(); - } - $matched = $this->matched($carrier, $this->typographyAnalysis['rules']); - return $this->styles( - array_intersect_key($matched['base'], array_flip(self::captionProperties($matched['base'], $sizesBox))), - $carrier, - null, - $this->typographyAnalysis['customProperties'] - ); - } - - /** - * @param array> $base The carrier's matched base facts. - * @return list - */ - private static function captionProperties(array $base, bool $sizesBox): array - { - // Inline padding leaves the line box, so only a block-level carrier sizes the button. - return $sizesBox && self::isBlockLevelDisplay((string) ($base['display']['value'] ?? '')) - ? array_merge(self::CAPTION_PROPERTIES, self::CAPTION_BOX_PROPERTIES) - : self::CAPTION_PROPERTIES; - } - public function typographyStyles(DOMElement $element, array $stylesheets, string $inlineCss = ''): array { $this->typographyAnalysis ??= $this->typographyAnalysis($stylesheets, $inlineCss); @@ -754,7 +719,12 @@ private function carrierTypography(array $roles, DOMElement $label, array $rules $conditions = array_fill_keys(array_keys($labelConditional), true); for ( $element = $carrier; $element instanceof DOMElement && ! $element->isSameNode($label); $element = $element->parentNode instanceof DOMElement ? $element->parentNode : null ) { $matched = $this->matched($element, $rules); - $carried = $captionBox && $element->isSameNode($carrier) ? array_flip(self::captionProperties($matched['base'], true)) : $properties; + $carried = $properties; + // The caption carrier's own padding sizes an unpadded button only + // when the carrier is block-level; inline padding leaves the line box. + if ( $captionBox && $element->isSameNode($carrier) && self::isBlockLevelDisplay((string) ($matched['base']['display']['value'] ?? '')) ) { + $carried += array_flip(self::CAPTION_BOX_PROPERTIES); + } $base = array_intersect_key($matched['base'], $carried); $conditional = $this->effectiveConditional($matched['conditional'], $matched['base']); foreach ($base as $property => $fact) { diff --git a/php-transformer/tests/unit/form-submit-caption-box.php b/php-transformer/tests/unit/form-submit-caption-box.php index 15d04815d..53bf76cf0 100644 --- a/php-transformer/tests/unit/form-submit-caption-box.php +++ b/php-transformer/tests/unit/form-submit-caption-box.php @@ -46,23 +46,6 @@ $patches = array_map(static fn (array $variant): array => $variant['style_patch'] ?? array(), array_filter($responsive['variants'], static fn (array $variant): bool => 1 === ($variant['index'] ?? null) && 'control' === ($variant['role'] ?? null))); $assert('4px' === ($responsive['styles']['padding'] ?? null) && in_array('12px', array_column($patches, 'padding'), true), 'a conditional carrier padding becomes a variant', json_encode(array( $responsive['styles'], $patches ))); -// The control manifest describes the same caption box, through the element -// painting an off-screen native submit, so a consumer that layers a control's -// own style over the graph keeps the carrier's padding and case. -$css = ''; -foreach ( array( - 'native button' => '', - 'off-screen submit presenter' => 'Send', -) as $name => $submitMarkup ) { - $result = (new Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer())->transform('
' . $css . '
' . $submitMarkup . '
')->toArray(); - $controls = array(); - foreach ( array_merge($result['fallbacks'] ?? array(), array_column($result['source_reports']['wordpress_site_plan']['runtime_declarations'] ?? array(), 'payload')) as $entry ) { - $controls = $entry['controls'] ?? ($entry['entities'][0]['controls'] ?? $controls); - } - $style = end($controls)['presentation']['style'] ?? array(); - $assert('.625em' === ($style['spacing']['padding']['top'] ?? null) && '1.9375em' === ($style['spacing']['padding']['left'] ?? null) && 'uppercase' === ($style['typography']['textTransform'] ?? null), $name . ' control style carries the caption box', json_encode($style)); -} - if ( 0 < $failures ) { fwrite(STDERR, "form submit caption box FAILED: {$passes} passed, {$failures} failed\n"); exit(1);