diff --git a/php-transformer/composer.json b/php-transformer/composer.json index c6805639..566ce4e2 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -60,6 +60,7 @@ "php tests/unit/fallback-finding-normalizer.php", "php tests/unit/navigation-underline-color-resolver.php", "php tests/unit/button-signal-classifier.php", + "php tests/unit/button-style-resolver.php", "php tests/unit/button-visual-probe-diagnostics.php", "php tests/unit/block-style-support-conversion.php", "php tests/unit/content-round-trip-reporter.php", diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index dccb9e42..3b1980ad 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -553,6 +553,11 @@ private function buttonStyleSupport(array $attrs): array $declarations[] = 'background-color:' . $background; } + $shadow = trim((string) ($style['shadow'] ?? '')); + if ( '' !== $shadow ) { + $declarations[] = 'box-shadow:' . $shadow; + } + $padding = is_array($style['spacing']['padding'] ?? null) ? $style['spacing']['padding'] : array(); foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) { if ( isset($padding[$side]) && '' !== (string) $padding[$side] ) { diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php index 694e6c04..cac094cd 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php @@ -26,7 +26,8 @@ * - Outline/ghost buttons (transparent/absent background) get style.border.* + * style.color.text and never a background or gradient fill. * - Buttons carry padding but not margin (inter-button spacing rides on the - * parent core/buttons block gap), plus a curated typography subset. + * parent core/buttons block gap), plus source shadow and a curated + * typography subset. * A button whose resolved CSS carries no paintable colors/border stays default. */ final class ButtonStyleResolver @@ -85,6 +86,11 @@ public function nativeAttributes(string $resolvedStyle): array $style['spacing']['padding'] = $padding; } + $shadow = $this->buttonShadow($declarations); + if ( '' !== $shadow ) { + $style['shadow'] = $shadow; + } + $typography = $this->buttonTypography(is_array($mapped['typography'] ?? null) ? $mapped['typography'] : array()); if ( array() !== $typography ) { $style['typography'] = $typography; @@ -119,6 +125,27 @@ private function buttonWidth(array $declarations): ?int return null; } + /** + * Core/button supports shadow as a canonical `style.shadow` value. Keep this + * button-specific instead of making every block consume `box-shadow`, because + * class-owned card/section shadows should continue riding on preserved CSS. + * + * @param array $declarations + */ + private function buttonShadow(array $declarations): string + { + $shadow = trim((string) ($declarations['box-shadow'] ?? '')); + if ( '' === $shadow || in_array(strtolower($shadow), array( 'none', 'initial', 'inherit', 'unset' ), true) ) { + return ''; + } + + if ( preg_match('/(?:expression\s*\(|javascript\s*:|url\s*\()/i', $shadow) ) { + return ''; + } + + return $shadow; + } + /** * Project the shared typography attributes onto the button-supported subset, * preserving the canonical emission order. diff --git a/php-transformer/src/VisualParity/ButtonMenuVisualProbe.php b/php-transformer/src/VisualParity/ButtonMenuVisualProbe.php index cd5e8f2e..3b96b6f9 100644 --- a/php-transformer/src/VisualParity/ButtonMenuVisualProbe.php +++ b/php-transformer/src/VisualParity/ButtonMenuVisualProbe.php @@ -492,12 +492,66 @@ private function computedStyle(DOMElement $element, array $rules): array $style = array_merge($style, $this->declarations($element->getAttribute('style'))); } + $style = $this->resolveCustomProperties($element, $style); $style = array_intersect_key($style, array_flip(self::STYLE_FIELDS)); + $style = $this->withCoreButtonWidthClass($element, $style); ksort($style); return $style; } + /** + * @param array $style + * @return array + */ + private function resolveCustomProperties(DOMElement $element, array $style): array + { + if ( array() === $style ) { + return $style; + } + + $customProperties = $this->customProperties($element->ownerDocument); + if ( array() === $customProperties ) { + return $style; + } + + foreach ( $style as $property => $value ) { + if ( ! str_contains($value, 'var(') ) { + continue; + } + $style[$property] = preg_replace_callback('/var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]*))?\)/', static function (array $matches) use ($customProperties): string { + $name = (string) ($matches[1] ?? ''); + if ( isset($customProperties[$name]) ) { + return $customProperties[$name]; + } + return trim((string) ($matches[2] ?? $matches[0])); + }, $value) ?? $value; + } + + return $style; + } + + /** + * @return array + */ + private function customProperties(?DOMDocument $document): array + { + if ( ! $document instanceof DOMDocument ) { + return array(); + } + + $properties = array(); + foreach ( $document->getElementsByTagName('style') as $style ) { + if ( preg_match_all('/(--[A-Za-z0-9_-]+)\s*:\s*([^;{}]+)/', (string) $style->textContent, $matches, PREG_SET_ORDER) ) { + foreach ( $matches as $match ) { + $properties[(string) $match[1]] = trim((string) $match[2]); + } + } + } + + return $properties; + } + /** * @param array}> $rules * @return array @@ -601,6 +655,9 @@ private function styleRules(DOMDocument $document): array if ( '' === $selector ) { continue; } + if ( $this->selectorCarriesPseudoState($selector) ) { + continue; + } $rules[] = array( 'selector' => $selector, 'declarations' => $this->declarations((string) $match[2]), @@ -612,6 +669,39 @@ private function styleRules(DOMDocument $document): array return $rules; } + /** + * WordPress renders core/button custom width as a wrapper class. The visual + * probe compares the inner link/button control, so synthesize the equivalent + * width declaration from the nearest core/button wrapper. + * + * @param array $style + * @return array + */ + private function withCoreButtonWidthClass(DOMElement $element, array $style): array + { + if ( isset($style['width']) ) { + return $style; + } + + for ( $node = $element; $node instanceof DOMElement; $node = $node->parentNode instanceof DOMElement ? $node->parentNode : null ) { + $className = $node->hasAttribute('class') ? $node->getAttribute('class') : ''; + if ( preg_match('/(?:^|\s)wp-block-button__width-(25|50|75|100)(?:\s|$)/', $className, $match) ) { + $style['width'] = $match[1] . '%'; + return $style; + } + if ( 'body' === strtolower($node->tagName) ) { + break; + } + } + + return $style; + } + + private function selectorCarriesPseudoState(string $selector): bool + { + return 1 === preg_match('/:{1,2}(?:hover|focus-visible|focus-within|focus|active|visited|before|after)\b/i', $selector); + } + /** * @return array */ @@ -634,7 +724,11 @@ private function declarations(string $style): array private function matchesSimpleSelector(DOMElement $element, string $selector): bool { - $selector = trim(preg_replace('/:(hover|focus|active|visited|before|after)\b.*/', '', $selector) ?? $selector); + if ( $this->selectorCarriesPseudoState($selector) ) { + return false; + } + + $selector = trim($selector); if ( '' === $selector || str_contains($selector, '>') || str_contains($selector, '+') || str_contains($selector, '~') ) { return false; } diff --git a/php-transformer/src/VisualParity/ButtonMenuVisualProbeComparator.php b/php-transformer/src/VisualParity/ButtonMenuVisualProbeComparator.php index 0281063f..ca788ab7 100644 --- a/php-transformer/src/VisualParity/ButtonMenuVisualProbeComparator.php +++ b/php-transformer/src/VisualParity/ButtonMenuVisualProbeComparator.php @@ -35,6 +35,7 @@ final class ButtonMenuVisualProbeComparator 'border-top-right-radius', ), 'padding' => array('padding', 'padding-bottom', 'padding-left', 'padding-right', 'padding-top'), + 'shadow' => array('box-shadow'), 'text_color' => array('color'), 'width' => array('width', 'min-width'), ); @@ -310,6 +311,7 @@ private function guidanceForGroup(string $group): string 'padding' => 'Preserve source padding on the button/link element or its wrapper block spacing support.', 'radius' => 'Preserve source border radius on the core/button border support.', 'fill' => 'Preserve source fill/background color and avoid theme default fill leakage.', + 'shadow' => 'Preserve source box-shadow/glow on the core/button shadow support.', 'border' => 'Preserve border width/style/color together; partial border emission usually changes button shape.', 'text_color' => 'Preserve link text color separately from fill/background color.', default => '', @@ -415,6 +417,20 @@ private function groupValues(array $style, array $fields): array private function styleGroupValues(array $style, string $group, array $fields): array { $values = $this->groupValues($style, $fields); + if ( 'fill' === $group ) { + $fill = trim((string) ($values['background-color'] ?? $values['background'] ?? '')); + return '' === $fill ? array() : array('background' => $fill); + } + + if ( 'padding' === $group ) { + return $this->boxValues('padding', $values); + } + + if ( 'radius' === $group ) { + $radius = trim((string) ($values['border-radius'] ?? '')); + return '' === $radius ? $values : array('border-radius' => $radius); + } + if ( 'border' !== $group ) { return $values; } @@ -441,6 +457,39 @@ private function styleGroupValues(array $style, string $group, array $fields): a }, ARRAY_FILTER_USE_BOTH); } + /** + * @param array $values + * @return array + */ + private function boxValues(string $prefix, array $values): array + { + $shorthand = trim((string) ($values[$prefix] ?? '')); + if ( '' !== $shorthand ) { + $parts = preg_split('/\s+/', $shorthand) ?: array(); + $parts = array_values(array_filter($parts, static fn (string $part): bool => '' !== $part)); + if ( 1 === count($parts) ) { + $parts = array($parts[0], $parts[0], $parts[0], $parts[0]); + } elseif ( 2 === count($parts) ) { + $parts = array($parts[0], $parts[1], $parts[0], $parts[1]); + } elseif ( 3 === count($parts) ) { + $parts = array($parts[0], $parts[1], $parts[2], $parts[1]); + } else { + $parts = array_slice($parts, 0, 4); + } + $values = array( + $prefix . '-top' => $parts[0], + $prefix . '-right' => $parts[1], + $prefix . '-bottom' => $parts[2], + $prefix . '-left' => $parts[3], + ) + $values; + } + + unset($values[$prefix]); + ksort($values); + + return array_filter($values, static fn (string $value): bool => '' !== trim($value)); + } + /** * @param array $style * @return array diff --git a/php-transformer/tests/unit/button-style-resolver.php b/php-transformer/tests/unit/button-style-resolver.php new file mode 100644 index 00000000..220d1e5a --- /dev/null +++ b/php-transformer/tests/unit/button-style-resolver.php @@ -0,0 +1,49 @@ + +.btn { padding:9px 20px; border-radius:6px; font-weight:600; text-transform:uppercase; } +.btn-primary { background:#e8a020; color:#050d1a; box-shadow:0 0 24px rgba(232,160,32,0.3); } + +Get early access +HTML; + +$result = ( new HtmlTransformer() )->transform($html, array())->toArray(); +$button = $result['blocks'][0]['innerBlocks'][0] ?? array(); +$attrs = is_array($button['attrs'] ?? null) ? $button['attrs'] : array(); +$markup = (string) ($result['serialized_blocks'] ?? ''); + +$assert('core/button' === ($button['blockName'] ?? ''), 'button signal becomes core/button', (string) ($button['blockName'] ?? '(none)')); +$assert('0 0 24px rgba(232,160,32,0.3)' === ($attrs['style']['shadow'] ?? ''), 'button box-shadow maps to canonical style.shadow', json_encode($attrs['style'] ?? array())); +$assert(str_contains($markup, 'box-shadow:0 0 24px rgba(232,160,32,0.3)'), 'rendered core/button carries source box-shadow', $markup); +$assert(str_contains($markup, 'background-color:#e8a020'), 'rendered core/button carries source fill', $markup); +$assert(str_contains($markup, 'color:#050d1a'), 'rendered core/button carries source text color', $markup); + +if ( $failures > 0 ) { + fwrite(STDERR, "Button style resolver tests: {$failures} failed, {$passes} passed\n"); + exit(1); +} + +fwrite(STDOUT, "Button style resolver tests: {$passes} passed\n"); diff --git a/php-transformer/tests/unit/button-visual-probe-diagnostics.php b/php-transformer/tests/unit/button-visual-probe-diagnostics.php index 16c7aa7f..f2129880 100644 --- a/php-transformer/tests/unit/button-visual-probe-diagnostics.php +++ b/php-transformer/tests/unit/button-visual-probe-diagnostics.php @@ -94,6 +94,24 @@ ); $assert(! in_array('button_border_missing', $causeCodes($zeroBorderReport), true), 'ignores non-visual border:0 declarations', json_encode($zeroBorderReport['matches'][0] ?? array())); +$hoverReport = $compare( + 'Start', + 'Start' +); +$assert(! in_array('button_fill_mismatch', $causeCodes($hoverReport), true), 'ignores hover fill when comparing resting button state', json_encode($hoverReport['matches'][0] ?? array())); + +$widthReport = $compare( + 'Start', + '' +); +$assert(! in_array('button_width_missing', $causeCodes($widthReport), true), 'recognizes core/button custom width wrapper classes', json_encode($widthReport['matches'][0] ?? array())); + +$shadowReport = $compare( + 'Start', + 'Start' +); +$assert(in_array('button_shadow_missing', $causeCodes($shadowReport), true), 'reports missing button shadow/glow', json_encode($shadowReport['matches'][0] ?? array())); + if ( $failures > 0 ) { fwrite(STDERR, "Button visual probe diagnostic tests: {$failures} failed, {$passes} passed\n"); exit(1);