From 68696adb13b152fe9d817f229bf39ec443b6e862 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:40:10 +0800 Subject: [PATCH 1/2] Block Supports: Escape Global Styles and default pseudo-state text clips in state styles. A block's state or breakpoint styles now undo a text gradient set in Global Styles for its block type or block style variation. A breakpoint pseudo-state now also undoes a text gradient set by the default pseudo-state. Backports https://github.com/WordPress/gutenberg/pull/86245. --- src/wp-includes/block-supports/states.php | 59 ++++++- src/wp-includes/class-wp-theme-json.php | 30 +++- tests/phpunit/tests/block-supports/states.php | 159 ++++++++++++++++++ tests/phpunit/tests/theme/wpThemeJson.php | 95 +++++++++++ 4 files changed, 338 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index 2a80c26db420c..82e695d973107 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -399,6 +399,41 @@ function wp_get_root_state_style( $state_style, $nested_keys ) { return $root_style; } +/** + * Returns the Global Styles a block instance layers over: its block type's, + * then those of the block style variation applied to it. + * + * @since 7.3.0 + * + * @param string $block_name Block name. + * @param string|null $class_name Block class name, which names any applied variation. + * @param string[] $state_keys Pseudo-state and breakpoint keys to leave out. + * @return array Root-only style object. + */ +function wp_get_block_state_global_base_style( $block_name, $class_name, $state_keys ) { + $global_styles = wp_get_global_styles(); + $block_styles = $global_styles['blocks'][ $block_name ] ?? array(); + if ( ! is_array( $block_styles ) ) { + return array(); + } + + $nested_keys = array_merge( array( 'elements', 'blocks', 'variations' ), $state_keys ); + $base_style = wp_get_root_state_style( $block_styles, $nested_keys ); + + // Only the first block style variation with data applies, as in the variation support. + foreach ( wp_get_block_style_variation_name_from_class( $class_name ) ?? array() as $variation ) { + $variation_styles = $block_styles['variations'][ $variation ] ?? null; + if ( ! empty( $variation_styles ) && is_array( $variation_styles ) ) { + return wp_merge_state_style_objects( + $base_style, + wp_get_root_state_style( $variation_styles, $nested_keys ) + ); + } + } + + return $base_style; +} + /** * Generates all element selectors for a block root selector. * @@ -752,6 +787,22 @@ function wp_render_block_states_support( $block_content, $block ) { ); $viewport_settings = wp_get_global_settings( array( 'viewport' ) ); $responsive_media_queries = WP_Theme_JSON::get_viewport_media_queries( $viewport_settings ); + $state_keys = array_merge( $supported_pseudo_states, array_keys( $responsive_media_queries ) ); + + // Global Styles only matter for the clip escapes, so skip the lookup when no state could need one. + if ( + array_intersect( $state_keys, array_keys( $style ) ) && + block_has_support( $block_type, array( 'background', 'backgroundClip' ), false ) + ) { + $default_root_style = wp_merge_state_style_objects( + wp_get_block_state_global_base_style( + $block_name, + $block['attrs']['className'] ?? null, + $state_keys + ), + $default_root_style + ); + } foreach ( $supported_pseudo_states as $pseudo_state ) { if ( empty( $style[ $pseudo_state ] ) || ! is_array( $style[ $pseudo_state ] ) ) { @@ -853,9 +904,13 @@ function wp_render_block_states_support( $block_content, $block ) { array( $pseudo_state => $style[ $breakpoint ][ $pseudo_state ] ), $block_type, $media_query, + // Cascade order: default, breakpoint, then the default pseudo-state. wp_merge_state_style_objects( - $default_root_style, - $root_state_style + wp_merge_state_style_objects( + $default_root_style, + $root_state_style + ), + $style[ $pseudo_state ] ?? array() ) ) ); diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 9027c779a7eb3..6ab5a751bf57d 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -984,6 +984,7 @@ protected static function schema_in_root_and_per_origin( $schema ) { * Processes pseudo-selectors for any node (block or variation). * * @since 7.0.0 + * @since 7.3.0 Added the `$default_node` parameter. * * @param array $node The node data (block or variation). * @param string $base_selector The base selector. @@ -991,9 +992,10 @@ protected static function schema_in_root_and_per_origin( $schema ) { * @param string $block_name The block name. * @param array|null $block_metadata Metadata about the block to get styles for. * @param array|null $style_variation Style variation metadata. + * @param array|null $default_node For a breakpoint node, the node outside the breakpoint. * @return array Array of pseudo-selector declarations. */ - private function process_pseudo_selectors( $node, $base_selector, $settings, $block_name, $block_metadata = null, $style_variation = null ) { + private function process_pseudo_selectors( $node, $base_selector, $settings, $block_name, $block_metadata = null, $style_variation = null, $default_node = null ) { $pseudo_declarations = array(); $add_declarations = static function ( $selector, $declarations ) use ( &$pseudo_declarations ) { if ( empty( $declarations ) ) { @@ -1044,7 +1046,11 @@ 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 = self::get_state_declarations_with_text_clip_escapes( $declarations, $pseudo_node, $base_styles ); + // Mirror of `get_state_base_styles()`: the default pseudo-state comes nearest. + $pseudo_base = is_array( $default_node[ $pseudo_selector ] ?? null ) + ? array_merge( array( $default_node[ $pseudo_selector ] ), $base_styles ) + : $base_styles; + $declarations = self::get_state_declarations_with_text_clip_escapes( $declarations, $pseudo_node, $pseudo_base ); $add_declarations( $combined_selector, $declarations ); } } @@ -4046,8 +4052,11 @@ private static function get_style_state_keys( array $settings ): array { * Returns the style objects a state node layers over, nearest first. * * Drops the path's state keys (e.g. `@mobile`) from the innermost outward. + * A breakpoint pseudo-state (e.g. `@mobile` `:hover`) also layers over the + * default pseudo-state. * * @since 7.2.0 + * @since 7.3.0 Includes the default pseudo-state for a breakpoint pseudo-state. * * @param string[] $path Path to the node within the theme.json tree. * @param array $tree The theme.json tree to read from. @@ -4059,6 +4068,21 @@ private static function get_state_base_styles( array $path, array $tree, array $ $base_path = $path; $base_styles = array(); + // A breakpoint pseudo-state is output after the default pseudo-state, so that comes nearest. + $count = count( $path ); + if ( + $count >= 2 && + in_array( $path[ $count - 1 ], array_merge( ...array_values( static::VALID_BLOCK_PSEUDO_SELECTORS ) ), true ) && + array_key_exists( $path[ $count - 2 ], static::get_viewport_media_queries( $settings['viewport'] ?? null ) ) + ) { + $default_pseudo_path = $path; + unset( $default_pseudo_path[ $count - 2 ] ); + $layer = _wp_array_get( $tree, array_values( $default_pseudo_path ) ); + if ( is_array( $layer ) ) { + $base_styles[] = $layer; + } + } + foreach ( array_reverse( array_keys( $path ) ) as $index ) { if ( ! in_array( $path[ $index ], $state_keys, true ) ) { continue; @@ -4260,7 +4284,7 @@ public function get_styles_for_block( $block_metadata ) { $variation_responsive_css .= $breakpoint_media . '{' . $base_ruleset . '}'; } - $breakpoint_pseudo_declarations = $this->process_pseudo_selectors( $breakpoint_node, $style_variation['selector'], $settings, $block_name, $block_metadata, $style_variation ); + $breakpoint_pseudo_declarations = $this->process_pseudo_selectors( $breakpoint_node, $style_variation['selector'], $settings, $block_name, $block_metadata, $style_variation, $style_variation_node ); foreach ( $breakpoint_pseudo_declarations as $pseudo_selector => $pseudo_declarations ) { if ( empty( $pseudo_declarations ) ) { continue; diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index eacd5d7c893d1..49911bb7c8a72 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -438,6 +438,165 @@ public function test_block_state_rule_refills_a_text_color_over_a_base_box_clip( $this->assertArrayNotHasKey( 'background-image', $declarations, 'Expected no text clip escape.' ); } + /** + * Renders a Paragraph with a mobile box clip over the given Global Styles + * for Paragraph. + * + * @param array $paragraph_styles Global Styles for Paragraph. + * @param string $class_name Optional block class name. + * @return string Generated block-supports stylesheet. + */ + private function get_box_clip_over_global_styles_stylesheet( $paragraph_styles, $class_name = '' ) { + $this->ensure_block_registered( + 'core/paragraph', + array(), + array( 'background' => array( 'backgroundClip' => true ) ) + ); + + $filter = static function ( $theme_json ) use ( $paragraph_styles ) { + return $theme_json->update_with( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( 'core/paragraph' => $paragraph_styles ), + ), + ) + ); + }; + + add_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver::clean_cached_data(); + + try { + $block = array( + 'blockName' => 'core/paragraph', + 'attrs' => array( + 'className' => $class_name, + 'style' => array( + '@mobile' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ), + ), + ); + + wp_render_block_states_support( '

Text

', $block ); + + return wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + } finally { + remove_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver::clean_cached_data(); + } + } + + /** + * Tests that a block instance state escapes a text clip set for its block + * type in Global Styles. + * + * @ticket XXXXX + * @covers ::wp_render_block_states_support + */ + public function test_instance_state_escapes_a_global_styles_text_clip() { + $actual_stylesheet = $this->get_box_clip_over_global_styles_stylesheet( + array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ) + ); + + $this->assertStringContainsString( 'background-clip:border-box !important;-webkit-text-fill-color:currentColor !important;background-image:unset !important;', $actual_stylesheet ); + } + + /** + * Tests that a block instance state escapes a text clip set by the block + * style variation applied to it. + * + * @ticket XXXXX + * @covers ::wp_render_block_states_support + */ + public function test_instance_state_escapes_a_block_style_variation_text_clip() { + register_block_style( + 'core/paragraph', + array( + 'name' => 'flashy', + 'label' => 'Flashy', + ) + ); + + try { + $actual_stylesheet = $this->get_box_clip_over_global_styles_stylesheet( + array( + 'variations' => array( + 'flashy' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ), + ), + ), + 'is-style-flashy' + ); + } finally { + unregister_block_style( 'core/paragraph', 'flashy' ); + } + + $this->assertStringContainsString( 'background-clip:border-box !important;-webkit-text-fill-color:currentColor !important;background-image:unset !important;', $actual_stylesheet ); + } + + /** + * Tests that a responsive pseudo-state escapes a text clip set by the + * default pseudo-state. + * + * @ticket XXXXX + * @covers ::wp_render_block_states_support + */ + public function test_responsive_pseudo_state_escapes_the_default_pseudo_state_text_clip() { + $block_type = $this->ensure_block_registered( + 'core/button', + array( 'root' => '.wp-block-button .wp-block-button__link' ), + array( 'background' => array( 'backgroundClip' => true ) ) + ); + $supports = $block_type->supports; + + $block_type->supports['background']['backgroundClip'] = true; + + try { + $block = array( + 'blockName' => 'core/button', + 'attrs' => array( + 'style' => array( + ':hover' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ), + '@mobile' => array( + ':hover' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ), + ), + ), + ); + + $actual = wp_render_block_states_support( '', $block ); + } finally { + $block_type->supports = $supports; + } + + 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] . ' .wp-block-button__link:hover{background-clip:border-box !important;-webkit-text-fill-color:currentColor !important;background-image:unset !important;}}', + $actual_stylesheet + ); + } + /** * 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 56c448ee1c43a..d5df82aba7ed0 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -1814,6 +1814,101 @@ public function test_pseudo_state_inside_a_breakpoint_reads_the_breakpoint_clip( ); } + /** + * @ticket XXXXX + */ + public function test_breakpoint_pseudo_state_reads_the_default_pseudo_state_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( + ':hover' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ), + '@mobile' => array( + ':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 XXXXX + */ + public function test_variation_breakpoint_pseudo_state_reads_the_variation_pseudo_state_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( + 'variations' => array( + 'flashy' => array( + ':hover' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ), + '@mobile' => array( + ':hover' => array( 'color' => array( 'text' => '#00ff00' ) ), + ), + ), + ), + ), + ), + ), + ) + ); + + $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){color: #00ff00;-webkit-text-fill-color: currentColor;}}', + $actual_styles + ); + } + /** * @ticket 65596 */ From 2c2b9c35c852f3901188b3346e23c5c28b1335cf Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:58:35 +0800 Subject: [PATCH 2/2] Tests: Add the Trac ticket number. --- tests/phpunit/tests/block-supports/states.php | 6 +++--- tests/phpunit/tests/theme/wpThemeJson.php | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 49911bb7c8a72..6b0a447bda585 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -493,7 +493,7 @@ private function get_box_clip_over_global_styles_stylesheet( $paragraph_styles, * Tests that a block instance state escapes a text clip set for its block * type in Global Styles. * - * @ticket XXXXX + * @ticket 66464 * @covers ::wp_render_block_states_support */ public function test_instance_state_escapes_a_global_styles_text_clip() { @@ -513,7 +513,7 @@ public function test_instance_state_escapes_a_global_styles_text_clip() { * Tests that a block instance state escapes a text clip set by the block * style variation applied to it. * - * @ticket XXXXX + * @ticket 66464 * @covers ::wp_render_block_states_support */ public function test_instance_state_escapes_a_block_style_variation_text_clip() { @@ -550,7 +550,7 @@ public function test_instance_state_escapes_a_block_style_variation_text_clip() * Tests that a responsive pseudo-state escapes a text clip set by the * default pseudo-state. * - * @ticket XXXXX + * @ticket 66464 * @covers ::wp_render_block_states_support */ public function test_responsive_pseudo_state_escapes_the_default_pseudo_state_text_clip() { diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index d5df82aba7ed0..f1b8b558488ba 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -1815,7 +1815,7 @@ public function test_pseudo_state_inside_a_breakpoint_reads_the_breakpoint_clip( } /** - * @ticket XXXXX + * @ticket 66464 */ public function test_breakpoint_pseudo_state_reads_the_default_pseudo_state_clip() { $theme_json = new WP_Theme_JSON( @@ -1849,7 +1849,7 @@ public function test_breakpoint_pseudo_state_reads_the_default_pseudo_state_clip } /** - * @ticket XXXXX + * @ticket 66464 */ public function test_variation_breakpoint_pseudo_state_reads_the_variation_pseudo_state_clip() { register_block_style(