From c8da6a7d8977cb76950494c9dd812b682809074e Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:06:20 +1000 Subject: [PATCH 01/13] Editor: Add a background clip block support Adds a background.backgroundClip block support, so a block can set which box its background is painted into: border-box, padding-box, content-box, or the text itself. Clipping to the text is what gives blocks a gradient text fill. Registers the property in the style engine and theme.json, allows the declarations through KSES, and skips the has-background class when the background is clipped to the text. Props aaronrobertshaw. See #66094. --- src/wp-includes/block-supports/background.php | 25 ++++- src/wp-includes/class-wp-theme-json.php | 34 ++++++ src/wp-includes/kses.php | 5 + .../style-engine/class-wp-style-engine.php | 49 +++++++++ .../wpRenderBackgroundSupport.php | 103 ++++++++++++++++++ .../tests/style-engine/styleEngine.php | 44 ++++++++ 6 files changed, 257 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/block-supports/background.php b/src/wp-includes/block-supports/background.php index 3b3e277c377f2..b2161973ecc6c 100644 --- a/src/wp-includes/block-supports/background.php +++ b/src/wp-includes/block-supports/background.php @@ -44,6 +44,7 @@ function wp_register_background_support( $block_type ) { * @since 6.6.0 Removed requirement for `backgroundImage.source`. A file/url is the default. * @since 6.7.0 Added support for `backgroundAttachment` output. * @since 7.1.0 Added support for `background.gradient` output. + * @since 7.2.0 Added support for `background.backgroundClip` output. * * @access private * @@ -56,9 +57,10 @@ function wp_render_background_support( $block_content, $block ) { $block_attributes = ( isset( $block['attrs'] ) && is_array( $block['attrs'] ) ) ? $block['attrs'] : array(); $has_background_image_support = block_has_support( $block_type, array( 'background', 'backgroundImage' ), false ); $has_background_gradient_support = block_has_support( $block_type, array( 'background', 'gradient' ), false ); + $has_background_clip_support = block_has_support( $block_type, array( 'background', 'backgroundClip' ), false ); if ( - ( ! $has_background_image_support && ! $has_background_gradient_support ) || + ( ! $has_background_image_support && ! $has_background_gradient_support && ! $has_background_clip_support ) || ! isset( $block_attributes['style']['background'] ) ) { return $block_content; @@ -67,8 +69,9 @@ function wp_render_background_support( $block_content, $block ) { // Check serialization skip for each feature individually. $skip_background_image = ! $has_background_image_support || wp_should_skip_block_supports_serialization( $block_type, 'background', 'backgroundImage' ); $skip_background_gradient = ! $has_background_gradient_support || wp_should_skip_block_supports_serialization( $block_type, 'background', 'gradient' ); + $skip_background_clip = ! $has_background_clip_support || wp_should_skip_block_supports_serialization( $block_type, 'background', 'backgroundClip' ); - if ( $skip_background_image && $skip_background_gradient ) { + if ( $skip_background_image && $skip_background_gradient && $skip_background_clip ) { return $block_content; } @@ -95,6 +98,10 @@ function wp_render_background_support( $block_content, $block ) { $background_styles['gradient'] = $block_attributes['style']['background']['gradient'] ?? null; } + if ( ! $skip_background_clip ) { + $background_styles['backgroundClip'] = $block_attributes['style']['background']['backgroundClip'] ?? null; + } + $styles = wp_style_engine_get_styles( array( 'background' => $background_styles ) ); if ( ! empty( $styles['css'] ) ) { @@ -111,7 +118,19 @@ function wp_render_background_support( $block_content, $block ) { } $tags->set_attribute( 'style', $updated_style ); - $tags->add_class( 'has-background' ); + + /* + * A background clipped to the text paints the glyphs rather than the + * block's box, so there is no block background to announce. Read the + * clip from the serialized styles so an unsupported or skipped value + * left in the block's attributes cannot suppress the class. + */ + $background_clip = $background_styles['backgroundClip'] ?? null; + $has_background = ! empty( $background_styles['backgroundImage'] ) || ! empty( $background_styles['gradient'] ); + + if ( $has_background && 'text' !== $background_clip ) { + $tags->add_class( 'has-background' ); + } } return $tags->get_updated_html(); diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 116a8ad951983..83ba647467883 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -259,6 +259,7 @@ class WP_Theme_JSON { * @since 7.0.0 Added `dimensions.width` and `dimensions.height`. * Added `text-indent` property. * @since 7.1.0 Added `min-width` and `text-shadow`. + * @since 7.2.0 Added `background-clip`. * @var array */ const PROPERTIES_METADATA = array( @@ -270,6 +271,7 @@ class WP_Theme_JSON { 'background-repeat' => array( 'background', 'backgroundRepeat' ), 'background-size' => array( 'background', 'backgroundSize' ), 'background-attachment' => array( 'background', 'backgroundAttachment' ), + 'background-clip' => array( 'background', 'backgroundClip' ), 'border-radius' => array( 'border', 'radius' ), 'border-top-left-radius' => array( 'border', 'radius', 'topLeft' ), 'border-top-right-radius' => array( 'border', 'radius', 'topRight' ), @@ -428,6 +430,7 @@ class WP_Theme_JSON { * Added support for `dimensions.width` and `dimensions.height`. * Added support for `typography.textIndent`. * @since 7.1.0 Added `viewport` property. + * @since 7.2.0 Added `background.backgroundClip` property. * Added support for `background.gradient`, `dimensions.minWidth` and `blockVisibility.allowEditing`. * @since 7.2.0 Added support for `typography.textShadow`, `typography.textShadowPresets`, * and `typography.defaultTextShadowPresets`. @@ -437,6 +440,7 @@ class WP_Theme_JSON { 'appearanceTools' => null, 'useRootPaddingAwareAlignments' => null, 'background' => array( + 'backgroundClip' => null, 'backgroundImage' => null, 'backgroundSize' => null, 'gradient' => null, @@ -582,10 +586,12 @@ class WP_Theme_JSON { * @since 7.0.0 Added support for `dimensions.width` and `dimensions.height`. * @since 7.1.0 Added support for `background.gradient`,`dimensions.minWidth`, * and `typography.textShadow`. + * @since 7.2.0 Added support for `background.backgroundClip`. * @var array */ const VALID_STYLES = array( 'background' => array( + 'backgroundClip' => null, 'backgroundImage' => null, 'backgroundPosition' => null, 'backgroundRepeat' => null, @@ -3224,6 +3230,34 @@ protected static function compute_style_properties( $styles, $settings = array() 'name' => $css_property, 'value' => $value, ); + + /* + * Background clipping needs vendor prefixed properties for + * cross-browser support. The `text` value clips the background to + * the text and makes it visible through a transparent fill. The box + * values only reset the fill color, to cancel any inherited text + * clipping. `-webkit-background-clip` is an alias of + * `background-clip` in Chromium, so resetting it would discard the + * value set above. Values outside the set the style engine accepts + * get neither, so a typo cannot reset the fill color. + */ + if ( 'background-clip' === $css_property ) { + if ( 'text' === $value ) { + $declarations[] = array( + 'name' => '-webkit-background-clip', + 'value' => 'text', + ); + $declarations[] = array( + 'name' => '-webkit-text-fill-color', + 'value' => 'transparent', + ); + } elseif ( in_array( $value, array( 'border-box', 'padding-box', 'content-box' ), true ) ) { + $declarations[] = array( + 'name' => '-webkit-text-fill-color', + 'value' => 'currentColor', + ); + } + } } // If a variable value is added to the root, the corresponding property should be removed. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index cbe2f4d04c3a7..877c938891379 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3689,6 +3689,7 @@ function kses_init() { * @since 6.6.0 Added support for `grid-column`, `grid-row`, and `container-type`. * @since 6.9.0 Added support for `white-space`. * @since 7.1.0 Extended gradient support to allow any single-level nested function. + * @since 7.2.0 Added support for `background-clip`. * Added support for transform functions, `clip-path` basic shapes, * and URLs in the SVG element reference properties. * @since 7.2.0 Added support for CSS anchor positioning properties and `text-shadow`. @@ -3717,6 +3718,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { * * @since 2.8.1 * @since 7.1.0 Added support for SVG presentation attributes. + * @since 7.2.0 Added support for `background-clip` and its vendor prefixed companions. * * @param string[] $attr Array of allowed CSS attributes. */ @@ -3731,6 +3733,9 @@ function safecss_filter_attr( $css, $deprecated = '' ) { 'background-size', 'background-attachment', 'background-blend-mode', + 'background-clip', + '-webkit-background-clip', + '-webkit-text-fill-color', 'border', 'border-radius', diff --git a/src/wp-includes/style-engine/class-wp-style-engine.php b/src/wp-includes/style-engine/class-wp-style-engine.php index b2988c7140b07..58b79592d8aa1 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine.php +++ b/src/wp-includes/style-engine/class-wp-style-engine.php @@ -50,6 +50,7 @@ final class WP_Style_Engine { * * @since 6.1.0 * @since 7.1.0 Added `background.gradient` property. + * @since 7.2.0 Added `background.backgroundClip` property. * @var array */ const BLOCK_STYLE_DEFINITIONS_METADATA = array( @@ -97,6 +98,10 @@ final class WP_Style_Engine { 'has-background' => true, ), ), + 'backgroundClip' => array( + 'value_func' => array( self::class, 'get_background_clip_css_declarations' ), + 'path' => array( 'background', 'backgroundClip' ), + ), ), 'color' => array( 'text' => array( @@ -768,6 +773,50 @@ protected static function get_url_or_value_css_declaration( $style_value, $style return $css_declarations; } + /** + * Style value parser that returns the CSS declarations for background clipping. + * + * For the `text` value the background is clipped to the block's text, which + * requires the vendor prefixed properties and a transparent fill color. + * + * @since 7.2.0 + * + * @param string $style_value A single raw style value from $block_styles array. + * @param array $style_definition A single style definition from BLOCK_STYLE_DEFINITIONS_METADATA. + * @return string[] An associative array of CSS definitions, e.g., array( "$property" => "$value", "$property" => "$value" ). + */ + protected static function get_background_clip_css_declarations( $style_value, $style_definition ) { + if ( empty( $style_value ) || ! is_string( $style_value ) ) { + return array(); + } + + $valid_values = array( 'border-box', 'padding-box', 'content-box', 'text' ); + + if ( ! in_array( $style_value, $valid_values, true ) ) { + return array(); + } + + $css_declarations = array( + 'background-clip' => $style_value, + ); + + if ( 'text' === $style_value ) { + $css_declarations['-webkit-background-clip'] = 'text'; + $css_declarations['-webkit-text-fill-color'] = 'transparent'; + } else { + /* + * Only the fill color is restored. `-webkit-background-clip` is an + * alias of `background-clip` in Chromium, so resetting it here would + * discard the value set above. The fill color is inherited, so it + * needs its initial value rather than `unset`, which would take a + * transparent fill from an ancestor clipping to text. + */ + $css_declarations['-webkit-text-fill-color'] = 'currentColor'; + } + + return $css_declarations; + } + /** * Returns compiled CSS from CSS declarations. * diff --git a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php index 01eb2f8fa92bd..894022e6d7bf2 100644 --- a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php @@ -70,6 +70,7 @@ public function filter_set_theme_root() { * @ticket 61720 * @ticket 61858 * @ticket 64974 + * @ticket 66094 * * @covers ::wp_render_background_support * @@ -282,6 +283,108 @@ public static function data_background_block_support() { 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), + 'background clip alone does not add has-background' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-is-output', + 'background_settings' => array( + 'backgroundClip' => true, + ), + 'background_style' => array( + 'backgroundClip' => 'border-box', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clip text style is applied with vendor prefixes' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-text-is-output', + 'background_settings' => array( + 'backgroundClip' => true, + ), + 'background_style' => array( + 'backgroundClip' => 'text', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clip alone does not add has-background for block with only backgroundClip support' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-only-support', + 'background_settings' => array( + 'backgroundClip' => true, + ), + 'background_style' => array( + 'backgroundClip' => 'padding-box', + ), + 'expected_wrapper' => '

Content

', + 'wrapper' => '

Content

', + ), + 'background clip style is not applied if the block does not support it' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-not-supported', + 'background_settings' => array( + 'backgroundClip' => false, + ), + 'background_style' => array( + 'backgroundClip' => 'text', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clip keeps has-background when a background is painted into a box' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-with-gradient', + 'background_settings' => array( + 'gradient' => true, + 'backgroundClip' => true, + ), + 'background_style' => array( + 'gradient' => 'linear-gradient(135deg,rgb(255,0,0) 0%,rgb(0,0,255) 100%)', + 'backgroundClip' => 'padding-box', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clipped to text drops has-background from a gradient' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-text-with-gradient', + 'background_settings' => array( + 'gradient' => true, + 'backgroundClip' => true, + ), + 'background_style' => array( + 'gradient' => 'linear-gradient(135deg,rgb(255,0,0) 0%,rgb(0,0,255) 100%)', + 'backgroundClip' => 'text', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clipped to text keeps has-background when the block does not support the clip' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-text-without-clip-support', + 'background_settings' => array( + 'gradient' => true, + 'backgroundClip' => false, + ), + 'background_style' => array( + 'gradient' => 'linear-gradient(135deg,rgb(255,0,0) 0%,rgb(0,0,255) 100%)', + 'backgroundClip' => 'text', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clip style is appended to existing styles' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-appends-styles', + 'background_settings' => array( + 'backgroundClip' => true, + ), + 'background_style' => array( + 'backgroundClip' => 'content-box', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), ); } diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index fe858e07cb6a7..e53252a0715e3 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -25,6 +25,7 @@ class Tests_wpStyleEngine extends WP_UnitTestCase { * @ticket 63799 * @ticket 64974 * @ticket 65037 + * @ticket 66094 * * @covers ::wp_style_engine_get_styles * @@ -667,6 +668,49 @@ public function data_wp_style_engine_get_styles() { ), ), ), + + 'inline_background_clip_border_box' => array( + 'block_styles' => array( + 'background' => array( + 'backgroundClip' => 'border-box', + ), + ), + 'options' => array(), + 'expected_output' => array( + 'css' => 'background-clip:border-box;-webkit-text-fill-color:currentColor;', + 'declarations' => array( + 'background-clip' => 'border-box', + '-webkit-text-fill-color' => 'currentColor', + ), + ), + ), + + 'inline_background_clip_text_with_vendor_prefixes' => array( + 'block_styles' => array( + 'background' => array( + 'backgroundClip' => 'text', + ), + ), + 'options' => array(), + 'expected_output' => array( + 'css' => 'background-clip:text;-webkit-background-clip:text;-webkit-text-fill-color:transparent;', + 'declarations' => array( + 'background-clip' => 'text', + '-webkit-background-clip' => 'text', + '-webkit-text-fill-color' => 'transparent', + ), + ), + ), + + 'inline_background_clip_invalid_value' => array( + 'block_styles' => array( + 'background' => array( + 'backgroundClip' => 'invalid-value', + ), + ), + 'options' => array(), + 'expected_output' => array(), + ), ); } From fd2560c9a2105d3519594363ac1aceb935768a92 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:27:20 +0800 Subject: [PATCH 02/13] Editor: Escape an inherited text clip in block states and breakpoints Backports the PHP from Gutenberg PR 77142. A breakpoint or pseudo state layered over a text clip now unsets the inherited gradient when it sets a box clip, and restores the text fill when it only sets a text color. See #66094. --- src/wp-includes/block-supports/states.php | 103 +++- src/wp-includes/class-wp-theme-json.php | 143 ++++++ .../style-engine/class-wp-style-engine.php | 12 +- tests/phpunit/tests/block-supports/states.php | 125 +++++ tests/phpunit/tests/theme/wpThemeJson.php | 442 ++++++++++++++++++ 5 files changed, 816 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index ebd2c0915a3e6..589693751e2fd 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -147,6 +147,75 @@ function wp_get_state_declarations_with_background_resets( $declarations ) { return $declarations; } +/** + * Merges one state style object over another. + * + * Mirror of `mergeStyleObjects` in packages/block-editor/src/hooks/style.jsx. + * + * @since 7.2.0 + * + * @param array $target Styles underneath. + * @param array $source Styles layered over them. + * @return array Merged style object. + */ +function wp_merge_state_style_objects( $target, $source ) { + $target = is_array( $target ) ? $target : array(); + $source = is_array( $source ) ? $source : array(); + $merged = $target; + + foreach ( $source as $key => $value ) { + $merged[ $key ] = is_array( $value ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) + ? wp_merge_state_style_objects( $merged[ $key ], $value ) + : $value; + } + + return $merged; +} + +/** + * Adds the declarations a state needs to escape a text clip it layers over. + * + * Mirror of `getStateTextClipEscapeCSS` and `getStateTextFillResetCSS` in + * packages/block-editor/src/hooks/style.jsx. + * + * @since 7.2.0 + * + * @param array $declarations CSS declarations generated by the style engine. + * @param array $state_style State style object. + * @param array $base_style Styles the state layers over. + * @return array CSS declarations with the escapes applied where needed. + */ +function wp_get_state_declarations_with_text_clip_escapes( $declarations, $state_style, $base_style ) { + if ( ! is_array( $declarations ) || ! is_array( $base_style ) ) { + return $declarations; + } + + if ( 'text' !== ( $base_style['background']['backgroundClip'] ?? null ) ) { + return $declarations; + } + + $state_clip = $state_style['background']['backgroundClip'] ?? null; + + $paints_its_own_background = + ! empty( $state_style['background']['gradient'] ) || + ! empty( $state_style['background']['backgroundImage'] ) || + ! empty( $state_style['color']['gradient'] ); + + if ( $state_clip && 'text' !== $state_clip ) { + if ( ! $paints_its_own_background ) { + $declarations['background-image'] = 'unset'; + } + + return $declarations; + } + + if ( ! $state_clip && ! $paints_its_own_background && ! empty( $state_style['color']['text'] ) ) { + $declarations['-webkit-text-fill-color'] = 'currentColor'; + } + + return $declarations; +} + /** * Adds fallback dimension styles for aspectRatio and height block-support values. * @@ -365,14 +434,16 @@ function wp_get_block_state_element_selectors( $root_selector ) { * * @since 7.1.0 * @since 7.2.0 Added fallback dimension styles. + * Added the `$base_style` parameter. * * @param array $css_rules Style rules. * @param string $state Pseudo-state selector. * @param string|null $selector Block, feature, or element selector. * @param array $style Style object. * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. + * @param array|null $base_style Optional styles the state layers over. */ -function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $rules_group = null ) { +function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $rules_group = null, $base_style = null ) { if ( empty( $style ) || ! is_array( $style ) ) { return; } @@ -389,6 +460,12 @@ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $declarations['text-align'] = $text_align; } + $declarations = wp_get_state_declarations_with_text_clip_escapes( + $declarations, + $style, + $base_style + ); + if ( empty( $declarations ) ) { return; } @@ -407,13 +484,15 @@ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, * Builds compiled state style rules, preserving the selector each rule targets. * * @since 7.1.0 + * @since 7.2.0 Added the `$base_style` parameter. * * @param array $state_styles Map of state to style array. * @param WP_Block_Type $block_type Block type. * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. + * @param array|null $base_style Optional styles the state layers over. * @return array[] State style rules. */ -function wp_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null ) { +function wp_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null, $base_style = null ) { $css_rules = array(); $block_selectors = is_array( $block_type->selectors ) ? $block_type->selectors @@ -430,7 +509,8 @@ function wp_get_block_state_style_rules( $state_styles, $block_type, $rules_grou $state, $group['selector'], $group['style'], - $rules_group + $rules_group, + $base_style ); } } @@ -576,6 +656,10 @@ function wp_render_block_states_support( $block_content, $block ) { $supported_pseudo_states = WP_Theme_JSON::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array(); $css_rules = array(); + $default_root_style = wp_get_root_state_style( + $style, + array_merge( array( 'elements' ), $supported_pseudo_states ) + ); $viewport_settings = wp_get_global_settings( array( 'viewport' ) ); $responsive_media_queries = WP_Theme_JSON::get_viewport_media_queries( $viewport_settings ); @@ -588,7 +672,9 @@ function wp_render_block_states_support( $block_content, $block ) { $css_rules, wp_get_block_state_style_rules( array( $pseudo_state => $style[ $pseudo_state ] ), - $block_type + $block_type, + null, + $default_root_style ) ); } @@ -609,7 +695,8 @@ function wp_render_block_states_support( $block_content, $block ) { wp_get_block_state_style_rules( array( '' => $root_state_style ), $block_type, - $media_query + $media_query, + $default_root_style ) ); } @@ -675,7 +762,11 @@ function wp_render_block_states_support( $block_content, $block ) { wp_get_block_state_style_rules( array( $pseudo_state => $style[ $breakpoint ][ $pseudo_state ] ), $block_type, - $media_query + $media_query, + wp_merge_state_style_objects( + $default_root_style, + $root_state_style + ) ) ); } diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 83ba647467883..092d931e10a52 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -1006,6 +1006,15 @@ private function process_pseudo_selectors( $node, $base_selector, $settings, $bl return $pseudo_declarations; } + // Nearest first: this node, then its variation's block. + $base_styles = array( $node ); + foreach ( array( $style_variation['path'] ?? null, $block_metadata['path'] ?? null ) as $path ) { + $layer = $path ? _wp_array_get( $this->theme_json, $path ) : null; + if ( is_array( $layer ) ) { + $base_styles[] = $layer; + } + } + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) { if ( isset( $node[ $pseudo_selector ] ) ) { $pseudo_node = $node[ $pseudo_selector ]; @@ -1027,6 +1036,7 @@ private function process_pseudo_selectors( $node, $base_selector, $settings, $bl $combined_selector = static::append_to_selector( $base_selector, $pseudo_selector ); $declarations = static::compute_style_properties( $pseudo_node, $settings, null, null ); + $declarations = static::get_state_declarations_with_text_clip_escapes( $declarations, $pseudo_node, $base_styles ); $add_declarations( $combined_selector, $declarations ); } } @@ -3955,6 +3965,126 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt return $nodes; } + /** + * Returns the node keys that hold a state's styles: a breakpoint, a pseudo + * state or a custom state. + * + * @since 7.2.0 + * + * @param array|null $settings Settings from theme.json. + * @return string[] Style-state keys. + */ + private static function get_style_state_keys( $settings ) { + $keys = array_keys( static::get_viewport_media_queries( $settings['viewport'] ?? null ) ); + + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS as $pseudo_selectors ) { + $keys = array_merge( $keys, $pseudo_selectors ); + } + + foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS as $pseudo_selectors ) { + $keys = array_merge( $keys, $pseudo_selectors ); + } + + foreach ( static::VALID_BLOCK_CUSTOM_STATES as $custom_states ) { + $keys = array_merge( $keys, $custom_states ); + } + + return array_values( array_unique( $keys ) ); + } + + /** + * Returns the style objects a state node layers over, nearest first. + * + * Drops the path's state keys (e.g. `@mobile`) from the innermost outward. + * + * @since 7.2.0 + * + * @param array $path Path to the node within the theme.json tree. + * @param array $tree The theme.json tree to read from. + * @param array|null $settings Settings from theme.json. + * @return array[] Style objects, nearest first. + */ + private static function get_state_base_styles( $path, $tree, $settings ) { + if ( ! is_array( $path ) ) { + return array(); + } + + $state_keys = static::get_style_state_keys( $settings ); + $base_path = $path; + $base_styles = array(); + + foreach ( array_reverse( array_keys( $path ) ) as $index ) { + if ( ! in_array( $path[ $index ], $state_keys, true ) ) { + continue; + } + + unset( $base_path[ $index ] ); + $layer = _wp_array_get( $tree, array_values( $base_path ) ); + if ( is_array( $layer ) ) { + $base_styles[] = $layer; + } + } + + return $base_styles; + } + + /** + * Adds the declarations a state needs to escape a text clip set below it. + * + * Mirror of `wp_get_state_declarations_with_text_clip_escapes()`. + * + * @since 7.2.0 + * + * @param array $declarations Declarations generated for the state. + * @param array $state_style State style object. + * @param array[] $base_styles Style objects the state layers over, nearest first. + * @return array Declarations with the escapes applied where needed. + */ + private static function get_state_declarations_with_text_clip_escapes( $declarations, $state_style, $base_styles ) { + if ( ! is_array( $declarations ) || ! is_array( $state_style ) || ! is_array( $base_styles ) ) { + return $declarations; + } + + $base_clip = null; + foreach ( $base_styles as $base_style ) { + if ( ! empty( $base_style['background']['backgroundClip'] ) ) { + $base_clip = $base_style['background']['backgroundClip']; + break; + } + } + + if ( 'text' !== $base_clip ) { + return $declarations; + } + + $state_clip = $state_style['background']['backgroundClip'] ?? null; + + $paints_its_own_background = + ! empty( $state_style['background']['gradient'] ) || + ! empty( $state_style['background']['backgroundImage'] ) || + ! empty( $state_style['color']['gradient'] ); + + if ( $state_clip && 'text' !== $state_clip ) { + if ( ! $paints_its_own_background ) { + $declarations[] = array( + 'name' => 'background-image', + 'value' => 'unset', + ); + } + + return $declarations; + } + + if ( ! $state_clip && ! $paints_its_own_background && ! empty( $state_style['color']['text'] ) ) { + $declarations[] = array( + 'name' => '-webkit-text-fill-color', + 'value' => 'currentColor', + ); + } + + return $declarations; + } + /** * Gets the CSS rules for a particular block from theme.json. * @@ -4090,6 +4220,11 @@ public function get_styles_for_block( $block_metadata ) { // Process base properties for this breakpoint. $breakpoint_declarations = static::compute_style_properties( $breakpoint_node, $settings, null, $this->theme_json ); + $breakpoint_declarations = static::get_state_declarations_with_text_clip_escapes( + $breakpoint_declarations, + $breakpoint_node, + array( $style_variation_node, $node ) + ); if ( ! empty( $breakpoint_declarations ) ) { $base_ruleset = static::to_ruleset( ':root :where(' . $style_variation['selector'] . ')', $breakpoint_declarations ); $variation_responsive_css .= $breakpoint_media . '{' . $base_ruleset . '}'; @@ -4214,11 +4349,18 @@ static function ( $pseudo_selector ) use ( $selector ) { * element then compute the style properties for it. * Otherwise just compute the styles for the default selector as normal. */ + $base_styles = static::get_state_base_styles( $block_metadata['path'] ?? array(), $this->theme_json, $settings ); + if ( $pseudo_selector && isset( $node[ $pseudo_selector ] ) && isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] ) && in_array( $pseudo_selector, static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ], true ) ) { $declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, $this->theme_json, $selector, $use_root_padding ); + $declarations = static::get_state_declarations_with_text_clip_escapes( + $declarations, + $node[ $pseudo_selector ], + array_merge( array( $node ), $base_styles ) + ); } else { /* * For block pseudo-selector nodes (e.g. ':hover'), $node has already had any @@ -4226,6 +4368,7 @@ static function ( $pseudo_selector ) use ( $selector ) { * so those properties are not output twice. */ $declarations = static::compute_style_properties( $node, $settings, null, $this->theme_json, $selector, $use_root_padding ); + $declarations = static::get_state_declarations_with_text_clip_escapes( $declarations, $node, $base_styles ); } $block_rules = ''; diff --git a/src/wp-includes/style-engine/class-wp-style-engine.php b/src/wp-includes/style-engine/class-wp-style-engine.php index 58b79592d8aa1..f77d250821bd1 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine.php +++ b/src/wp-includes/style-engine/class-wp-style-engine.php @@ -31,6 +31,14 @@ */ #[AllowDynamicProperties] final class WP_Style_Engine { + /** + * The values `background-clip` accepts. + * + * @since 7.2.0 + * @var string[] + */ + const VALID_BACKGROUND_CLIP_VALUES = array( 'border-box', 'padding-box', 'content-box', 'text' ); + /** * Style definitions that contain the instructions to parse/output valid Gutenberg styles from a block's attributes. * @@ -790,9 +798,7 @@ protected static function get_background_clip_css_declarations( $style_value, $s return array(); } - $valid_values = array( 'border-box', 'padding-box', 'content-box', 'text' ); - - if ( ! in_array( $style_value, $valid_values, true ) ) { + if ( ! in_array( $style_value, static::VALID_BACKGROUND_CLIP_VALUES, true ) ) { return array(); } diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index e66ab3ee146d5..1ef20f147f63a 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -160,6 +160,131 @@ public function test_adds_background_image_reset_for_solid_background_color() { ); } + /** + * Tests that a state escaping a text clip stops the inherited gradient painting. + * + * @ticket 66094 + * @covers ::wp_get_state_declarations_with_text_clip_escapes + */ + public function test_adds_background_image_reset_when_a_state_escapes_a_text_clip() { + $actual = wp_get_state_declarations_with_text_clip_escapes( + array( 'background-clip' => 'border-box' ), + array( 'background' => array( 'backgroundClip' => 'border-box' ) ), + array( 'background' => array( 'backgroundClip' => 'text' ) ) + ); + + $this->assertSame( + array( + 'background-clip' => 'border-box', + 'background-image' => 'unset', + ), + $actual + ); + } + + /** + * Tests that a background the state paints itself is kept. + * + * @ticket 66094 + * @covers ::wp_get_state_declarations_with_text_clip_escapes + */ + public function test_no_background_image_reset_when_the_state_paints_its_own_background() { + $state_style = array( + 'background' => array( + 'backgroundClip' => 'padding-box', + 'gradient' => 'linear-gradient(135deg, #ff0000, #0000ff)', + ), + ); + + $actual = wp_get_state_declarations_with_text_clip_escapes( + array( 'background-clip' => 'padding-box' ), + $state_style, + array( 'background' => array( 'backgroundClip' => 'text' ) ) + ); + + $this->assertSame( array( 'background-clip' => 'padding-box' ), $actual ); + } + + /** + * Tests that a box clip is left alone when nothing above it clips to text. + * + * @ticket 66094 + * @covers ::wp_get_state_declarations_with_text_clip_escapes + */ + public function test_no_background_image_reset_without_an_inherited_text_clip() { + $actual = wp_get_state_declarations_with_text_clip_escapes( + array( 'background-clip' => 'padding-box' ), + array( 'background' => array( 'backgroundClip' => 'padding-box' ) ), + array( 'background' => array( 'backgroundImage' => array( 'url' => 'image.png' ) ) ) + ); + + $this->assertSame( array( 'background-clip' => 'padding-box' ), $actual ); + } + + /** + * Tests that a text color set under a text clip is painted. + * + * @ticket 66094 + * @covers ::wp_get_state_declarations_with_text_clip_escapes + */ + public function test_adds_text_fill_reset_for_a_text_color_under_a_text_clip() { + $actual = wp_get_state_declarations_with_text_clip_escapes( + array( 'color' => '#ff0000' ), + array( 'color' => array( 'text' => '#ff0000' ) ), + array( 'background' => array( 'backgroundClip' => 'text' ) ) + ); + + $this->assertSame( + array( + 'color' => '#ff0000', + '-webkit-text-fill-color' => 'currentColor', + ), + $actual + ); + } + + /** + * Tests that the fill is left alone when the state paints its own gradient. + * + * @ticket 66094 + * @covers ::wp_get_state_declarations_with_text_clip_escapes + */ + public function test_no_text_fill_reset_when_the_state_paints_its_own_gradient() { + $state_style = array( + 'color' => array( 'text' => '#ff0000' ), + 'background' => array( 'gradient' => 'linear-gradient(135deg, #ff0000, #0000ff)' ), + ); + + $actual = wp_get_state_declarations_with_text_clip_escapes( + array( 'color' => '#ff0000' ), + $state_style, + array( 'background' => array( 'backgroundClip' => 'text' ) ) + ); + + $this->assertSame( array( 'color' => '#ff0000' ), $actual ); + } + + /** + * Tests that the fill is left to the style engine when the state sets its own clip. + * + * @ticket 66094 + * @covers ::wp_get_state_declarations_with_text_clip_escapes + */ + public function test_no_text_fill_reset_when_the_state_sets_its_own_clip() { + $state_style = array( + 'color' => array( 'text' => '#ff0000' ), + 'background' => array( 'backgroundClip' => 'text' ), + ); + + $actual = wp_get_state_declarations_with_text_clip_escapes( + array( 'color' => '#ff0000' ), + $state_style, + array( 'background' => array( 'backgroundClip' => 'text' ) ) + ); + + $this->assertSame( array( 'color' => '#ff0000' ), $actual ); + } + /** * Tests that background-image reset is not added when the state also sets a legacy gradient. * diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 0b75ece403145..b5eec7e6b91ec 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -1372,6 +1372,448 @@ public function test_get_stylesheet_uses_custom_viewport_breakpoints_for_respons ); } + /** + * Builds a Paragraph stylesheet with a text gradient and the given states. + * + * @param array $state_styles State styles to merge into the block styles. + * @return string Generated stylesheet. + */ + private function get_text_clip_stylesheet( $state_styles ) { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/paragraph' => array_merge( + array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ), + $state_styles + ), + ), + ), + ) + ); + + return $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_escaping_a_text_clip_clears_the_inherited_gradient() { + $stylesheet = $this->get_text_clip_stylesheet( + array( + '@mobile' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ) + ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(p){background-clip: border-box;-webkit-text-fill-color: currentColor;background-image: unset;}}', + $stylesheet + ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_painting_its_own_background_keeps_it() { + $stylesheet = $this->get_text_clip_stylesheet( + array( + '@mobile' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#00ff00,#ffff00)', + 'backgroundClip' => 'border-box', + ), + ), + ) + ); + + $this->assertStringNotContainsString( 'background-image: unset', $stylesheet ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_text_color_under_a_text_clip_gets_its_fill_back() { + $stylesheet = $this->get_text_clip_stylesheet( + array( '@mobile' => array( 'color' => array( 'text' => '#00ff00' ) ) ) + ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(p){color: #00ff00;-webkit-text-fill-color: currentColor;}}', + $stylesheet + ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_keeping_the_text_clip_needs_no_escape() { + $stylesheet = $this->get_text_clip_stylesheet( + array( + '@mobile' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#00ff00,#ffff00)', + 'backgroundClip' => 'text', + ), + ), + ) + ); + + $this->assertStringNotContainsString( 'background-image: unset', $stylesheet ); + $this->assertStringNotContainsString( '-webkit-text-fill-color: currentColor', $stylesheet ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_element_styles_read_the_element_clip() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ), + ), + '@mobile' => array( + 'elements' => array( + 'link' => array( 'color' => array( 'text' => '#00ff00' ) ), + ), + ), + ), + ), + ), + ) + ); + + $stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + // The state key sits in the middle of this node's path, not at its end. + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.wp-block-group a:where(:not(.wp-element-button))){color: #00ff00;-webkit-text-fill-color: currentColor;}}', + $stylesheet + ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_variation_styles_read_the_variation_clip() { + register_block_style( + 'core/group', + array( + 'name' => 'flashy', + 'label' => 'Flashy', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'variations' => array( + 'flashy' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + '@mobile' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ), + ), + ), + ), + ), + ) + ); + + $actual_styles = $theme_json->get_styles_for_block( + array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + 'variations' => array( + array( + 'path' => array( 'styles', 'blocks', 'core/group', 'variations', 'flashy' ), + 'selector' => '.is-style-flashy.wp-block-group', + ), + ), + ) + ); + + unregister_block_style( 'core/group', 'flashy' ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.is-style-flashy.wp-block-group){background-clip: border-box;-webkit-text-fill-color: currentColor;background-image: unset;}}', + $actual_styles + ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_variation_styles_read_the_block_clip() { + register_block_style( + 'core/group', + array( + 'name' => 'flashy', + 'label' => 'Flashy', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + 'variations' => array( + 'flashy' => array( + '@mobile' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ), + ), + ), + ), + ), + ) + ); + + $actual_styles = $theme_json->get_styles_for_block( + array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + 'variations' => array( + array( + 'path' => array( 'styles', 'blocks', 'core/group', 'variations', 'flashy' ), + 'selector' => '.is-style-flashy.wp-block-group', + ), + ), + ) + ); + + unregister_block_style( 'core/group', 'flashy' ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.is-style-flashy.wp-block-group){background-clip: border-box;-webkit-text-fill-color: currentColor;background-image: unset;}}', + $actual_styles + ); + } + + /** + * @ticket 66094 + */ + public function test_variation_pseudo_state_reads_the_block_clip() { + register_block_style( + 'core/button', + array( + 'name' => 'flashy', + 'label' => 'Flashy', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + 'variations' => array( + 'flashy' => array( + ':hover' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ), + ), + ), + ), + ), + ) + ); + + $actual_styles = $theme_json->get_styles_for_block( + array( + 'name' => 'core/button', + 'path' => array( 'styles', 'blocks', 'core/button' ), + 'selector' => '.wp-block-button', + 'css' => '.wp-block-button', + 'variations' => array( + array( + 'path' => array( 'styles', 'blocks', 'core/button', 'variations', 'flashy' ), + 'selector' => '.is-style-flashy.wp-block-button', + ), + ), + ) + ); + + unregister_block_style( 'core/button', 'flashy' ); + + $this->assertStringContainsString( + ':root :where(.is-style-flashy.wp-block-button:hover){background-clip: border-box;-webkit-text-fill-color: currentColor;background-image: unset;}', + $actual_styles + ); + } + + /** + * @ticket 66094 + */ + public function test_variation_breakpoint_pseudo_state_reads_the_block_clip() { + register_block_style( + 'core/button', + array( + 'name' => 'flashy', + 'label' => 'Flashy', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + 'variations' => array( + 'flashy' => array( + '@mobile' => array( + ':hover' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $actual_styles = $theme_json->get_styles_for_block( + array( + 'name' => 'core/button', + 'path' => array( 'styles', 'blocks', 'core/button' ), + 'selector' => '.wp-block-button', + 'css' => '.wp-block-button', + 'variations' => array( + array( + 'path' => array( 'styles', 'blocks', 'core/button', 'variations', 'flashy' ), + 'selector' => '.is-style-flashy.wp-block-button', + ), + ), + ) + ); + + unregister_block_style( 'core/button', 'flashy' ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.is-style-flashy.wp-block-button:hover){background-clip: border-box;-webkit-text-fill-color: currentColor;background-image: unset;}}', + $actual_styles + ); + } + + /** + * @ticket 66094 + */ + public function test_pseudo_state_reads_the_clip_its_own_breakpoint_sets() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + '@mobile' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ':hover' => array( 'color' => array( 'text' => '#00ff00' ) ), + ), + ), + ), + ), + ) + ); + + $stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.wp-block-button .wp-block-button__link:hover){color: #00ff00;-webkit-text-fill-color: currentColor;}}', + $stylesheet + ); + } + + /** + * @ticket 66094 + */ + public function test_pseudo_state_inside_a_breakpoint_reads_the_breakpoint_clip() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + '@mobile' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ':hover' => array( 'color' => array( 'text' => '#00ff00' ) ), + ), + ), + ), + ), + ) + ); + + $stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + // The breakpoint already escaped the clip, so no fill is needed. + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.wp-block-button .wp-block-button__link:hover){color: #00ff00;}}', + $stylesheet + ); + } + /** * @ticket 65596 */ From d0f81d0739c77ae037c8e158d3bb741127ac7fae Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:52:47 +0800 Subject: [PATCH 03/13] Editor: Show a block's breakpoint text color over an inherited text gradient Backports the PHP from Gutenberg PR 77142. A breakpoint or hover text color on a block that supports the background clip now fills the letters again even when the text gradient comes from global styles. See #66094. --- src/wp-includes/block-supports/states.php | 50 ++++++++++------ tests/phpunit/tests/block-supports/states.php | 60 +++++++++++++++++++ 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index 589693751e2fd..76b2f9a427a5d 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -175,22 +175,22 @@ function wp_merge_state_style_objects( $target, $source ) { /** * Adds the declarations a state needs to escape a text clip it layers over. * + * A state's text color fills the letters again whether the text clip comes + * from the block's own styles or from global styles, which the block cannot + * see. The fill does nothing when no text clip applies. + * * Mirror of `getStateTextClipEscapeCSS` and `getStateTextFillResetCSS` in * packages/block-editor/src/hooks/style.jsx. * * @since 7.2.0 * - * @param array $declarations CSS declarations generated by the style engine. - * @param array $state_style State style object. - * @param array $base_style Styles the state layers over. + * @param array $declarations CSS declarations generated by the style engine. + * @param array $state_style State style object. + * @param array|null $base_style Styles the state layers over. * @return array CSS declarations with the escapes applied where needed. */ function wp_get_state_declarations_with_text_clip_escapes( $declarations, $state_style, $base_style ) { - if ( ! is_array( $declarations ) || ! is_array( $base_style ) ) { - return $declarations; - } - - if ( 'text' !== ( $base_style['background']['backgroundClip'] ?? null ) ) { + if ( ! is_array( $declarations ) || ! is_array( $state_style ) ) { return $declarations; } @@ -201,16 +201,22 @@ function wp_get_state_declarations_with_text_clip_escapes( $declarations, $state ! empty( $state_style['background']['backgroundImage'] ) || ! empty( $state_style['color']['gradient'] ); - if ( $state_clip && 'text' !== $state_clip ) { - if ( ! $paints_its_own_background ) { - $declarations['background-image'] = 'unset'; + if ( $paints_its_own_background ) { + return $declarations; + } + + if ( ! $state_clip ) { + if ( ! empty( $state_style['color']['text'] ) ) { + $declarations['-webkit-text-fill-color'] = 'currentColor'; } return $declarations; } - if ( ! $state_clip && ! $paints_its_own_background && ! empty( $state_style['color']['text'] ) ) { - $declarations['-webkit-text-fill-color'] = 'currentColor'; + $base_clip = is_array( $base_style ) ? ( $base_style['background']['backgroundClip'] ?? null ) : null; + + if ( 'text' !== $state_clip && 'text' === $base_clip ) { + $declarations['background-image'] = 'unset'; } return $declarations; @@ -460,11 +466,14 @@ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $declarations['text-align'] = $text_align; } - $declarations = wp_get_state_declarations_with_text_clip_escapes( - $declarations, - $style, - $base_style - ); + // Only block states pass a base; element states match the editor and skip it. + if ( null !== $base_style ) { + $declarations = wp_get_state_declarations_with_text_clip_escapes( + $declarations, + $style, + $base_style + ); + } if ( empty( $declarations ) ) { return; @@ -493,6 +502,11 @@ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, * @return array[] State style rules. */ function wp_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null, $base_style = null ) { + // Only a block that supports the clip can sit under a text clip to escape. + if ( ! block_has_support( $block_type, array( 'background', 'backgroundClip' ), false ) ) { + $base_style = null; + } + $css_rules = array(); $block_selectors = is_array( $block_type->selectors ) ? $block_type->selectors diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 1ef20f147f63a..3cc39d42a722d 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -243,6 +243,29 @@ public function test_adds_text_fill_reset_for_a_text_color_under_a_text_clip() { ); } + /** + * Tests that a text color is painted under a text clip from global styles, + * which the block's own styles do not show. + * + * @ticket 66094 + * @covers ::wp_get_state_declarations_with_text_clip_escapes + */ + public function test_adds_text_fill_reset_for_a_text_color_without_a_known_text_clip() { + $actual = wp_get_state_declarations_with_text_clip_escapes( + array( 'color' => '#ff0000' ), + array( 'color' => array( 'text' => '#ff0000' ) ), + array() + ); + + $this->assertSame( + array( + 'color' => '#ff0000', + '-webkit-text-fill-color' => 'currentColor', + ), + $actual + ); + } + /** * Tests that the fill is left alone when the state paints its own gradient. * @@ -1143,6 +1166,43 @@ public function test_responsive_root_state_generates_media_query_scoped_css() { ); } + /** + * Tests that a responsive text color fills the letters again for a block + * that supports the background clip, so a text gradient from global styles + * cannot hide it. + * + * @ticket 66094 + */ + public function test_responsive_text_color_fills_text_for_a_block_that_supports_the_clip() { + $this->ensure_block_registered( + 'test/responsive-text-clip-state', + array(), + array( 'background' => array( 'backgroundClip' => true ) ) + ); + + $block = array( + 'blockName' => 'test/responsive-text-clip-state', + 'attrs' => array( + 'style' => array( + '@mobile' => array( + 'color' => array( + 'text' => '#ff0000', + ), + ), + ), + ), + ); + + $actual = wp_render_block_states_support( '
Hello
', $block ); + preg_match( '/wp-states-[a-f0-9]{8}/', $actual, $matches ); + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $matches[0] . '{color:#ff0000 !important;-webkit-text-fill-color:currentColor !important;}}', + $actual_stylesheet + ); + } + /** * Tests that responsive text alignment generates media-query scoped CSS. * From b317898bd548fb40597fec9a3372e7d4c69815b4 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:54:16 +0800 Subject: [PATCH 04/13] Editor: Skip has-background for a background color clipped to the text Backports the PHP from Gutenberg PR 77142. A background color on a block that clips its background to the text is clipped to the letters too, so it no longer adds has-background and the padding that comes with it. See #66094. --- src/wp-includes/block-supports/colors.php | 13 +++ tests/phpunit/tests/block-supports/colors.php | 108 ++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/wp-includes/block-supports/colors.php b/src/wp-includes/block-supports/colors.php index 567a5422490b3..95711d1b1fd40 100644 --- a/src/wp-includes/block-supports/colors.php +++ b/src/wp-includes/block-supports/colors.php @@ -74,6 +74,7 @@ function wp_register_colors_support( $block_type ) { * @since 5.6.0 * @since 6.1.0 Implemented the style engine to generate CSS and classnames. * @since 7.1.0 Suppresses color.gradient when background.gradient is supported and set. + * @since 7.2.0 Skips `has-background` for a background color clipped to the text. * @access private * * @param WP_Block_Type $block_type Block type. @@ -133,6 +134,18 @@ function wp_apply_colors_support( $block_type, $block_attributes ) { $attributes = array(); $styles = wp_style_engine_get_styles( array( 'color' => $color_block_styles ), array( 'convert_vars_to_classnames' => true ) ); + /* + * Mirror of the background support: a color clipped to the text paints the + * glyphs, not the box, so only a gradient still adds `has-background`. + */ + $clips_to_text = 'text' === ( $block_attributes['style']['background']['backgroundClip'] ?? null ) && + block_has_support( $block_type, array( 'background', 'backgroundClip' ), false ) && + ! wp_should_skip_block_supports_serialization( $block_type, 'background', 'backgroundClip' ); + + if ( $clips_to_text && ! empty( $styles['classnames'] ) && empty( $color_block_styles['gradient'] ) ) { + $styles['classnames'] = implode( ' ', array_diff( explode( ' ', $styles['classnames'] ), array( 'has-background' ) ) ); + } + if ( ! empty( $styles['classnames'] ) ) { $attributes['class'] = $styles['classnames']; } diff --git a/tests/phpunit/tests/block-supports/colors.php b/tests/phpunit/tests/block-supports/colors.php index 30e4985c63ed9..9ddb2a475e832 100644 --- a/tests/phpunit/tests/block-supports/colors.php +++ b/tests/phpunit/tests/block-supports/colors.php @@ -299,4 +299,112 @@ public function test_color_gradient_emitted_when_background_gradient_is_not_supp $this->assertSame( $expected, $actual ); } + + /** + * Registers a block with color and background clip support. + * + * @param array|bool $background_skip Background serialization skip. + * @return WP_Block_Type The registered block type. + */ + private function register_background_clip_block( $background_skip = false ) { + $this->test_block_name = 'test/color-background-clip'; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => array( + 'backgroundColor' => array( + 'type' => 'string', + ), + 'gradient' => array( + 'type' => 'string', + ), + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( + 'color' => array( + 'background' => true, + 'gradients' => true, + ), + 'background' => array( + 'backgroundClip' => true, + '__experimentalSkipSerialization' => $background_skip, + ), + ), + ) + ); + + return WP_Block_Type_Registry::get_instance()->get_registered( $this->test_block_name ); + } + + /** + * @ticket 66094 + * + * @dataProvider data_background_color_has_background_with_a_clip + * + * @param array $block_atts Block attributes. + * @param array|bool $background_skip Background serialization skip. + * @param string|null $expected_class Expected class attribute. + */ + public function test_background_color_has_background_with_a_clip( $block_atts, $background_skip, $expected_class ) { + $block_type = $this->register_background_clip_block( $background_skip ); + + $actual = wp_apply_colors_support( $block_type, $block_atts ); + + $this->assertSame( $expected_class, $actual['class'] ?? null ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_background_color_has_background_with_a_clip() { + return array( + 'preset color clipped to the text' => array( + array( + 'backgroundColor' => 'base', + 'style' => array( 'background' => array( 'backgroundClip' => 'text' ) ), + ), + false, + 'has-base-background-color', + ), + 'custom color clipped to the text' => array( + array( + 'style' => array( + 'color' => array( 'background' => '#ff0000' ), + 'background' => array( 'backgroundClip' => 'text' ), + ), + ), + false, + null, + ), + 'preset color clipped to a box' => array( + array( + 'backgroundColor' => 'base', + 'style' => array( 'background' => array( 'backgroundClip' => 'padding-box' ) ), + ), + false, + 'has-background has-base-background-color', + ), + 'preset color with a skipped text clip' => array( + array( + 'backgroundColor' => 'base', + 'style' => array( 'background' => array( 'backgroundClip' => 'text' ) ), + ), + array( 'backgroundClip' ), + 'has-background has-base-background-color', + ), + 'color gradient over a text clip' => array( + array( + 'gradient' => 'vivid', + 'style' => array( 'background' => array( 'backgroundClip' => 'text' ) ), + ), + false, + 'has-background has-vivid-gradient-background', + ), + ); + } } From e0d34542157fe073129e28ac068faec3df370acb Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:12:29 +0800 Subject: [PATCH 05/13] Docs: Move the background-clip note under the 7.2.0 tag in safecss_filter_attr(). The 7.2.0 line for background-clip sat between the 7.1.0 line and its continuation, so the 7.1.0 transform and clip-path notes read as 7.2.0 changes. It now continues the existing 7.2.0 entry. See #66094. --- src/wp-includes/kses.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 877c938891379..c68b71fcc1527 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3689,10 +3689,10 @@ function kses_init() { * @since 6.6.0 Added support for `grid-column`, `grid-row`, and `container-type`. * @since 6.9.0 Added support for `white-space`. * @since 7.1.0 Extended gradient support to allow any single-level nested function. - * @since 7.2.0 Added support for `background-clip`. * Added support for transform functions, `clip-path` basic shapes, * and URLs in the SVG element reference properties. * @since 7.2.0 Added support for CSS anchor positioning properties and `text-shadow`. + * Added support for `background-clip`. * * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. * @param string $deprecated Not used. From d9326f6eef2871228f5fff270f19fd8640bacdb4 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:08:27 +0800 Subject: [PATCH 06/13] Background clip: Add array shapes and type hints to state text clip helpers. --- src/wp-includes/block-supports/states.php | 38 ++++++++------ src/wp-includes/class-wp-theme-json.php | 64 ++++++++++++++--------- 2 files changed, 60 insertions(+), 42 deletions(-) diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index 76b2f9a427a5d..8b406faa4fea2 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -154,9 +154,9 @@ function wp_get_state_declarations_with_background_resets( $declarations ) { * * @since 7.2.0 * - * @param array $target Styles underneath. - * @param array $source Styles layered over them. - * @return array Merged style object. + * @param array $target Styles underneath. + * @param array $source Styles layered over them. + * @return array Merged style object. */ function wp_merge_state_style_objects( $target, $source ) { $target = is_array( $target ) ? $target : array(); @@ -184,10 +184,10 @@ function wp_merge_state_style_objects( $target, $source ) { * * @since 7.2.0 * - * @param array $declarations CSS declarations generated by the style engine. - * @param array $state_style State style object. - * @param array|null $base_style Styles the state layers over. - * @return array CSS declarations with the escapes applied where needed. + * @param array $declarations CSS declarations generated by the style engine. + * @param array $state_style State style object. + * @param array|null $base_style Styles the state layers over. + * @return array CSS declarations with the escapes applied where needed. */ function wp_get_state_declarations_with_text_clip_escapes( $declarations, $state_style, $base_style ) { if ( ! is_array( $declarations ) || ! is_array( $state_style ) ) { @@ -442,12 +442,14 @@ function wp_get_block_state_element_selectors( $root_selector ) { * @since 7.2.0 Added fallback dimension styles. * Added the `$base_style` parameter. * - * @param array $css_rules Style rules. - * @param string $state Pseudo-state selector. - * @param string|null $selector Block, feature, or element selector. - * @param array $style Style object. - * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. - * @param array|null $base_style Optional styles the state layers over. + * @param array[] $css_rules Style rules. + * @param string $state Pseudo-state selector. + * @param string|null $selector Block, feature, or element selector. + * @param array $style Style object. + * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. + * @param array|null $base_style Optional styles the state layers over. + * + * @phpstan-param list> $css_rules */ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $rules_group = null, $base_style = null ) { if ( empty( $style ) || ! is_array( $style ) ) { @@ -495,11 +497,13 @@ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, * @since 7.1.0 * @since 7.2.0 Added the `$base_style` parameter. * - * @param array $state_styles Map of state to style array. - * @param WP_Block_Type $block_type Block type. - * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. - * @param array|null $base_style Optional styles the state layers over. + * @param array $state_styles Map of state to style array. + * @param WP_Block_Type $block_type Block type. + * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. + * @param array|null $base_style Optional styles the state layers over. * @return array[] State style rules. + * + * @phpstan-return list> */ function wp_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null, $base_style = null ) { // Only a block that supports the clip can sit under a text clip to escape. diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 092d931e10a52..3e7214b85d8cd 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -1036,7 +1036,7 @@ private function process_pseudo_selectors( $node, $base_selector, $settings, $bl $combined_selector = static::append_to_selector( $base_selector, $pseudo_selector ); $declarations = static::compute_style_properties( $pseudo_node, $settings, null, null ); - $declarations = static::get_state_declarations_with_text_clip_escapes( $declarations, $pseudo_node, $base_styles ); + $declarations = self::get_state_declarations_with_text_clip_escapes( $declarations, $pseudo_node, $base_styles ); $add_declarations( $combined_selector, $declarations ); } } @@ -3971,10 +3971,12 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt * * @since 7.2.0 * - * @param array|null $settings Settings from theme.json. + * @param array $settings Settings from theme.json. * @return string[] Style-state keys. + * + * @phpstan-return list */ - private static function get_style_state_keys( $settings ) { + private static function get_style_state_keys( array $settings ): array { $keys = array_keys( static::get_viewport_media_queries( $settings['viewport'] ?? null ) ); foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS as $pseudo_selectors ) { @@ -3999,17 +4001,15 @@ private static function get_style_state_keys( $settings ) { * * @since 7.2.0 * - * @param array $path Path to the node within the theme.json tree. - * @param array $tree The theme.json tree to read from. - * @param array|null $settings Settings from theme.json. + * @param string[] $path Path to the node within the theme.json tree. + * @param array $tree The theme.json tree to read from. + * @param array $settings Settings from theme.json. * @return array[] Style objects, nearest first. + * + * @phpstan-return list> */ - private static function get_state_base_styles( $path, $tree, $settings ) { - if ( ! is_array( $path ) ) { - return array(); - } - - $state_keys = static::get_style_state_keys( $settings ); + private static function get_state_base_styles( array $path, array $tree, array $settings ): array { + $state_keys = self::get_style_state_keys( $settings ); $base_path = $path; $base_styles = array(); @@ -4035,16 +4035,30 @@ private static function get_state_base_styles( $path, $tree, $settings ) { * * @since 7.2.0 * - * @param array $declarations Declarations generated for the state. - * @param array $state_style State style object. - * @param array[] $base_styles Style objects the state layers over, nearest first. - * @return array Declarations with the escapes applied where needed. + * @param array[] $declarations Declarations generated for the state. + * @param array $state_style State style object. + * @param array[] $base_styles Style objects the state layers over, nearest first. + * @return array[] Declarations with the escapes applied where needed. + * + * @phpstan-param list $declarations + * @phpstan-param array{ + * background?: array{ + * backgroundClip?: mixed, + * backgroundImage?: mixed, + * gradient?: mixed, + * ... + * }, + * color?: array{ + * gradient?: mixed, + * text?: mixed, + * ... + * }, + * ... + * } $state_style + * @phpstan-param list> $base_styles + * @phpstan-return list */ - private static function get_state_declarations_with_text_clip_escapes( $declarations, $state_style, $base_styles ) { - if ( ! is_array( $declarations ) || ! is_array( $state_style ) || ! is_array( $base_styles ) ) { - return $declarations; - } - + private static function get_state_declarations_with_text_clip_escapes( array $declarations, array $state_style, array $base_styles ): array { $base_clip = null; foreach ( $base_styles as $base_style ) { if ( ! empty( $base_style['background']['backgroundClip'] ) ) { @@ -4220,7 +4234,7 @@ public function get_styles_for_block( $block_metadata ) { // Process base properties for this breakpoint. $breakpoint_declarations = static::compute_style_properties( $breakpoint_node, $settings, null, $this->theme_json ); - $breakpoint_declarations = static::get_state_declarations_with_text_clip_escapes( + $breakpoint_declarations = self::get_state_declarations_with_text_clip_escapes( $breakpoint_declarations, $breakpoint_node, array( $style_variation_node, $node ) @@ -4349,14 +4363,14 @@ static function ( $pseudo_selector ) use ( $selector ) { * element then compute the style properties for it. * Otherwise just compute the styles for the default selector as normal. */ - $base_styles = static::get_state_base_styles( $block_metadata['path'] ?? array(), $this->theme_json, $settings ); + $base_styles = self::get_state_base_styles( $block_metadata['path'] ?? array(), $this->theme_json, $settings ); if ( $pseudo_selector && isset( $node[ $pseudo_selector ] ) && isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] ) && in_array( $pseudo_selector, static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ], true ) ) { $declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, $this->theme_json, $selector, $use_root_padding ); - $declarations = static::get_state_declarations_with_text_clip_escapes( + $declarations = self::get_state_declarations_with_text_clip_escapes( $declarations, $node[ $pseudo_selector ], array_merge( array( $node ), $base_styles ) @@ -4368,7 +4382,7 @@ static function ( $pseudo_selector ) use ( $selector ) { * so those properties are not output twice. */ $declarations = static::compute_style_properties( $node, $settings, null, $this->theme_json, $selector, $use_root_padding ); - $declarations = static::get_state_declarations_with_text_clip_escapes( $declarations, $node, $base_styles ); + $declarations = self::get_state_declarations_with_text_clip_escapes( $declarations, $node, $base_styles ); } $block_rules = ''; From 338993412fde0cf237a78f44a8b8ae11325b27f3 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:29:43 +0800 Subject: [PATCH 07/13] Background clip: Share the state text clip helpers between block supports and theme.json. --- src/wp-includes/block-supports/states.php | 85 +++--- src/wp-includes/class-wp-theme-json.php | 33 +-- tests/phpunit/tests/block-supports/states.php | 256 ++++++++++++------ 3 files changed, 231 insertions(+), 143 deletions(-) diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index 8b406faa4fea2..a0f6e5e4025d1 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -173,53 +173,57 @@ function wp_merge_state_style_objects( $target, $source ) { } /** - * Adds the declarations a state needs to escape a text clip it layers over. - * - * A state's text color fills the letters again whether the text clip comes - * from the block's own styles or from global styles, which the block cannot - * see. The fill does nothing when no text clip applies. - * - * Mirror of `getStateTextClipEscapeCSS` and `getStateTextFillResetCSS` in - * packages/block-editor/src/hooks/style.jsx. + * Checks whether a state paints its own background. * * @since 7.2.0 + * @access private * - * @param array $declarations CSS declarations generated by the style engine. - * @param array $state_style State style object. - * @param array|null $base_style Styles the state layers over. - * @return array CSS declarations with the escapes applied where needed. + * @param array $state_style State style object. + * @return bool Whether the state sets a gradient or background image. */ -function wp_get_state_declarations_with_text_clip_escapes( $declarations, $state_style, $base_style ) { - if ( ! is_array( $declarations ) || ! is_array( $state_style ) ) { - return $declarations; - } - - $state_clip = $state_style['background']['backgroundClip'] ?? null; - - $paints_its_own_background = - ! empty( $state_style['background']['gradient'] ) || +function wp_state_paints_own_background( array $state_style ): bool { + return ! empty( $state_style['background']['gradient'] ) || ! empty( $state_style['background']['backgroundImage'] ) || ! empty( $state_style['color']['gradient'] ); +} - if ( $paints_its_own_background ) { - return $declarations; - } - - if ( ! $state_clip ) { - if ( ! empty( $state_style['color']['text'] ) ) { - $declarations['-webkit-text-fill-color'] = 'currentColor'; - } - - return $declarations; +/** + * Returns the declarations that unclip a text clip when a state sets a different clip. + * + * Mirror of `getStateTextClipEscapeCSS` in packages/block-editor/src/hooks/style.jsx. + * + * @since 7.2.0 + * + * @param array $state_style State style object. + * @param string $base_clip Background clip of the styles the state layers over. + * @return array CSS declarations, empty when no escape is needed. + */ +function wp_get_state_text_clip_escape( array $state_style, string $base_clip ): array { + $state_clip = $state_style['background']['backgroundClip'] ?? ''; + if ( empty( $state_clip ) || 'text' === $state_clip || 'text' !== $base_clip || wp_state_paints_own_background( $state_style ) ) { + return array(); } + return array( 'background-image' => 'unset' ); +} - $base_clip = is_array( $base_style ) ? ( $base_style['background']['backgroundClip'] ?? null ) : null; - - if ( 'text' !== $state_clip && 'text' === $base_clip ) { - $declarations['background-image'] = 'unset'; +/** + * Returns the declarations that refill the letters when a state sets a text color but no clip. + * + * The fill is added even without a known text clip, since a block instance + * cannot see a clip set in global styles. It does nothing when no clip applies. + * + * Mirror of `getStateTextFillResetCSS` in packages/block-editor/src/hooks/style.jsx. + * + * @since 7.2.0 + * + * @param array $state_style State style object. + * @return array CSS declarations, empty when no reset is needed. + */ +function wp_get_state_text_fill_reset( array $state_style ): array { + if ( ! empty( $state_style['background']['backgroundClip'] ) || empty( $state_style['color']['text'] ) || wp_state_paints_own_background( $state_style ) ) { + return array(); } - - return $declarations; + return array( '-webkit-text-fill-color' => 'currentColor' ); } /** @@ -470,10 +474,11 @@ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, // Only block states pass a base; element states match the editor and skip it. if ( null !== $base_style ) { - $declarations = wp_get_state_declarations_with_text_clip_escapes( + $base_clip = $base_style['background']['backgroundClip'] ?? ''; + $declarations = array_merge( $declarations, - $style, - $base_style + wp_get_state_text_clip_escape( $style, is_string( $base_clip ) ? $base_clip : '' ), + wp_get_state_text_fill_reset( $style ) ); } diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 3e7214b85d8cd..85866482f571a 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -4031,8 +4031,6 @@ private static function get_state_base_styles( array $path, array $tree, array $ /** * Adds the declarations a state needs to escape a text clip set below it. * - * Mirror of `wp_get_state_declarations_with_text_clip_escapes()`. - * * @since 7.2.0 * * @param array[] $declarations Declarations generated for the state. @@ -4059,10 +4057,10 @@ private static function get_state_base_styles( array $path, array $tree, array $ * @phpstan-return list */ private static function get_state_declarations_with_text_clip_escapes( array $declarations, array $state_style, array $base_styles ): array { - $base_clip = null; + $base_clip = ''; foreach ( $base_styles as $base_style ) { if ( ! empty( $base_style['background']['backgroundClip'] ) ) { - $base_clip = $base_style['background']['backgroundClip']; + $base_clip = is_string( $base_style['background']['backgroundClip'] ) ? $base_style['background']['backgroundClip'] : ''; break; } } @@ -4071,28 +4069,15 @@ private static function get_state_declarations_with_text_clip_escapes( array $de return $declarations; } - $state_clip = $state_style['background']['backgroundClip'] ?? null; - - $paints_its_own_background = - ! empty( $state_style['background']['gradient'] ) || - ! empty( $state_style['background']['backgroundImage'] ) || - ! empty( $state_style['color']['gradient'] ); - - if ( $state_clip && 'text' !== $state_clip ) { - if ( ! $paints_its_own_background ) { - $declarations[] = array( - 'name' => 'background-image', - 'value' => 'unset', - ); - } - - return $declarations; - } + $escapes = array_merge( + wp_get_state_text_clip_escape( $state_style, $base_clip ), + wp_get_state_text_fill_reset( $state_style ) + ); - if ( ! $state_clip && ! $paints_its_own_background && ! empty( $state_style['color']['text'] ) ) { + foreach ( $escapes as $name => $value ) { $declarations[] = array( - 'name' => '-webkit-text-fill-color', - 'value' => 'currentColor', + 'name' => $name, + 'value' => $value, ); } diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 3cc39d42a722d..c5ea13571abb4 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -164,148 +164,246 @@ public function test_adds_background_image_reset_for_solid_background_color() { * Tests that a state escaping a text clip stops the inherited gradient painting. * * @ticket 66094 - * @covers ::wp_get_state_declarations_with_text_clip_escapes + * @covers ::wp_get_state_text_clip_escape */ - public function test_adds_background_image_reset_when_a_state_escapes_a_text_clip() { - $actual = wp_get_state_declarations_with_text_clip_escapes( - array( 'background-clip' => 'border-box' ), + public function test_text_clip_escape_unsets_the_background_image_when_a_state_escapes_a_text_clip() { + $actual = wp_get_state_text_clip_escape( array( 'background' => array( 'backgroundClip' => 'border-box' ) ), - array( 'background' => array( 'backgroundClip' => 'text' ) ) + 'text' ); - $this->assertSame( - array( - 'background-clip' => 'border-box', - 'background-image' => 'unset', - ), - $actual - ); + $this->assertSame( array( 'background-image' => 'unset' ), $actual ); } /** * Tests that a background the state paints itself is kept. * * @ticket 66094 - * @covers ::wp_get_state_declarations_with_text_clip_escapes + * @covers ::wp_get_state_text_clip_escape + * @covers ::wp_get_state_text_fill_reset + * @covers ::wp_state_paints_own_background + * + * @dataProvider data_state_styles_that_paint_their_own_background + * + * @param array $state_style State style object. */ - public function test_no_background_image_reset_when_the_state_paints_its_own_background() { - $state_style = array( - 'background' => array( - 'backgroundClip' => 'padding-box', - 'gradient' => 'linear-gradient(135deg, #ff0000, #0000ff)', - ), - ); + public function test_text_clip_helpers_add_nothing_when_the_state_paints_its_own_background( $state_style ) { + $this->assertSame( array(), wp_get_state_text_clip_escape( $state_style, 'text' ), 'Expected no text clip escape.' ); + $this->assertSame( array(), wp_get_state_text_fill_reset( $state_style ), 'Expected no text fill reset.' ); + } - $actual = wp_get_state_declarations_with_text_clip_escapes( - array( 'background-clip' => 'padding-box' ), - $state_style, - array( 'background' => array( 'backgroundClip' => 'text' ) ) - ); + /** + * Data provider. + * + * @return array[] + */ + public function data_state_styles_that_paint_their_own_background() { + $color = array( 'text' => '#ff0000' ); - $this->assertSame( array( 'background-clip' => 'padding-box' ), $actual ); + return array( + 'background gradient' => array( + array( + 'color' => $color, + 'background' => array( + 'backgroundClip' => 'padding-box', + 'gradient' => 'linear-gradient(135deg, #ff0000, #0000ff)', + ), + ), + ), + 'background image' => array( + array( + 'color' => $color, + 'background' => array( + 'backgroundClip' => 'padding-box', + 'backgroundImage' => array( 'url' => 'image.png' ), + ), + ), + ), + 'color gradient' => array( + array( + 'color' => array_merge( $color, array( 'gradient' => 'linear-gradient(135deg, #ff0000, #0000ff)' ) ), + 'background' => array( 'backgroundClip' => 'padding-box' ), + ), + ), + 'gradient, no clip' => array( + array( + 'color' => $color, + 'background' => array( 'gradient' => 'linear-gradient(135deg, #ff0000, #0000ff)' ), + ), + ), + ); } /** - * Tests that a box clip is left alone when nothing above it clips to text. + * Tests that a box clip is left alone when nothing below it clips to text. * * @ticket 66094 - * @covers ::wp_get_state_declarations_with_text_clip_escapes + * @covers ::wp_get_state_text_clip_escape + * + * @dataProvider data_base_clips_that_are_not_text + * + * @param string $base_clip Background clip the state layers over. */ - public function test_no_background_image_reset_without_an_inherited_text_clip() { - $actual = wp_get_state_declarations_with_text_clip_escapes( - array( 'background-clip' => 'padding-box' ), + public function test_text_clip_escape_adds_nothing_without_a_base_text_clip( $base_clip ) { + $actual = wp_get_state_text_clip_escape( array( 'background' => array( 'backgroundClip' => 'padding-box' ) ), - array( 'background' => array( 'backgroundImage' => array( 'url' => 'image.png' ) ) ) + $base_clip ); - $this->assertSame( array( 'background-clip' => 'padding-box' ), $actual ); + $this->assertSame( array(), $actual ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_base_clips_that_are_not_text() { + return array( + 'no base clip' => array( '' ), + 'border-box' => array( 'border-box' ), + ); } /** - * Tests that a text color set under a text clip is painted. + * Tests that an empty state clip of any type counts as no clip. * * @ticket 66094 - * @covers ::wp_get_state_declarations_with_text_clip_escapes + * @covers ::wp_get_state_text_clip_escape + * @covers ::wp_get_state_text_fill_reset + * + * @dataProvider data_empty_state_clips + * + * @param mixed $state_clip Background clip set by the state. */ - public function test_adds_text_fill_reset_for_a_text_color_under_a_text_clip() { - $actual = wp_get_state_declarations_with_text_clip_escapes( - array( 'color' => '#ff0000' ), - array( 'color' => array( 'text' => '#ff0000' ) ), - array( 'background' => array( 'backgroundClip' => 'text' ) ) + public function test_text_clip_helpers_treat_an_empty_state_clip_as_no_clip( $state_clip ) { + $state_style = array( + 'color' => array( 'text' => '#ff0000' ), + 'background' => array( 'backgroundClip' => $state_clip ), ); + $this->assertSame( array(), wp_get_state_text_clip_escape( $state_style, 'text' ), 'Expected no text clip escape.' ); $this->assertSame( - array( - 'color' => '#ff0000', - '-webkit-text-fill-color' => 'currentColor', - ), - $actual + array( '-webkit-text-fill-color' => 'currentColor' ), + wp_get_state_text_fill_reset( $state_style ), + 'Expected the text fill reset.' ); } /** - * Tests that a text color is painted under a text clip from global styles, - * which the block's own styles do not show. + * Data provider. * - * @ticket 66094 - * @covers ::wp_get_state_declarations_with_text_clip_escapes + * @return array[] */ - public function test_adds_text_fill_reset_for_a_text_color_without_a_known_text_clip() { - $actual = wp_get_state_declarations_with_text_clip_escapes( - array( 'color' => '#ff0000' ), - array( 'color' => array( 'text' => '#ff0000' ) ), - array() + public function data_empty_state_clips() { + return array( + 'false' => array( false ), + 'zero' => array( 0 ), + 'zero string' => array( '0' ), + 'empty string' => array( '' ), + 'empty array' => array( array() ), ); + } + /** + * Tests that a state without its own clip, or keeping the text clip, needs no escape. + * + * @ticket 66094 + * @covers ::wp_get_state_text_clip_escape + */ + public function test_text_clip_escape_adds_nothing_when_the_state_keeps_the_text_clip() { $this->assertSame( - array( - 'color' => '#ff0000', - '-webkit-text-fill-color' => 'currentColor', - ), - $actual + array(), + wp_get_state_text_clip_escape( array( 'background' => array( 'backgroundClip' => 'text' ) ), 'text' ), + 'Expected no escape for a state that also clips to text.' + ); + $this->assertSame( + array(), + wp_get_state_text_clip_escape( array( 'color' => array( 'text' => '#ff0000' ) ), 'text' ), + 'Expected no escape for a state without a clip.' ); } /** - * Tests that the fill is left alone when the state paints its own gradient. + * Tests that a text color set without a clip gets its fill back. * * @ticket 66094 - * @covers ::wp_get_state_declarations_with_text_clip_escapes + * @covers ::wp_get_state_text_fill_reset */ - public function test_no_text_fill_reset_when_the_state_paints_its_own_gradient() { - $state_style = array( - 'color' => array( 'text' => '#ff0000' ), - 'background' => array( 'gradient' => 'linear-gradient(135deg, #ff0000, #0000ff)' ), - ); + public function test_text_fill_reset_refills_a_text_color() { + $actual = wp_get_state_text_fill_reset( array( 'color' => array( 'text' => '#ff0000' ) ) ); - $actual = wp_get_state_declarations_with_text_clip_escapes( - array( 'color' => '#ff0000' ), - $state_style, - array( 'background' => array( 'backgroundClip' => 'text' ) ) - ); - - $this->assertSame( array( 'color' => '#ff0000' ), $actual ); + $this->assertSame( array( '-webkit-text-fill-color' => 'currentColor' ), $actual ); } /** * Tests that the fill is left to the style engine when the state sets its own clip. * * @ticket 66094 - * @covers ::wp_get_state_declarations_with_text_clip_escapes + * @covers ::wp_get_state_text_fill_reset */ - public function test_no_text_fill_reset_when_the_state_sets_its_own_clip() { + public function test_text_fill_reset_adds_nothing_when_the_state_sets_its_own_clip() { $state_style = array( 'color' => array( 'text' => '#ff0000' ), 'background' => array( 'backgroundClip' => 'text' ), ); - $actual = wp_get_state_declarations_with_text_clip_escapes( - array( 'color' => '#ff0000' ), - $state_style, + $this->assertSame( array(), wp_get_state_text_fill_reset( $state_style ) ); + } + + /** + * Tests that the fill is left alone when the state sets no text color. + * + * @ticket 66094 + * @covers ::wp_get_state_text_fill_reset + */ + public function test_text_fill_reset_adds_nothing_without_a_text_color() { + $this->assertSame( array(), wp_get_state_text_fill_reset( array( 'color' => array( 'background' => '#ff0000' ) ) ) ); + } + + /** + * Tests that a block state escapes its base text clip. + * + * @ticket 66094 + * @covers ::wp_add_block_state_style_rule + */ + public function test_block_state_rule_escapes_a_base_text_clip() { + $css_rules = array(); + + wp_add_block_state_style_rule( + $css_rules, + ':hover', + '.wp-block-test', + array( 'background' => array( 'backgroundClip' => 'border-box' ) ), + null, array( 'background' => array( 'backgroundClip' => 'text' ) ) ); - $this->assertSame( array( 'color' => '#ff0000' ), $actual ); + $this->assertSame( 'unset', $css_rules[0]['declarations']['background-image'] ?? null ); + } + + /** + * Tests that a block state text color gets its fill back over a base box clip, + * since a text clip from global styles cannot be seen from the block. + * + * @ticket 66094 + * @covers ::wp_add_block_state_style_rule + */ + public function test_block_state_rule_refills_a_text_color_over_a_base_box_clip() { + $css_rules = array(); + + wp_add_block_state_style_rule( + $css_rules, + ':hover', + '.wp-block-test', + array( 'color' => array( 'text' => '#ff0000' ) ), + null, + array( 'background' => array( 'backgroundClip' => 'border-box' ) ) + ); + + $declarations = $css_rules[0]['declarations']; + $this->assertSame( 'currentColor', $declarations['-webkit-text-fill-color'] ?? null, 'Expected the text fill reset.' ); + $this->assertArrayNotHasKey( 'background-image', $declarations, 'Expected no text clip escape.' ); } /** From f53dc95c721ce5f29df036967635766380c99463 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:04:22 +0800 Subject: [PATCH 08/13] Docs: Trim the PHPStan shapes on the state text clip helpers. --- src/wp-includes/block-supports/states.php | 4 ---- src/wp-includes/class-wp-theme-json.php | 19 ------------------- 2 files changed, 23 deletions(-) diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index a0f6e5e4025d1..cd86fd4d8cf17 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -452,8 +452,6 @@ function wp_get_block_state_element_selectors( $root_selector ) { * @param array $style Style object. * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. * @param array|null $base_style Optional styles the state layers over. - * - * @phpstan-param list> $css_rules */ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $rules_group = null, $base_style = null ) { if ( empty( $style ) || ! is_array( $style ) ) { @@ -507,8 +505,6 @@ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. * @param array|null $base_style Optional styles the state layers over. * @return array[] State style rules. - * - * @phpstan-return list> */ function wp_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null, $base_style = null ) { // Only a block that supports the clip can sit under a text clip to escape. diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 85866482f571a..6e7a986aae1d5 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -3973,8 +3973,6 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt * * @param array $settings Settings from theme.json. * @return string[] Style-state keys. - * - * @phpstan-return list */ private static function get_style_state_keys( array $settings ): array { $keys = array_keys( static::get_viewport_media_queries( $settings['viewport'] ?? null ) ); @@ -4005,8 +4003,6 @@ private static function get_style_state_keys( array $settings ): array { * @param array $tree The theme.json tree to read from. * @param array $settings Settings from theme.json. * @return array[] Style objects, nearest first. - * - * @phpstan-return list> */ private static function get_state_base_styles( array $path, array $tree, array $settings ): array { $state_keys = self::get_style_state_keys( $settings ); @@ -4039,21 +4035,6 @@ private static function get_state_base_styles( array $path, array $tree, array $ * @return array[] Declarations with the escapes applied where needed. * * @phpstan-param list $declarations - * @phpstan-param array{ - * background?: array{ - * backgroundClip?: mixed, - * backgroundImage?: mixed, - * gradient?: mixed, - * ... - * }, - * color?: array{ - * gradient?: mixed, - * text?: mixed, - * ... - * }, - * ... - * } $state_style - * @phpstan-param list> $base_styles * @phpstan-return list */ private static function get_state_declarations_with_text_clip_escapes( array $declarations, array $state_style, array $base_styles ): array { From 3cce078bf1e153a0f7a54a38da5197b8bf43ac7e Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:43:06 +0800 Subject: [PATCH 09/13] Background clip: Ignore invalid clip values in the state text clip helpers. --- src/wp-includes/block-supports/states.php | 9 +++++- tests/phpunit/tests/block-supports/states.php | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index cd86fd4d8cf17..2f28556144f2c 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -200,6 +200,9 @@ function wp_state_paints_own_background( array $state_style ): bool { */ function wp_get_state_text_clip_escape( array $state_style, string $base_clip ): array { $state_clip = $state_style['background']['backgroundClip'] ?? ''; + if ( ! in_array( $state_clip, WP_Style_Engine::VALID_BACKGROUND_CLIP_VALUES, true ) ) { + $state_clip = ''; + } if ( empty( $state_clip ) || 'text' === $state_clip || 'text' !== $base_clip || wp_state_paints_own_background( $state_style ) ) { return array(); } @@ -220,7 +223,11 @@ function wp_get_state_text_clip_escape( array $state_style, string $base_clip ): * @return array CSS declarations, empty when no reset is needed. */ function wp_get_state_text_fill_reset( array $state_style ): array { - if ( ! empty( $state_style['background']['backgroundClip'] ) || empty( $state_style['color']['text'] ) || wp_state_paints_own_background( $state_style ) ) { + $state_clip = $state_style['background']['backgroundClip'] ?? ''; + if ( ! in_array( $state_clip, WP_Style_Engine::VALID_BACKGROUND_CLIP_VALUES, true ) ) { + $state_clip = ''; + } + if ( ! empty( $state_clip ) || empty( $state_style['color']['text'] ) || wp_state_paints_own_background( $state_style ) ) { return array(); } return array( '-webkit-text-fill-color' => 'currentColor' ); diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index c5ea13571abb4..fa901d4ea0682 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -305,6 +305,38 @@ public function data_empty_state_clips() { ); } + /** + * Tests that an invalid state clip over a base text clip does not unset the gradient. + * + * @ticket 66094 + * @covers ::wp_get_state_text_clip_escape + */ + public function test_text_clip_escape_ignores_an_invalid_state_clip() { + $actual = wp_get_state_text_clip_escape( + array( 'background' => array( 'backgroundClip' => 'borderbox' ) ), + 'text' + ); + + $this->assertSame( array(), $actual ); + } + + /** + * Tests that a text color with an invalid state clip gets its fill back. + * + * @ticket 66094 + * @covers ::wp_get_state_text_fill_reset + */ + public function test_text_fill_reset_ignores_an_invalid_state_clip() { + $actual = wp_get_state_text_fill_reset( + array( + 'color' => array( 'text' => '#ff0000' ), + 'background' => array( 'backgroundClip' => 'borderbox' ), + ) + ); + + $this->assertSame( array( '-webkit-text-fill-color' => 'currentColor' ), $actual ); + } + /** * Tests that a state without its own clip, or keeping the text clip, needs no escape. * From f1037507e51f02458a27a46c898bf4ea05e29149 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:53:54 +0800 Subject: [PATCH 10/13] Background clip: Narrow return types and drop a redundant clip guard. --- src/wp-includes/block-supports/states.php | 4 ++++ src/wp-includes/class-wp-theme-json.php | 2 ++ src/wp-includes/style-engine/class-wp-style-engine.php | 8 +++----- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index 2f28556144f2c..2657558486273 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -197,6 +197,8 @@ function wp_state_paints_own_background( array $state_style ): bool { * @param array $state_style State style object. * @param string $base_clip Background clip of the styles the state layers over. * @return array CSS declarations, empty when no escape is needed. + * + * @phpstan-return array */ function wp_get_state_text_clip_escape( array $state_style, string $base_clip ): array { $state_clip = $state_style['background']['backgroundClip'] ?? ''; @@ -221,6 +223,8 @@ function wp_get_state_text_clip_escape( array $state_style, string $base_clip ): * * @param array $state_style State style object. * @return array CSS declarations, empty when no reset is needed. + * + * @phpstan-return array */ function wp_get_state_text_fill_reset( array $state_style ): array { $state_clip = $state_style['background']['backgroundClip'] ?? ''; diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 6e7a986aae1d5..f57d473065980 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -3973,6 +3973,8 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt * * @param array $settings Settings from theme.json. * @return string[] Style-state keys. + * + * @phpstan-return list */ private static function get_style_state_keys( array $settings ): array { $keys = array_keys( static::get_viewport_media_queries( $settings['viewport'] ?? null ) ); diff --git a/src/wp-includes/style-engine/class-wp-style-engine.php b/src/wp-includes/style-engine/class-wp-style-engine.php index f77d250821bd1..81499d15e5905 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine.php +++ b/src/wp-includes/style-engine/class-wp-style-engine.php @@ -791,13 +791,11 @@ protected static function get_url_or_value_css_declaration( $style_value, $style * * @param string $style_value A single raw style value from $block_styles array. * @param array $style_definition A single style definition from BLOCK_STYLE_DEFINITIONS_METADATA. - * @return string[] An associative array of CSS definitions, e.g., array( "$property" => "$value", "$property" => "$value" ). + * @return array An associative array of CSS definitions, mapping properties to values. + * + * @phpstan-return array */ protected static function get_background_clip_css_declarations( $style_value, $style_definition ) { - if ( empty( $style_value ) || ! is_string( $style_value ) ) { - return array(); - } - if ( ! in_array( $style_value, static::VALID_BACKGROUND_CLIP_VALUES, true ) ) { return array(); } From 86dd8867fb88528ac1b74a218844cbca9a93bc9c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 19 Oct 2026 19:19:17 -0700 Subject: [PATCH 11/13] Account for failure to get registered block type --- src/wp-includes/block-supports/background.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/block-supports/background.php b/src/wp-includes/block-supports/background.php index b2161973ecc6c..2d46dcb6db43f 100644 --- a/src/wp-includes/block-supports/background.php +++ b/src/wp-includes/block-supports/background.php @@ -53,7 +53,11 @@ function wp_register_background_support( $block_type ) { * @return string Filtered block content. */ function wp_render_background_support( $block_content, $block ) { - $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + if ( ! $block_type ) { + return $block_content; + } + $block_attributes = ( isset( $block['attrs'] ) && is_array( $block['attrs'] ) ) ? $block['attrs'] : array(); $has_background_image_support = block_has_support( $block_type, array( 'background', 'backgroundImage' ), false ); $has_background_gradient_support = block_has_support( $block_type, array( 'background', 'gradient' ), false ); From 6cc206dc3e75508bf2d8e0774f7e22016a378ad2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 19 Oct 2026 19:19:39 -0700 Subject: [PATCH 12/13] Add array shape for block object --- src/wp-includes/block-supports/background.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/wp-includes/block-supports/background.php b/src/wp-includes/block-supports/background.php index 2d46dcb6db43f..8d464cc789ee4 100644 --- a/src/wp-includes/block-supports/background.php +++ b/src/wp-includes/block-supports/background.php @@ -51,6 +51,18 @@ function wp_register_background_support( $block_type ) { * @param string $block_content Rendered block content. * @param array $block Block object. * @return string Filtered block content. + * + * @phpstan-param array{ + * blockName: string, + * attrs: array{ + * style?: array{ + * background?: array, + * ... + * }, + * ... + * }, + * ... + * } $block */ function wp_render_background_support( $block_content, $block ) { $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); From 19b90f081be4786a81814f732f99c03a1e96c374 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 19 Oct 2026 19:27:30 -0700 Subject: [PATCH 13/13] Improve typing for wp_apply_colors_support() --- src/wp-includes/block-supports/colors.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/block-supports/colors.php b/src/wp-includes/block-supports/colors.php index 95711d1b1fd40..3c3396c587439 100644 --- a/src/wp-includes/block-supports/colors.php +++ b/src/wp-includes/block-supports/colors.php @@ -77,11 +77,11 @@ function wp_register_colors_support( $block_type ) { * @since 7.2.0 Skips `has-background` for a background color clipped to the text. * @access private * - * @param WP_Block_Type $block_type Block type. - * @param array $block_attributes Block attributes. - * @return array Colors CSS classes and inline styles. + * @param WP_Block_Type $block_type Block type. + * @param array $block_attributes Block attributes. + * @return array Colors CSS classes and inline styles. */ -function wp_apply_colors_support( $block_type, $block_attributes ) { +function wp_apply_colors_support( WP_Block_Type $block_type, array $block_attributes ): array { $color_support = $block_type->supports['color'] ?? false; if (