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 (