From 2a9c7d650fd9a80630612996a27f7baa241546de Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 5 May 2026 13:47:19 +1000 Subject: [PATCH 1/8] Add responsive global styles for blocks --- src/wp-includes/class-wp-theme-json.php | 414 ++++++++++++++++++++++-- 1 file changed, 396 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index e42731c09baf5..13c73f71075b7 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -644,6 +644,19 @@ class WP_Theme_JSON { 'core/button' => array( ':hover', ':focus', ':focus-visible', ':active' ), ); + /** + * Responsive breakpoint state keys and their corresponding CSS media queries. + * These are available for all blocks and wrap their styles in the given media query. + * Keep in sync with RESPONSIVE_BREAKPOINTS in packages/global-styles-engine/src/core/render.tsx. + * + * @since 7.1.0 + * @var array + */ + const RESPONSIVE_BREAKPOINTS = array( + 'mobile' => '@media (width <= 480px)', + 'tablet' => '@media (480px < width <= 782px)', + ); + /** * The valid elements that can be found under styles. * @@ -1054,11 +1067,12 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema_styles_elements = array(); /* - * Set allowed element pseudo selectors based on per element allow list. + * Set allowed element pseudo selectors and responsive breakpoint states.. * Target data structure in schema: * e.g. * - top level elements: `$schema['styles']['elements']['link'][':hover']`. * - block level elements: `$schema['styles']['blocks']['core/button']['elements']['link'][':hover']`. + * - block responsive elements: `$schema['styles']['blocks']['core/button']['tablet']['elements']['link'][':hover']`. */ foreach ( $valid_element_names as $element ) { $schema_styles_elements[ $element ] = $styles_non_top_level; @@ -1068,6 +1082,11 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema_styles_elements[ $element ][ $pseudo_selector ] = $styles_non_top_level; } } + + // Add responsive breakpoint states for elements. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) { + $schema_styles_elements[ $element ][ $breakpoint_state ] = $styles_non_top_level; + } } $schema_styles_blocks = array(); @@ -1075,19 +1094,31 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n /* * Generate a schema for blocks. - * - Block styles can contain `elements` & `variations` definitions. + * - Block styles can contain `elements`, `variations`, and responsive breakpoint state definitions. * - Variations definitions cannot be nested. - * - Variations can contain styles for inner `blocks`. - * - Variation inner `blocks` styles can contain `elements`. + * - Variations can contain styles for inner `blocks`, `elements`, and responsive breakpoint states. + * - Variation inner `blocks` styles can contain `elements` and responsive breakpoint states. * - * As each variation needs a `blocks` schema but further nested - * inner `blocks`, the overall schema will be generated in multiple passes. + * As each variation needs both a `blocks` schema and responsive `blocks` schemas + * for further nested inner `blocks`, the overall schema is generated in multiple passes. */ foreach ( $valid_block_names as $block ) { $schema_settings_blocks[ $block ] = static::VALID_SETTINGS; $schema_styles_blocks[ $block ] = $styles_non_top_level; $schema_styles_blocks[ $block ]['elements'] = $schema_styles_elements; + // Add responsive breakpoint states for all blocks. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) { + $schema_styles_blocks[ $block ][ $breakpoint_state ] = $styles_non_top_level; + $schema_styles_blocks[ $block ][ $breakpoint_state ]['elements'] = $schema_styles_elements; + + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { + $schema_styles_blocks[ $block ][ $breakpoint_state ][ $pseudo_selector ] = $styles_non_top_level; + } + } + } + // Add pseudo-selectors for blocks that support them if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { @@ -1119,6 +1150,19 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n foreach ( $style_variation_names as $variation_name ) { $variation_schema = $block_style_variation_styles; + // Add responsive breakpoint states to block style variations. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) { + $variation_schema[ $breakpoint_state ] = $styles_non_top_level; + $variation_schema[ $breakpoint_state ]['elements'] = $schema_styles_elements; + $variation_schema[ $breakpoint_state ]['blocks'] = $schema_styles_blocks; + + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { + $variation_schema[ $breakpoint_state ][ $pseudo_selector ] = $styles_non_top_level; + } + } + } + // Add pseudo-selectors to variations for blocks that support them if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { @@ -1886,6 +1930,11 @@ protected function get_layout_styles( $block_metadata, $options = array() ) { } } } + + if ( ! empty( $options['media_query'] ) && ! empty( $block_rules ) ) { + $block_rules = $options['media_query'] . '{' . $block_rules . '}'; + } + return $block_rules; } @@ -2887,56 +2936,156 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt 'path' => $node_path, 'selector' => $selector, 'selectors' => $feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), 'duotone' => $duotone_selector, 'features' => $feature_selectors, 'variations' => $variation_selectors, 'css' => $selector, ); + // Responsive block nodes: emit one node per breakpoint that has styles. + // These are rendered immediately after the base block node so that + // the cascade order is: .block{} → @media{.block{}} + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ] ) ) { + $nodes[] = array( + 'name' => $name, + 'path' => array( 'styles', 'blocks', $name, $breakpoint ), + 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ], + 'selector' => $selector, + 'selectors' => $feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), + 'variations' => $variation_selectors, + 'css' => $selector, + ); + } + } + // Handle any pseudo selectors for the block. if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) { foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo_selector ) { - if ( isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ) ) { + $has_pseudo = isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ); + if ( $has_pseudo ) { + /* + * Append the pseudo-selector to each feature selector so that + * get_feature_declarations_for_node generates CSS scoped to the + * pseudo-state (e.g. '.wp-block-button:hover') rather than the + * default state (e.g. '.wp-block-button'). + */ + $pseudo_feature_selectors = array(); + foreach ( $feature_selectors ?? array() as $feature => $feature_selector ) { + if ( is_array( $feature_selector ) ) { + $pseudo_feature_selectors[ $feature ] = array(); + foreach ( $feature_selector as $subfeature => $subfeature_selector ) { + $pseudo_feature_selectors[ $feature ][ $subfeature ] = static::append_to_selector( $subfeature_selector, $pseudo_selector ); + } + } else { + $pseudo_feature_selectors[ $feature ] = static::append_to_selector( $feature_selector, $pseudo_selector ); + } + } + $nodes[] = array( 'name' => $name, 'path' => array( 'styles', 'blocks', $name, $pseudo_selector ), 'selector' => static::append_to_selector( $selector, $pseudo_selector ), - 'selectors' => $feature_selectors, + 'selectors' => $pseudo_feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), 'duotone' => $duotone_selector, 'variations' => $variation_selectors, 'css' => static::append_to_selector( $selector, $pseudo_selector ), ); + + // Responsive pseudo nodes: emit one node per breakpoint that has + // this pseudo state, immediately after the default pseudo node. + // Cascade order: .block:hover{} → @media{.block:hover{}} + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) { + $nodes[] = array( + 'name' => $name, + 'path' => array( 'styles', 'blocks', $name, $breakpoint, $pseudo_selector ), + 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ], + 'selector' => static::append_to_selector( $selector, $pseudo_selector ), + 'selectors' => $pseudo_feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), + 'variations' => $variation_selectors, + 'css' => static::append_to_selector( $selector, $pseudo_selector ), + ); + } + } } } } } - if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) { foreach ( $theme_json['styles']['blocks'][ $name ]['elements'] as $element => $node ) { - $node_path = array( 'styles', 'blocks', $name, 'elements', $element ); + $element_path = array( 'styles', 'blocks', $name, 'elements', $element ); if ( $include_node_paths_only ) { $nodes[] = array( - 'path' => $node_path, + 'path' => $element_path, ); continue; } + $element_selector = $selectors[ $name ]['elements'][ $element ]; + $nodes[] = array( - 'path' => $node_path, - 'selector' => $selectors[ $name ]['elements'][ $element ], + 'path' => $element_path, + 'selector' => $element_selector, ); + // Responsive element nodes: one node per breakpoint that has + // styles for this element. Cascade: a{} → @media{a{}} + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ] ) ) { + $nodes[] = array( + 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ), + 'selector' => $element_selector, + 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ], + ); + } + } + // Handle any pseudo selectors for the element. if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] ) ) { foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] as $pseudo_selector ) { - if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'][ $element ][ $pseudo_selector ] ) ) { - $node_path = array( 'styles', 'blocks', $name, 'elements', $element ); + // Create element pseudo node if default or any responsive breakpoint has the pseudo. + $has_element_pseudo = isset( $theme_json['styles']['blocks'][ $name ]['elements'][ $element ][ $pseudo_selector ] ); + if ( ! $has_element_pseudo ) { + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $bp ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $bp ]['elements'][ $element ][ $pseudo_selector ] ) ) { + $has_element_pseudo = true; + break; + } + } + } + + if ( $has_element_pseudo ) { + $element_pseudo_path = array( 'styles', 'blocks', $name, 'elements', $element ); + if ( $include_node_paths_only ) { + $nodes[] = array( + 'path' => $element_pseudo_path, + ); + continue; + } $nodes[] = array( - 'path' => $node_path, - 'selector' => static::append_to_selector( $selectors[ $name ]['elements'][ $element ], $pseudo_selector ), + 'path' => $element_pseudo_path, + 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ), ); + + // Responsive element pseudo nodes: one node per breakpoint + // that has this pseudo state for this element. + // Cascade: a:hover{} → @media{a:hover{}} + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ][ $pseudo_selector ] ) ) { + $nodes[] = array( + 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ), + 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ), + 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ], + ); + } + } } } } @@ -2965,16 +3114,19 @@ public function get_styles_for_block( $block_metadata ) { $settings = $this->theme_json['settings'] ?? array(); $feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $node ); $is_root_selector = static::ROOT_BLOCK_SELECTOR === $selector; + $media_query = $block_metadata['media_query'] ?? null; // Update text indent selector for paragraph blocks based on the textIndent setting. $block_name = $block_metadata['name'] ?? null; $feature_declarations = static::update_paragraph_text_indent_selector( $feature_declarations, $settings, $block_name ); + $block_elements = $block_metadata['elements'] ?? array(); // If there are style variations, generate the declarations for them, including any feature selectors the block may have. $style_variation_declarations = array(); $style_variation_custom_css = array(); + $style_variation_responsive_css = array(); $style_variation_layout_metadata = array(); - if ( ! empty( $block_metadata['variations'] ) ) { + if ( ! $media_query && ! empty( $block_metadata['variations'] ) ) { foreach ( $block_metadata['variations'] as $style_variation ) { $style_variation_node = _wp_array_get( $this->theme_json, $style_variation['path'], array() ); $clean_style_variation_selector = trim( $style_variation['selector'] ); @@ -3040,6 +3192,135 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { 'node' => $style_variation_node, ); } + + // Store responsive breakpoint CSS for the style variation. + // This includes both base properties and feature-level selectors. + $variation_responsive_css = ''; + + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( ! isset( $style_variation_node[ $breakpoint ] ) ) { + continue; + } + + $breakpoint_node = $style_variation_node[ $breakpoint ]; + $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ]; + // Process feature-level declarations for this breakpoint. + $breakpoint_feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $breakpoint_node ); + $breakpoint_feature_declarations = static::update_paragraph_text_indent_selector( $breakpoint_feature_declarations, $settings, $block_name ); + $breakpoint_feature_declarations = static::update_button_width_declarations( $breakpoint_feature_declarations, $settings ); + foreach ( $breakpoint_feature_declarations as $feature_selector => $feature_decl ) { + $clean_feature_selector = preg_replace( '/,\s+/', ',', $feature_selector ); + $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_feature_selector ); + + if ( $block_metadata['selector'] && ! str_contains( $clean_feature_selector, $block_metadata['selector'] ) ) { + /* + * Feature selector is block-level (e.g. `.wp-block-button` for + * dimensions/width) — apply the variation class directly to it. + */ + $feature_element_selector = str_replace( $shortened_selector, '', $clean_style_variation_selector ); + $combined_selectors = str_replace( $feature_element_selector, '', $clean_style_variation_selector ); + } else { + // Prepend the variation selector to the current selector. + $split_selectors = explode( ',', $shortened_selector ); + $updated_selectors = array_map( + static function ( $split_selector ) use ( $clean_style_variation_selector ) { + return $clean_style_variation_selector . $split_selector; + }, + $split_selectors + ); + $combined_selectors = implode( ',', $updated_selectors ); + } + + $feature_ruleset = static::to_ruleset( ':root :where(' . $combined_selectors . ')', $feature_decl ); + $variation_responsive_css .= $breakpoint_media . '{' . $feature_ruleset . '}'; + } + + // Process base properties for this breakpoint. + $breakpoint_declarations = static::compute_style_properties( $breakpoint_node, $settings, null, $this->theme_json ); + if ( ! empty( $breakpoint_declarations ) ) { + $base_ruleset = static::to_ruleset( ':root :where(' . $style_variation['selector'] . ')', $breakpoint_declarations ); + $variation_responsive_css .= $breakpoint_media . '{' . $base_ruleset . '}'; + } + + $breakpoint_pseudo_declarations = static::process_pseudo_selectors( $breakpoint_node, $style_variation['selector'], $settings, $block_name ); + foreach ( $breakpoint_pseudo_declarations as $pseudo_selector => $pseudo_declarations ) { + if ( empty( $pseudo_declarations ) ) { + continue; + } + $pseudo_ruleset = static::to_ruleset( ':root :where(' . $pseudo_selector . ')', $pseudo_declarations ); + $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_ruleset . '}'; + } + + // Process custom CSS for this breakpoint. + if ( isset( $breakpoint_node['css'] ) ) { + $breakpoint_custom_css = static::process_blocks_custom_css( $breakpoint_node['css'], $style_variation['selector'] ); + $variation_responsive_css .= $breakpoint_media . '{' . $breakpoint_custom_css . '}'; + } + + // Process blockGap responsive layout styles for this variation. + if ( isset( $breakpoint_node['spacing']['blockGap'] ) ) { + $variation_layout_metadata = $style_variation; + $variation_layout_metadata['selector'] = $style_variation['selector'] . $block_metadata['css']; + $variation_responsive_css .= $this->get_layout_styles( + $variation_layout_metadata, + array( + 'node' => $breakpoint_node, + 'media_query' => $breakpoint_media, + ) + ); + } + + // Process nested element styles for this breakpoint state. + if ( isset( $breakpoint_node['elements'] ) && ! empty( $block_elements ) ) { + foreach ( $breakpoint_node['elements'] as $element_name => $element_node ) { + if ( ! isset( $block_elements[ $element_name ] ) ) { + continue; + } + + $clean_element_selector = preg_replace( '/,\s+/', ',', $block_elements[ $element_name ] ); + $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_element_selector ); + $split_selectors = explode( ',', $shortened_selector ); + $updated_selectors = array_map( + static function ( $split_selector ) use ( $clean_style_variation_selector ) { + return $clean_style_variation_selector . $split_selector; + }, + $split_selectors + ); + $variation_element_selector = implode( ',', $updated_selectors ); + + $element_declarations = static::compute_style_properties( $element_node, $settings, null, $this->theme_json ); + if ( ! empty( $element_declarations ) ) { + $element_ruleset = static::to_ruleset( ':root :where(' . $variation_element_selector . ')', $element_declarations ); + $variation_responsive_css .= $breakpoint_media . '{' . $element_ruleset . '}'; + } + + if ( isset( $element_node['css'] ) ) { + $element_custom_css = static::process_blocks_custom_css( $element_node['css'], $variation_element_selector ); + $variation_responsive_css .= $breakpoint_media . '{' . $element_custom_css . '}'; + } + + if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) { + foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) { + if ( ! isset( $element_node[ $pseudo_selector ] ) ) { + continue; + } + + $pseudo_declarations = static::compute_style_properties( $element_node[ $pseudo_selector ], $settings, null, $this->theme_json ); + if ( empty( $pseudo_declarations ) ) { + continue; + } + + $pseudo_selector_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $variation_element_selector, $pseudo_selector ) . ')', $pseudo_declarations ); + $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_selector_ruleset . '}'; + } + } + } + } + } + + if ( ! empty( $variation_responsive_css ) ) { + $style_variation_responsive_css[ $style_variation['selector'] ] = $variation_responsive_css; + } } } /* @@ -3231,6 +3512,13 @@ static function ( $pseudo_selector ) use ( $selector ) { $block_rules .= $this->process_blocks_custom_css( $node['css'], $selector ); } + // 8. Wrap the entire block output in a media query if this is a responsive node. + // Responsive nodes are created by get_block_nodes() for each breakpoint and carry + // a 'media_query' key. + if ( $media_query && ! empty( $block_rules ) ) { + $block_rules = $media_query . '{' . $block_rules . '}'; + } + return $block_rules; } @@ -3726,6 +4014,10 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme continue; } + $block_name = in_array( 'blocks', $metadata['path'], true ) + ? static::get_block_name_from_metadata_path( $metadata ) + : null; + // The global styles custom CSS is not sanitized, but can only be edited by users with 'edit_css' capability. if ( isset( $input['css'] ) && current_user_can( 'edit_css' ) ) { $output = $input; @@ -3751,6 +4043,34 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme } } + // Re-add and process responsive breakpoint styles. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $input[ $breakpoint ] ) ) { + $output[ $breakpoint ] = static::remove_insecure_styles( $input[ $breakpoint ] ); + + if ( isset( $input[ $breakpoint ]['elements'] ) ) { + $output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $input[ $breakpoint ]['elements'] ); + } + + if ( isset( $input[ $breakpoint ]['blocks'] ) ) { + $output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $input[ $breakpoint ]['blocks'] ); + } + + if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) { + if ( isset( $input[ $breakpoint ][ $pseudo_selector ] ) ) { + $output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $input[ $breakpoint ][ $pseudo_selector ] ); + } + } + } + + // Responsive custom CSS is allowed for users with 'edit_css' capability. + if ( isset( $input[ $breakpoint ]['css'] ) && current_user_can( 'edit_css' ) ) { + $output[ $breakpoint ]['css'] = $input[ $breakpoint ]['css']; + } + } + } + if ( ! empty( $output ) ) { _wp_array_set( $sanitized, $metadata['path'], $output ); } @@ -3772,6 +4092,34 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme $variation_output['elements'] = static::remove_insecure_element_styles( $variation_input['elements'] ); } + // Re-add and process responsive breakpoint styles for variations. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $variation_input[ $breakpoint ] ) ) { + $variation_output[ $breakpoint ] = static::remove_insecure_styles( $variation_input[ $breakpoint ] ); + + if ( isset( $variation_input[ $breakpoint ]['elements'] ) ) { + $variation_output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $variation_input[ $breakpoint ]['elements'] ); + } + + if ( isset( $variation_input[ $breakpoint ]['blocks'] ) ) { + $variation_output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $variation_input[ $breakpoint ]['blocks'] ); + } + + if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) { + if ( isset( $variation_input[ $breakpoint ][ $pseudo_selector ] ) ) { + $variation_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $variation_input[ $breakpoint ][ $pseudo_selector ] ); + } + } + } + + // Responsive custom CSS is allowed for users with 'edit_css' capability. + if ( isset( $variation_input[ $breakpoint ]['css'] ) && current_user_can( 'edit_css' ) ) { + $variation_output[ $breakpoint ]['css'] = $variation_input[ $breakpoint ]['css']; + } + } + } + if ( ! empty( $variation_output ) ) { _wp_array_set( $sanitized, $variation['path'], $variation_output ); } @@ -3832,6 +4180,21 @@ protected static function remove_insecure_element_styles( $elements ) { } } + // Re-add and process responsive breakpoint styles for elements. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $element_input[ $breakpoint ] ) ) { + $element_output[ $breakpoint ] = static::remove_insecure_styles( $element_input[ $breakpoint ] ); + + if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) { + foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) { + if ( isset( $element_input[ $breakpoint ][ $pseudo_selector ] ) ) { + $element_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $element_input[ $breakpoint ][ $pseudo_selector ] ); + } + } + } + } + } + $sanitized[ $element_name ] = $element_output; } } @@ -3855,6 +4218,21 @@ protected static function remove_insecure_inner_block_styles( $blocks ) { $block_output['elements'] = static::remove_insecure_element_styles( $block_input['elements'] ); } + // Re-add and process responsive breakpoint styles for inner blocks. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $block_input[ $breakpoint ] ) ) { + $block_output[ $breakpoint ] = static::remove_insecure_styles( $block_input[ $breakpoint ] ); + + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] as $pseudo_selector ) { + if ( isset( $block_input[ $breakpoint ][ $pseudo_selector ] ) ) { + $block_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $block_input[ $breakpoint ][ $pseudo_selector ] ); + } + } + } + } + } + $sanitized[ $block_type ] = $block_output; } return $sanitized; From 1f87d3c116b0e45040acf80e8d8edc18a4d05d97 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Mon, 1 Jun 2026 13:59:21 +1000 Subject: [PATCH 2/8] add get_block_name_from_metadata_path --- src/wp-includes/class-wp-theme-json.php | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 13c73f71075b7..a90ac2dc337bd 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -3169,7 +3169,7 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { if ( isset( $block_metadata['name'] ) ) { $block_name = $block_metadata['name']; } elseif ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 3 ) { - $block_name = $block_metadata['path'][2]; + $block_name = static::get_block_name_from_metadata_path( $block_metadata ); } else { $block_name = null; } @@ -3346,7 +3346,7 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $is_processing_block_pseudo = false; $block_pseudo_selector = null; if ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 4 ) { - $block_name = $block_metadata['path'][2]; // 'core/button' + $block_name = static::get_block_name_from_metadata_path( $block_metadata ); // 'core/button' $last_path_element = $block_metadata['path'][ count( $block_metadata['path'] ) - 1 ]; // ':hover' if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) && @@ -3388,7 +3388,7 @@ static function ( $pseudo_selector ) use ( $selector ) { } elseif ( $is_processing_block_pseudo ) { // Process block pseudo-selector styles // For block pseudo-selectors, we need to get the block data first, then access the pseudo-selector - $block_name = $block_metadata['path'][2]; // 'core/button' + $block_name = static::get_block_name_from_metadata_path( $block_metadata ); // 'core/button' $block_data = _wp_array_get( $this->theme_json, array( 'styles', 'blocks', $block_name ), array() ); $pseudo_data = $block_data[ $block_pseudo_selector ] ?? array(); @@ -5221,4 +5221,18 @@ protected static function get_valid_block_style_variations( $blocks_metadata = a return $valid_variations; } + + /** + * Extracts the block name from the block metadata path. + * + * @since 7.0 + * + * @param array $block_metadata Block metadata. + * @return string|null The block name or null if not found. + */ + private static function get_block_name_from_metadata_path( $block_metadata ) { + if ( isset( $block_metadata['path'] ) ) { + return $block_metadata['path'][2]; + } + } } From f3fead8c761f981497c1702348d704c8740f03e8 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Mon, 1 Jun 2026 14:37:45 +1000 Subject: [PATCH 3/8] update to final state of gutenberg PR --- src/wp-includes/class-wp-theme-json.php | 120 +++++++++++++++++------- 1 file changed, 86 insertions(+), 34 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index a90ac2dc337bd..9b45e6d1eef6a 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -2922,6 +2922,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt } $variation_selectors = array(); + if ( $include_variations && isset( $node['variations'] ) ) { foreach ( $node['variations'] as $variation => $node ) { $variation_selectors[] = array( @@ -2938,7 +2939,6 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt 'selectors' => $feature_selectors, 'elements' => $selectors[ $name ]['elements'] ?? array(), 'duotone' => $duotone_selector, - 'features' => $feature_selectors, 'variations' => $variation_selectors, 'css' => $selector, ); @@ -2964,26 +2964,38 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt // Handle any pseudo selectors for the block. if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) { foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo_selector ) { - $has_pseudo = isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ); - if ( $has_pseudo ) { - /* - * Append the pseudo-selector to each feature selector so that - * get_feature_declarations_for_node generates CSS scoped to the - * pseudo-state (e.g. '.wp-block-button:hover') rather than the - * default state (e.g. '.wp-block-button'). - */ - $pseudo_feature_selectors = array(); - foreach ( $feature_selectors ?? array() as $feature => $feature_selector ) { - if ( is_array( $feature_selector ) ) { - $pseudo_feature_selectors[ $feature ] = array(); - foreach ( $feature_selector as $subfeature => $subfeature_selector ) { - $pseudo_feature_selectors[ $feature ][ $subfeature ] = static::append_to_selector( $subfeature_selector, $pseudo_selector ); - } - } else { - $pseudo_feature_selectors[ $feature ] = static::append_to_selector( $feature_selector, $pseudo_selector ); + $has_pseudo = isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ); + $has_responsive_pseudo = false; + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) { + $has_responsive_pseudo = true; + break; + } + } + + if ( ! $has_pseudo && ! $has_responsive_pseudo ) { + continue; + } + + /* + * Append the pseudo-selector to each feature selector so that + * get_feature_declarations_for_node generates CSS scoped to the + * pseudo-state (e.g. '.wp-block-button:hover') rather than the + * default state (e.g. '.wp-block-button'). + */ + $pseudo_feature_selectors = array(); + foreach ( $feature_selectors ?? array() as $feature => $feature_selector ) { + if ( is_array( $feature_selector ) ) { + $pseudo_feature_selectors[ $feature ] = array(); + foreach ( $feature_selector as $subfeature => $subfeature_selector ) { + $pseudo_feature_selectors[ $feature ][ $subfeature ] = static::append_to_selector( $subfeature_selector, $pseudo_selector ); } + } else { + $pseudo_feature_selectors[ $feature ] = static::append_to_selector( $feature_selector, $pseudo_selector ); } + } + if ( $has_pseudo ) { $nodes[] = array( 'name' => $name, 'path' => array( 'styles', 'blocks', $name, $pseudo_selector ), @@ -2994,22 +3006,63 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt 'variations' => $variation_selectors, 'css' => static::append_to_selector( $selector, $pseudo_selector ), ); + } - // Responsive pseudo nodes: emit one node per breakpoint that has - // this pseudo state, immediately after the default pseudo node. - // Cascade order: .block:hover{} → @media{.block:hover{}} - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { - if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) { - $nodes[] = array( - 'name' => $name, - 'path' => array( 'styles', 'blocks', $name, $breakpoint, $pseudo_selector ), - 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ], - 'selector' => static::append_to_selector( $selector, $pseudo_selector ), - 'selectors' => $pseudo_feature_selectors, - 'elements' => $selectors[ $name ]['elements'] ?? array(), - 'variations' => $variation_selectors, - 'css' => static::append_to_selector( $selector, $pseudo_selector ), - ); + // Responsive pseudo nodes: emit one node per breakpoint that has + // this pseudo state, immediately after the default pseudo node. + // Cascade order: .block:hover{} → @media{.block:hover{}} + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) { + $nodes[] = array( + 'name' => $name, + 'path' => array( 'styles', 'blocks', $name, $breakpoint, $pseudo_selector ), + 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ], + 'selector' => static::append_to_selector( $selector, $pseudo_selector ), + 'selectors' => $pseudo_feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), + 'variations' => $variation_selectors, + 'css' => static::append_to_selector( $selector, $pseudo_selector ), + ); + } + } + } + } + + // Handle custom states (e.g. '@current' for navigation). + if ( isset( static::VALID_BLOCK_CUSTOM_STATES[ $name ] ) ) { + foreach ( static::VALID_BLOCK_CUSTOM_STATES[ $name ] as $custom_state ) { + if ( + isset( $theme_json['styles']['blocks'][ $name ][ $custom_state ] ) && + isset( $selectors[ $name ]['states'][ $custom_state ] ) + ) { + $custom_css_selector = $selectors[ $name ]['states'][ $custom_state ]; + $nodes[] = array( + 'name' => $name, + 'path' => array( 'styles', 'blocks', $name, $custom_state ), + 'selector' => $custom_css_selector, + 'selectors' => $feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), + 'duotone' => $duotone_selector, + 'variations' => $variation_selectors, + 'css' => $custom_css_selector, + ); + + // Sub-pseudo-selectors within the custom state. + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $custom_state ][ $pseudo ] ) ) { + $compound_css_selector = static::append_to_selector( $custom_css_selector, $pseudo ); + $nodes[] = array( + 'name' => $name, + 'path' => array( 'styles', 'blocks', $name, $custom_state, $pseudo ), + 'selector' => $compound_css_selector, + 'selectors' => $feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), + 'duotone' => $duotone_selector, + 'variations' => $variation_selectors, + 'css' => $compound_css_selector, + ); + } } } } @@ -3019,7 +3072,6 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) { foreach ( $theme_json['styles']['blocks'][ $name ]['elements'] as $element => $node ) { $element_path = array( 'styles', 'blocks', $name, 'elements', $element ); - if ( $include_node_paths_only ) { $nodes[] = array( 'path' => $element_path, From f7e7559689af7d57ea478e3de0ae15ec1d6ce91f Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Mon, 1 Jun 2026 14:49:39 +1000 Subject: [PATCH 4/8] add some tests --- tests/phpunit/tests/theme/wpThemeJson.php | 451 ++++++++++++++++++++++ 1 file changed, 451 insertions(+) diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index ef1da85ce76d3..972652d7d67ee 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -936,6 +936,314 @@ public function test_get_styles_for_block_handles_whitelisted_element_pseudo_sel $this->assertSame( $focus_style, $theme_json->get_styles_for_block( $focus_node ) ); } + public function test_get_styles_for_block_responsive_feature_selector_not_duplicated_on_base_selector() { + register_block_type( + 'test/responsive-feature', + array( + 'api_version' => 3, + 'selectors' => array( + 'root' => '.wp-block-test-responsive-feature', + 'color' => '.wp-block-test-responsive-feature .color-target', + ), + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/responsive-feature' => array( + 'mobile' => array( + 'color' => array( + 'text' => 'red', + ), + ), + ), + ), + ), + ) + ); + + $base_metadata = array( + 'name' => 'test/responsive-feature', + 'path' => array( 'styles', 'blocks', 'test/responsive-feature' ), + 'selector' => '.wp-block-test-responsive-feature', + 'selectors' => array( + 'color' => '.wp-block-test-responsive-feature .color-target', + ), + ); + + $mobile_metadata = array( + 'name' => 'test/responsive-feature', + 'path' => array( 'styles', 'blocks', 'test/responsive-feature', 'mobile' ), + 'selector' => '.wp-block-test-responsive-feature', + 'selectors' => array( + 'color' => '.wp-block-test-responsive-feature .color-target', + ), + 'media_query' => '@media (width <= 480px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $base_metadata ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_metadata ); + + unregister_block_type( 'test/responsive-feature' ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.wp-block-test-responsive-feature .color-target){color: red;}}', + $actual_styles + ); + $this->assertStringNotContainsString( + '@media (width <= 480px){:root :where(.wp-block-test-responsive-feature){color: red;}}', + $actual_styles + ); + } + + public function test_get_styles_for_block_outputs_responsive_block_gap_after_default_gap() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'spacing' => array( + 'blockGap' => true, + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'spacing' => array( + 'blockGap' => '5rem', + ), + 'mobile' => array( + 'spacing' => array( + 'blockGap' => '2rem', + ), + ), + ), + ), + ), + ) + ); + + $base_metadata = array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + ); + + $mobile_metadata = array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group', 'mobile' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + 'media_query' => '@media (width <= 480px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $base_metadata ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_metadata ); + + $default_gap = ':root :where(.wp-block-group-is-layout-flex){gap: 5rem;}'; + $mobile_gap = ':root :where(.wp-block-group-is-layout-flex){gap: 2rem;}'; + + $this->assertStringContainsString( $default_gap, $actual_styles ); + $this->assertStringContainsString( '@media (width <= 480px)', $actual_styles ); + $this->assertStringContainsString( $mobile_gap, $actual_styles ); + $this->assertLessThan( strpos( $actual_styles, $mobile_gap ), strpos( $actual_styles, $default_gap ) ); + } + + public function test_get_styles_for_block_responsive_element_pseudo_styles_preserve_order_and_do_not_duplicate_pseudo() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'blue', + ), + ':hover' => array( + 'color' => array( + 'text' => 'navy', + ), + ), + ), + ), + 'mobile' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'red', + ), + ':hover' => array( + 'color' => array( + 'text' => 'darkred', + ), + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $link_selector = '.wp-block-group a:where(:not(.wp-element-button))'; + + // Nodes are assembled in cascade order: default, responsive, pseudo, responsive pseudo. + $link_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'elements', 'link' ), + 'selector' => $link_selector, + ); + + $mobile_link_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'mobile', 'elements', 'link' ), + 'selector' => $link_selector, + 'media_query' => '@media (width <= 480px)', + ); + + $hover_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'elements', 'link' ), + 'selector' => $link_selector . ':hover', + ); + + $mobile_hover_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'mobile', 'elements', 'link' ), + 'selector' => $link_selector . ':hover', + 'media_query' => '@media (width <= 480px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $link_node ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_link_node ); + $actual_styles .= $theme_json->get_styles_for_block( $hover_node ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_hover_node ); + + $default_link = ':root :where(.wp-block-group a:where(:not(.wp-element-button))){color: blue;}'; + $mobile_link = '@media (width <= 480px){:root :where(.wp-block-group a:where(:not(.wp-element-button))){color: red;}}'; + $default_hov = ':root :where(.wp-block-group a:where(:not(.wp-element-button)):hover){color: navy;}'; + $mobile_hov = '@media (width <= 480px){:root :where(.wp-block-group a:where(:not(.wp-element-button)):hover){color: darkred;}}'; + + $this->assertStringContainsString( $default_link, $actual_styles ); + $this->assertStringContainsString( $mobile_link, $actual_styles ); + $this->assertStringContainsString( $default_hov, $actual_styles ); + $this->assertStringContainsString( $mobile_hov, $actual_styles ); + + $this->assertLessThan( strpos( $actual_styles, $mobile_link ), strpos( $actual_styles, $default_link ) ); + $this->assertLessThan( strpos( $actual_styles, $default_hov ), strpos( $actual_styles, $mobile_link ) ); + $this->assertLessThan( strpos( $actual_styles, $mobile_hov ), strpos( $actual_styles, $default_hov ) ); + $this->assertStringNotContainsString( ':hover:hover', $actual_styles ); + } + + public function test_get_styles_for_block_with_style_variations_and_responsive_block_gap() { + register_block_style( + 'core/group', + array( + 'name' => 'withGap', + 'label' => 'With Gap', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'spacing' => array( + 'blockGap' => true, + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'variations' => array( + 'withGap' => array( + 'spacing' => array( + 'blockGap' => '5rem', + ), + 'mobile' => array( + 'spacing' => array( + 'blockGap' => '2rem', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $metadata = 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', 'withGap' ), + 'selector' => '.is-style-withGap.wp-block-group', + ), + ), + ); + + $actual_styles = $theme_json->get_styles_for_block( $metadata ); + + unregister_block_style( 'core/group', 'withGap' ); + + $default_gap = ':root :where(.is-style-withGap.wp-block-group.wp-block-group-is-layout-flex){gap: 5rem;}'; + $mobile_gap = ':root :where(.is-style-withGap.wp-block-group.wp-block-group-is-layout-flex){gap: 2rem;}'; + + $this->assertStringContainsString( $default_gap, $actual_styles ); + $this->assertStringContainsString( '@media (width <= 480px)', $actual_styles ); + $this->assertStringContainsString( $mobile_gap, $actual_styles ); + $this->assertLessThan( strpos( $actual_styles, $mobile_gap ), strpos( $actual_styles, $default_gap ) ); + } + + public function test_get_styles_for_block_outputs_tablet_responsive_styles_only() { + register_block_type( + 'test/tablet-only', + array( + 'api_version' => 3, + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/tablet-only' => array( + 'tablet' => array( + 'color' => array( + 'text' => 'purple', + ), + ), + ), + ), + ), + ) + ); + + $tablet_metadata = array( + 'name' => 'test/tablet-only', + 'path' => array( 'styles', 'blocks', 'test/tablet-only', 'tablet' ), + 'selector' => '.wp-block-test-tablet-only', + 'media_query' => '@media (480px < width <= 782px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $tablet_metadata ); + + unregister_block_type( 'test/tablet-only' ); + + $this->assertStringContainsString( + '@media (480px < width <= 782px){:root :where(.wp-block-test-tablet-only){color: purple;}}', + $actual_styles + ); + $this->assertStringNotContainsString( '@media (width <= 480px)', $actual_styles ); + } + /** * Tests that if an element has nothing but pseudo selector styles, they are still output by get_stylesheet. * @@ -1379,6 +1687,39 @@ public function test_get_stylesheet_returns_outline_styles() { $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); } + /** + * Tests that responsive block pseudo-selector styles are output even when the + * default pseudo-selector state does not have styles. + */ + public function test_get_stylesheet_outputs_responsive_block_pseudo_selector_without_default_pseudo_selector() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'mobile' => array( + ':hover' => array( + 'typography' => array( + 'writingMode' => 'vertical-rl', + ), + ), + ), + ), + ), + ), + ), + 'default' + ); + + $actual = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + $this->assertSame( + '@media (width <= 480px){:root :where(.wp-block-button .wp-block-button__link:hover){writing-mode: vertical-rl;}}', + $actual + ); + } + /** * Tests that a custom root selector is correctly applied when generating a stylesheet. * @@ -2859,6 +3200,116 @@ public function test_remove_insecure_properties_removes_unsafe_styles_sub_proper $this->assertEqualSetsWithIndex( $expected, $actual ); } + /** + * @covers WP_Theme_JSON::remove_insecure_properties + */ + public function test_remove_insecure_properties_preserves_responsive_block_element_styles() { + $actual = WP_Theme_JSON::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-gray', + ), + 'mobile' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-pink', + ), + ), + 'tablet' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-red', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-gray)', + ), + 'mobile' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-pink)', + ), + ), + 'tablet' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-red)', + ), + ), + ), + ), + ), + ), + ), + ); + + $this->assertEqualSetsWithIndex( $expected, $actual ); + } + + /** + * @covers WP_Theme_JSON::remove_insecure_properties + */ + public function test_remove_insecure_properties_preserves_responsive_elements_within_block_state() { + $actual = WP_Theme_JSON::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'mobile' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-pink', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'mobile' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-pink)', + ), + ), + ), + ), + ), + ), + ), + ); + + $this->assertEqualSetsWithIndex( $expected, $actual ); + } + /** * @ticket 54336 */ From 8c90405f802e52c5e723389a092c0eb1e29ad67a Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Mon, 1 Jun 2026 18:32:42 +1000 Subject: [PATCH 5/8] remove code from other features --- src/wp-includes/class-wp-theme-json.php | 45 ++--------------------- tests/phpunit/tests/theme/wpThemeJson.php | 33 ----------------- 2 files changed, 3 insertions(+), 75 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 9b45e6d1eef6a..c238a6a9d0f51 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -3027,47 +3027,6 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt } } } - - // Handle custom states (e.g. '@current' for navigation). - if ( isset( static::VALID_BLOCK_CUSTOM_STATES[ $name ] ) ) { - foreach ( static::VALID_BLOCK_CUSTOM_STATES[ $name ] as $custom_state ) { - if ( - isset( $theme_json['styles']['blocks'][ $name ][ $custom_state ] ) && - isset( $selectors[ $name ]['states'][ $custom_state ] ) - ) { - $custom_css_selector = $selectors[ $name ]['states'][ $custom_state ]; - $nodes[] = array( - 'name' => $name, - 'path' => array( 'styles', 'blocks', $name, $custom_state ), - 'selector' => $custom_css_selector, - 'selectors' => $feature_selectors, - 'elements' => $selectors[ $name ]['elements'] ?? array(), - 'duotone' => $duotone_selector, - 'variations' => $variation_selectors, - 'css' => $custom_css_selector, - ); - - // Sub-pseudo-selectors within the custom state. - if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) { - foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo ) { - if ( isset( $theme_json['styles']['blocks'][ $name ][ $custom_state ][ $pseudo ] ) ) { - $compound_css_selector = static::append_to_selector( $custom_css_selector, $pseudo ); - $nodes[] = array( - 'name' => $name, - 'path' => array( 'styles', 'blocks', $name, $custom_state, $pseudo ), - 'selector' => $compound_css_selector, - 'selectors' => $feature_selectors, - 'elements' => $selectors[ $name ]['elements'] ?? array(), - 'duotone' => $duotone_selector, - 'variations' => $variation_selectors, - 'css' => $compound_css_selector, - ); - } - } - } - } - } - } } if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) { foreach ( $theme_json['styles']['blocks'][ $name ]['elements'] as $element => $node ) { @@ -3259,7 +3218,6 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { // Process feature-level declarations for this breakpoint. $breakpoint_feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $breakpoint_node ); $breakpoint_feature_declarations = static::update_paragraph_text_indent_selector( $breakpoint_feature_declarations, $settings, $block_name ); - $breakpoint_feature_declarations = static::update_button_width_declarations( $breakpoint_feature_declarations, $settings ); foreach ( $breakpoint_feature_declarations as $feature_selector => $feature_decl ) { $clean_feature_selector = preg_replace( '/,\s+/', ',', $feature_selector ); $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_feature_selector ); @@ -3557,6 +3515,9 @@ static function ( $pseudo_selector ) use ( $selector ) { if ( isset( $style_variation_custom_css[ $style_variation_selector ] ) ) { $block_rules .= $style_variation_custom_css[ $style_variation_selector ]; } + if ( isset( $style_variation_responsive_css[ $style_variation_selector ] ) ) { + $block_rules .= $style_variation_responsive_css[ $style_variation_selector ]; + } } // 7. Generate and append any custom CSS rules. diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 972652d7d67ee..7f20573aa2b39 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -1687,39 +1687,6 @@ public function test_get_stylesheet_returns_outline_styles() { $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); } - /** - * Tests that responsive block pseudo-selector styles are output even when the - * default pseudo-selector state does not have styles. - */ - public function test_get_stylesheet_outputs_responsive_block_pseudo_selector_without_default_pseudo_selector() { - $theme_json = new WP_Theme_JSON( - array( - 'version' => WP_Theme_JSON::LATEST_SCHEMA, - 'styles' => array( - 'blocks' => array( - 'core/button' => array( - 'mobile' => array( - ':hover' => array( - 'typography' => array( - 'writingMode' => 'vertical-rl', - ), - ), - ), - ), - ), - ), - ), - 'default' - ); - - $actual = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); - - $this->assertSame( - '@media (width <= 480px){:root :where(.wp-block-button .wp-block-button__link:hover){writing-mode: vertical-rl;}}', - $actual - ); - } - /** * Tests that a custom root selector is correctly applied when generating a stylesheet. * From 5aaa944172ae1d9d6a779cf9ce1904d2ef352d2b Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 2 Jun 2026 14:26:03 +1000 Subject: [PATCH 6/8] Apply suggestions from code review Co-authored-by: Ramon --- src/wp-includes/class-wp-theme-json.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index c238a6a9d0f51..768306e558e61 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -1067,7 +1067,7 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema_styles_elements = array(); /* - * Set allowed element pseudo selectors and responsive breakpoint states.. + * Set allowed element pseudo selectors and responsive breakpoint states. * Target data structure in schema: * e.g. * - top level elements: `$schema['styles']['elements']['link'][':hover']`. @@ -5238,7 +5238,7 @@ protected static function get_valid_block_style_variations( $blocks_metadata = a /** * Extracts the block name from the block metadata path. * - * @since 7.0 + * @since 7.1 * * @param array $block_metadata Block metadata. * @return string|null The block name or null if not found. From b45bec6c1afe96ad96787652ca2dcfd8c75d6d72 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 2 Jun 2026 14:41:29 +1000 Subject: [PATCH 7/8] simplify metadata path return --- src/wp-includes/class-wp-theme-json.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 768306e558e61..ad6d0ecc7a061 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -5244,8 +5244,6 @@ protected static function get_valid_block_style_variations( $blocks_metadata = a * @return string|null The block name or null if not found. */ private static function get_block_name_from_metadata_path( $block_metadata ) { - if ( isset( $block_metadata['path'] ) ) { - return $block_metadata['path'][2]; - } + return $block_metadata['path'][2] ?? null; } } From 988113e2f5b08ad126e485606f22dc569c619a6e Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 2 Jun 2026 14:54:39 +1000 Subject: [PATCH 8/8] add ticket notes --- tests/phpunit/tests/theme/wpThemeJson.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 7f20573aa2b39..7eda511e1d9ec 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -936,6 +936,9 @@ public function test_get_styles_for_block_handles_whitelisted_element_pseudo_sel $this->assertSame( $focus_style, $theme_json->get_styles_for_block( $focus_node ) ); } + /** + * @ticket 65164 + */ public function test_get_styles_for_block_responsive_feature_selector_not_duplicated_on_base_selector() { register_block_type( 'test/responsive-feature', @@ -999,6 +1002,9 @@ public function test_get_styles_for_block_responsive_feature_selector_not_duplic ); } + /** + * @ticket 65164 + */ public function test_get_styles_for_block_outputs_responsive_block_gap_after_default_gap() { $theme_json = new WP_Theme_JSON( array( @@ -1052,6 +1058,9 @@ public function test_get_styles_for_block_outputs_responsive_block_gap_after_def $this->assertLessThan( strpos( $actual_styles, $mobile_gap ), strpos( $actual_styles, $default_gap ) ); } + /** + * @ticket 65164 + */ public function test_get_styles_for_block_responsive_element_pseudo_styles_preserve_order_and_do_not_duplicate_pseudo() { $theme_json = new WP_Theme_JSON( array( @@ -1137,6 +1146,9 @@ public function test_get_styles_for_block_responsive_element_pseudo_styles_prese $this->assertStringNotContainsString( ':hover:hover', $actual_styles ); } + /** + * @ticket 65164 + */ public function test_get_styles_for_block_with_style_variations_and_responsive_block_gap() { register_block_style( 'core/group', @@ -1201,6 +1213,9 @@ public function test_get_styles_for_block_with_style_variations_and_responsive_b $this->assertLessThan( strpos( $actual_styles, $mobile_gap ), strpos( $actual_styles, $default_gap ) ); } + /** + * @ticket 65164 + */ public function test_get_styles_for_block_outputs_tablet_responsive_styles_only() { register_block_type( 'test/tablet-only', @@ -3169,6 +3184,8 @@ public function test_remove_insecure_properties_removes_unsafe_styles_sub_proper /** * @covers WP_Theme_JSON::remove_insecure_properties + * + * @ticket 65164 */ public function test_remove_insecure_properties_preserves_responsive_block_element_styles() { $actual = WP_Theme_JSON::remove_insecure_properties( @@ -3232,6 +3249,8 @@ public function test_remove_insecure_properties_preserves_responsive_block_eleme /** * @covers WP_Theme_JSON::remove_insecure_properties + * + * @ticket 65164 */ public function test_remove_insecure_properties_preserves_responsive_elements_within_block_state() { $actual = WP_Theme_JSON::remove_insecure_properties(