From f3ec1e25143a3aea164a3fce2bc32e5ccb9897be Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 21 Apr 2026 16:16:00 +1000 Subject: [PATCH 01/26] Try responsive global block styles with states --- lib/class-wp-theme-json-gutenberg.php | 83 +++++++++++ .../global-styles-engine/src/core/render.tsx | 134 +++++++++++++++++- packages/global-styles-ui/src/hooks.ts | 34 +++-- packages/global-styles-ui/src/utils.ts | 17 ++- schemas/json/theme.json | 48 +++++++ 5 files changed, 303 insertions(+), 13 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 4423b6c2db1efb..d4611bdc71fdd5 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -620,6 +620,19 @@ class WP_Theme_JSON_Gutenberg { 'core/navigation-link' => 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)', + ); + /** * Custom states for blocks that map to CSS class selectors rather than * CSS pseudo-selectors. Values use the '@' prefix (e.g. '@current') to @@ -759,6 +772,37 @@ private static function process_pseudo_selectors( $node, $base_selector, $settin return $pseudo_declarations; } + /** + * Returns CSS rules for responsive breakpoint states stored in a block node. + * Unlike pseudo-selectors, breakpoint styles are available for all blocks and + * are wrapped in CSS media queries rather than appended to the selector. + * + * @param array $node The block's styles node from theme.json. + * @param string $base_selector The base CSS selector for the block. + * @param array $settings The theme.json settings. + * @return string CSS rules string with media query wrappers. + */ + private static function process_responsive_selectors( $node, $base_selector, $settings ) { + $responsive_css = ''; + + foreach ( static::RESPONSIVE_BREAKPOINTS as $breakpoint_key => $media_query ) { + if ( ! isset( $node[ $breakpoint_key ] ) ) { + continue; + } + + $declarations = static::compute_style_properties( $node[ $breakpoint_key ], $settings, null, null ); + + if ( empty( $declarations ) ) { + continue; + } + + $inner_rule = static::to_ruleset( ":root :where($base_selector)", $declarations ); + $responsive_css .= $media_query . '{' . $inner_rule . '}'; + } + + return $responsive_css; + } + /** * Returns a class name by an element name. * @@ -1087,6 +1131,11 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $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; + } + // 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 ) { @@ -1133,6 +1182,11 @@ 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; + } + // 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 ) { @@ -3245,6 +3299,7 @@ public function get_styles_for_block( $block_metadata ) { // 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'] ) ) { foreach ( $block_metadata['variations'] as $style_variation ) { @@ -3297,6 +3352,12 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $style_variation_custom_css[ $style_variation['selector'] ] = $this->process_blocks_custom_css( $style_variation_node['css'], $style_variation['selector'] ); } + // Store responsive breakpoint CSS for the style variation. + $variation_responsive_css = static::process_responsive_selectors( $style_variation_node, $style_variation['selector'], $settings ); + if ( ! empty( $variation_responsive_css ) ) { + $style_variation_responsive_css[ $style_variation['selector'] ] = $variation_responsive_css; + } + // Store variation metadata and node for layout styles generation. // Only store if the variation has blockGap defined. if ( isset( $style_variation_node['spacing']['blockGap'] ) ) { @@ -3474,6 +3535,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. @@ -3486,6 +3550,11 @@ static function ( $pseudo_selector ) use ( $selector ) { $block_rules .= $this->process_blocks_custom_css( $node['css'], $css_selector ); } + // 8. Generate and append responsive breakpoint rules. + if ( ! $is_root_selector ) { + $block_rules .= static::process_responsive_selectors( $node, $selector, $settings ); + } + return $block_rules; } @@ -4006,6 +4075,13 @@ 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 ( ! empty( $output ) ) { _wp_array_set( $sanitized, $metadata['path'], $output ); } @@ -4027,6 +4103,13 @@ 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 ( ! empty( $variation_output ) ) { _wp_array_set( $sanitized, $variation['path'], $variation_output ); } diff --git a/packages/global-styles-engine/src/core/render.tsx b/packages/global-styles-engine/src/core/render.tsx index 69174f06d2b493..5f4d786451db40 100644 --- a/packages/global-styles-engine/src/core/render.tsx +++ b/packages/global-styles-engine/src/core/render.tsx @@ -163,6 +163,15 @@ const VALID_BLOCK_PSEUDO_SELECTORS: Record< string, string[] > = { 'core/navigation-link': [ ':hover', ':focus', ':focus-visible', ':active' ], }; +/** + * Responsive breakpoint state keys and their corresponding CSS media queries. + * Keep in sync with WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS. + */ +const RESPONSIVE_BREAKPOINTS: Record< string, string > = { + mobile: '@media (width <= 480px)', + tablet: '@media (480px < width <= 782px)', +}; + /** * Transform given preset tree into a set of preset class declarations. * @@ -875,9 +884,17 @@ function pickStyleAndPseudoKeys( const allowedPseudoSelectors = blockName ? VALID_BLOCK_PSEUDO_SELECTORS[ blockName ] ?? [] : []; + // Responsive breakpoint keys are available for all blocks (blockName contains '/'). + const includeResponsive = blockName?.includes( '/' ) ?? false; const pickedEntries = entries.filter( ( [ key ] ) => - STYLE_KEYS.includes( key ) || allowedPseudoSelectors.includes( key ) + STYLE_KEYS.includes( key ) || + allowedPseudoSelectors.includes( key ) || + ( includeResponsive && + Object.prototype.hasOwnProperty.call( + RESPONSIVE_BREAKPOINTS, + key + ) ) ); // clone the style objects so that `getFeatureDeclarations` can remove consumed keys from it const clonedEntries = pickedEntries.map( ( [ key, style ] ) => [ @@ -973,6 +990,104 @@ function appendPseudoSelectorStyles( return ruleset; } +/** + * Appends CSS rules for responsive breakpoint states to a ruleset string. + * Block styles stored under 'mobile' or 'tablet' keys are wrapped in the + * corresponding media queries instead of being appended to the selector. + * + * @param styles The styles object potentially containing responsive keys. + * @param selector The base CSS selector for the block. + * @param ruleset The accumulating CSS ruleset string. + * @param featureSelectors Optional feature-level selectors for the block. + * @param treeSettings Global styles settings tree. + * @param styleVariationSelector Optional style variation selector. + * @return Updated ruleset string with responsive CSS rules appended. + */ +function appendResponsiveStyles( + styles: Record< string, any >, + selector: string, + ruleset: string, + featureSelectors: + | string + | Record< string, string | Record< string, string > > + | undefined, + treeSettings: Record< string, any > | undefined, + styleVariationSelector?: string +): string { + const responsiveStyles = Object.entries( styles ).filter( ( [ key ] ) => + Object.prototype.hasOwnProperty.call( RESPONSIVE_BREAKPOINTS, key ) + ); + + if ( ! responsiveStyles.length ) { + return ruleset; + } + + responsiveStyles.forEach( ( [ breakpointKey, breakpointStyle ] ) => { + if ( ! breakpointStyle || typeof breakpointStyle !== 'object' ) { + return; + } + + const mediaQuery = RESPONSIVE_BREAKPOINTS[ breakpointKey ]; + const remainingBreakpointStyles = JSON.parse( + JSON.stringify( breakpointStyle ) + ); + + if ( featureSelectors && typeof featureSelectors !== 'string' ) { + let breakpointFeatureDeclarations = getFeatureDeclarations( + featureSelectors, + remainingBreakpointStyles + ); + + breakpointFeatureDeclarations = updateParagraphTextIndentSelector( + breakpointFeatureDeclarations, + treeSettings, + undefined + ); + + breakpointFeatureDeclarations = updateButtonWidthDeclarations( + breakpointFeatureDeclarations, + treeSettings + ); + + Object.entries( breakpointFeatureDeclarations ).forEach( + ( [ baseSelector, declarations ] ) => { + if ( ! declarations.length ) { + return; + } + const cssSelector = styleVariationSelector + ? concatFeatureVariationSelectorString( + baseSelector, + styleVariationSelector + ) + : baseSelector; + const rules = declarations.join( ';' ); + ruleset += `${ mediaQuery }{:root :where(${ cssSelector }){${ rules };}}`; + } + ); + } + + const breakpointDeclarations = getStylesDeclarations( + remainingBreakpointStyles + ); + + if ( ! breakpointDeclarations.length ) { + return; + } + + const cssSelector = styleVariationSelector + ? concatFeatureVariationSelectorString( + selector, + styleVariationSelector + ) + : selector; + ruleset += `${ mediaQuery }{:root :where(${ cssSelector }){${ breakpointDeclarations.join( + ';' + ) };}}`; + } ); + + return ruleset; +} + export const getNodesWithStyles = ( tree: GlobalStylesConfig, blockSelectors: string | BlockSelectors @@ -1682,6 +1797,15 @@ export const transformToStyles = ( styleVariationSelector as string ); + ruleset = appendResponsiveStyles( + styleVariations, + styleVariationSelector as string, + ruleset, + featureSelectors, + tree.settings, + styleVariationSelector as string + ); + // Generate layout styles for the variation if it supports layout and has blockGap defined. if ( hasLayoutSupport && @@ -1711,6 +1835,14 @@ export const transformToStyles = ( tree.settings, name ); + + ruleset = appendResponsiveStyles( + styles, + selector, + ruleset, + featureSelectors, + tree.settings + ); } ); } diff --git a/packages/global-styles-ui/src/hooks.ts b/packages/global-styles-ui/src/hooks.ts index b97be534c15cfb..3806837c634983 100644 --- a/packages/global-styles-ui/src/hooks.ts +++ b/packages/global-styles-ui/src/hooks.ts @@ -53,6 +53,12 @@ export function useStyle< T = any >( state?: string ) { const { user, base, merged, onChange } = useContext( GlobalStylesContext ); + const isPseudoSelectorState = state?.startsWith( ':' ); + const pseudoSelectorState = isPseudoSelectorState ? state : undefined; + const stylePath = + state && ! isPseudoSelectorState + ? [ path, state ].filter( Boolean ).join( '.' ) + : path; let sourceValue = merged; if ( readFrom === 'base' ) { @@ -61,23 +67,33 @@ export function useStyle< T = any >( sourceValue = user; } - const styleValue = useMemo( () => { + const styleValue = useMemo< T | undefined >( () => { const rawValue = getStyle< T >( sourceValue, - path, + stylePath, blockName, shouldDecodeEncode ); - if ( state ) { - return ( rawValue as any )?.[ state ] ?? {}; + if ( pseudoSelectorState ) { + return ( + ( rawValue as Record< string, T | undefined > )?.[ + pseudoSelectorState + ] ?? ( {} as T ) + ); } return rawValue; - }, [ sourceValue, path, blockName, shouldDecodeEncode, state ] ); + }, [ + sourceValue, + stylePath, + blockName, + shouldDecodeEncode, + pseudoSelectorState, + ] ); const setStyleValue = useCallback( ( newValue: T | undefined ) => { let valueToSet: any = newValue; - if ( state ) { + if ( pseudoSelectorState ) { const fullCurrentValue = getStyle( user, path, @@ -86,18 +102,18 @@ export function useStyle< T = any >( ); valueToSet = { ...( fullCurrentValue as object ), - [ state ]: newValue, + [ pseudoSelectorState ]: newValue, }; } const newGlobalStyles = setStyle< any >( user, - path, + stylePath, valueToSet, blockName ); onChange( newGlobalStyles ); }, - [ user, onChange, path, blockName, state ] + [ user, onChange, path, stylePath, blockName, pseudoSelectorState ] ); return [ styleValue, setStyleValue ] as const; diff --git a/packages/global-styles-ui/src/utils.ts b/packages/global-styles-ui/src/utils.ts index 47b41411a1e754..50dc51334e3cd7 100644 --- a/packages/global-styles-ui/src/utils.ts +++ b/packages/global-styles-ui/src/utils.ts @@ -51,6 +51,15 @@ export const VALID_BLOCK_STATES: Record< string, StateDefinition[] > = { ], }; +/** + * Responsive breakpoint states available for all blocks. + * These map to CSS media queries wrapping the block's styles. + */ +export const RESPONSIVE_STATES: StateDefinition[] = [ + { value: 'mobile', label: __( 'Mobile' ) }, + { value: 'tablet', label: __( 'Tablet' ) }, +]; + /** * Get the valid states for a given block or element. * @@ -58,9 +67,11 @@ export const VALID_BLOCK_STATES: Record< string, StateDefinition[] > = { * @return Array of valid state definitions, or empty array if none */ export function getValidStates( name: string ): StateDefinition[] { - // Check if it's a block - if ( VALID_BLOCK_STATES[ name ] ) { - return VALID_BLOCK_STATES[ name ]; + // Check if it's a block (contains a slash, e.g. 'core/button'). + // All blocks receive responsive states by default. + if ( name.includes( '/' ) ) { + const blockPseudoStates = VALID_BLOCK_STATES[ name ] ?? []; + return [ ...blockPseudoStates, ...RESPONSIVE_STATES ]; } // Check if it's an element diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 6f6f077d0cba8b..a259842fb9a8b8 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -1863,6 +1863,21 @@ "stylesBlocksPseudoSelectorsPropertyNames": { "enum": [ ":hover", ":focus", ":focus-visible", ":active" ] }, + "stylesBlocksResponsiveSelectorsProperties": { + "description": "Responsive block states keyed by breakpoint name. Each breakpoint supports the same style properties as the default block state.", + "type": "object", + "properties": { + "mobile": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + "tablet": { + "$ref": "#/definitions/stylesPropertiesComplete" + } + } + }, + "stylesBlocksResponsiveSelectorsPropertyNames": { + "enum": [ "mobile", "tablet" ] + }, "stylesElementsPseudoSelectorsProperties": { "type": "object", "properties": { @@ -2039,6 +2054,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "type": "object", "properties": { @@ -2050,6 +2068,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" }, @@ -2060,6 +2081,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsPropertyNames" } @@ -2085,6 +2109,9 @@ { "enum": [ "variations" ] }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsPropertyNames" } @@ -2224,6 +2251,9 @@ { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" }, @@ -2416,6 +2446,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "type": "object", "properties": { @@ -2434,6 +2467,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, { "enum": [ "elements", "variations" ] } @@ -2555,6 +2591,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" }, @@ -2565,6 +2604,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsPropertyNames" } @@ -2886,6 +2928,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "type": "object", "properties": { @@ -2901,6 +2946,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, { "enum": [ "elements" ] } From 7a887ff22a678d134477f0dc37789fdbd091d8d8 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Wed, 22 Apr 2026 13:17:25 +1000 Subject: [PATCH 02/26] rules for specific selectors --- lib/class-wp-theme-json-gutenberg.php | 60 +++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index d4611bdc71fdd5..114f4a29d1dc0a 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -3352,12 +3352,6 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $style_variation_custom_css[ $style_variation['selector'] ] = $this->process_blocks_custom_css( $style_variation_node['css'], $style_variation['selector'] ); } - // Store responsive breakpoint CSS for the style variation. - $variation_responsive_css = static::process_responsive_selectors( $style_variation_node, $style_variation['selector'], $settings ); - if ( ! empty( $variation_responsive_css ) ) { - $style_variation_responsive_css[ $style_variation['selector'] ] = $variation_responsive_css; - } - // Store variation metadata and node for layout styles generation. // Only store if the variation has blockGap defined. if ( isset( $style_variation_node['spacing']['blockGap'] ) ) { @@ -3369,9 +3363,41 @@ 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 ) { + $feature_ruleset = static::to_ruleset( ':root :where(' . $feature_selector . ')', $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 . '}'; + } + } + + if ( ! empty( $variation_responsive_css ) ) { + $style_variation_responsive_css[ $style_variation['selector'] ] = $variation_responsive_css; + } } } - /* * Get a reference to element name from path. * $block_metadata['path'] = array( 'styles','elements','link' ); @@ -3552,6 +3578,26 @@ static function ( $pseudo_selector ) use ( $selector ) { // 8. Generate and append responsive breakpoint rules. if ( ! $is_root_selector ) { + $responsive_feature_css = ''; + + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( ! isset( $node[ $breakpoint ] ) ) { + continue; + } + + $breakpoint_node = $node[ $breakpoint ]; + $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $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 => $individual_feature_declarations ) { + $feature_ruleset = static::to_ruleset( ":root :where($feature_selector)", $individual_feature_declarations ); + $responsive_feature_css .= $breakpoint_media . '{' . $feature_ruleset . '}'; + } + } + + $block_rules .= $responsive_feature_css; $block_rules .= static::process_responsive_selectors( $node, $selector, $settings ); } From 2817ed4b02a24bcdc247797c7f02ee3065ebc5ea Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Wed, 22 Apr 2026 16:31:47 +1000 Subject: [PATCH 03/26] responsive custom CSS support --- lib/class-wp-theme-json-gutenberg.php | 33 +++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 114f4a29d1dc0a..392856b5db8762 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -3391,6 +3391,12 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $base_ruleset = static::to_ruleset( ':root :where(' . $style_variation['selector'] . ')', $breakpoint_declarations ); $variation_responsive_css .= $breakpoint_media . '{' . $base_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 . '}'; + } } if ( ! empty( $variation_responsive_css ) ) { @@ -3566,13 +3572,15 @@ static function ( $pseudo_selector ) use ( $selector ) { } } + // Compute selector for block custom CSS. + $css_feature_selector = $block_metadata['selectors']['css'] ?? null; + if ( is_array( $css_feature_selector ) ) { + $css_feature_selector = $css_feature_selector['root'] ?? null; + } + $css_selector = is_string( $css_feature_selector ) ? $css_feature_selector : $selector; + // 7. Generate and append any custom CSS rules. if ( isset( $node['css'] ) && ! $is_root_selector ) { - $css_feature_selector = $block_metadata['selectors']['css'] ?? null; - if ( is_array( $css_feature_selector ) ) { - $css_feature_selector = $css_feature_selector['root'] ?? null; - } - $css_selector = is_string( $css_feature_selector ) ? $css_feature_selector : $selector; $block_rules .= $this->process_blocks_custom_css( $node['css'], $css_selector ); } @@ -3595,6 +3603,11 @@ static function ( $pseudo_selector ) use ( $selector ) { $feature_ruleset = static::to_ruleset( ":root :where($feature_selector)", $individual_feature_declarations ); $responsive_feature_css .= $breakpoint_media . '{' . $feature_ruleset . '}'; } + + if ( isset( $breakpoint_node['css'] ) ) { + $breakpoint_custom_css = static::process_blocks_custom_css( $breakpoint_node['css'], $css_selector ); + $responsive_feature_css .= $breakpoint_media . '{' . $breakpoint_custom_css . '}'; + } } $block_rules .= $responsive_feature_css; @@ -4125,6 +4138,11 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { if ( isset( $input[ $breakpoint ] ) ) { $output[ $breakpoint ] = static::remove_insecure_styles( $input[ $breakpoint ] ); + + // 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']; + } } } @@ -4153,6 +4171,11 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { if ( isset( $variation_input[ $breakpoint ] ) ) { $variation_output[ $breakpoint ] = static::remove_insecure_styles( $variation_input[ $breakpoint ] ); + + // 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']; + } } } From a97f742cd80b01ce7e501c45bc64e2e0d567618a Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 11:44:51 +1000 Subject: [PATCH 04/26] styles for block elements --- lib/class-wp-theme-json-gutenberg.php | 144 +++++++++++++- phpunit/class-wp-theme-json-test.php | 110 +++++++++++ schemas/json/theme.json | 269 ++++++++++++++++++++++++-- 3 files changed, 510 insertions(+), 13 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 392856b5db8762..ecb3c2fe1d3638 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -1111,6 +1111,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(); @@ -1133,7 +1138,8 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n // 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 ] = $styles_non_top_level; + $schema_styles_blocks[ $block ][ $breakpoint_state ]['elements'] = $schema_styles_elements; } // Add pseudo-selectors for blocks that support them. @@ -1184,7 +1190,9 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n // 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 ] = $styles_non_top_level; + $variation_schema[ $breakpoint_state ]['elements'] = $schema_styles_elements; + $variation_schema[ $breakpoint_state ]['blocks'] = $schema_styles_blocks; } // Add pseudo-selectors to variations for blocks that support them. @@ -3292,6 +3300,8 @@ public function get_styles_for_block( $block_metadata ) { // 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 ); + $blocks_metadata = static::get_blocks_metadata(); + $block_elements = $block_name && isset( $blocks_metadata[ $block_name ]['elements'] ) ? $blocks_metadata[ $block_name ]['elements'] : array(); // Update button width declarations for percentage values to use calc() with block gap. $feature_declarations = static::update_button_width_declarations( $feature_declarations, $settings ); @@ -3397,6 +3407,53 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $breakpoint_custom_css = static::process_blocks_custom_css( $breakpoint_node['css'], $style_variation['selector'] ); $variation_responsive_css .= $breakpoint_media . '{' . $breakpoint_custom_css . '}'; } + + // 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 ) ) { @@ -3614,6 +3671,59 @@ static function ( $pseudo_selector ) use ( $selector ) { $block_rules .= static::process_responsive_selectors( $node, $selector, $settings ); } + // 9. When processing a block element node, emit responsive breakpoint overrides for + // that element immediately after its default styles. This ensures media queries always + // follow the default rules they are meant to override. + $path = $block_metadata['path']; + $path_count = count( $path ); + + $is_block_element_node = $is_processing_element + && $path_count >= 5 + && 'elements' === $path[ $path_count - 2 ] + && in_array( 'blocks', $path, true ); + + if ( $is_block_element_node ) { + $parent_block_path = array_slice( $path, 0, $path_count - 2 ); + + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + $responsive_element_path = array_merge( $parent_block_path, array( $breakpoint, 'elements', $current_element ) ); + $responsive_element_node = _wp_array_get( $this->theme_json, $responsive_element_path, null ); + + if ( empty( $responsive_element_node ) ) { + continue; + } + + $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ]; + $element_declarations = static::compute_style_properties( $responsive_element_node, $settings, null, $this->theme_json ); + + if ( ! empty( $element_declarations ) ) { + $element_ruleset = static::to_ruleset( ':root :where(' . $selector . ')', $element_declarations ); + $block_rules .= $breakpoint_media . '{' . $element_ruleset . '}'; + } + + if ( isset( $responsive_element_node['css'] ) ) { + $element_custom_css = static::process_blocks_custom_css( $responsive_element_node['css'], $selector ); + $block_rules .= $breakpoint_media . '{' . $element_custom_css . '}'; + } + + if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] ) ) { + foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] as $pseudo_sel ) { + if ( ! isset( $responsive_element_node[ $pseudo_sel ] ) ) { + continue; + } + + $pseudo_declarations = static::compute_style_properties( $responsive_element_node[ $pseudo_sel ], $settings, null, $this->theme_json ); + if ( empty( $pseudo_declarations ) ) { + continue; + } + + $pseudo_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $selector, $pseudo_sel ) . ')', $pseudo_declarations ); + $block_rules .= $breakpoint_media . '{' . $pseudo_ruleset . '}'; + } + } + } + } + return $block_rules; } @@ -4139,6 +4249,14 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme 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'] ); + } + // 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']; @@ -4172,6 +4290,14 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme 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'] ); + } + // 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']; @@ -4239,6 +4365,13 @@ 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 ] ); + } + } + $sanitized[ $element_name ] = $element_output; } } @@ -4262,6 +4395,13 @@ 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 ] ); + } + } + $sanitized[ $block_type ] = $block_output; } return $sanitized; diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 3675fbc1dbb8ed..85bcfe105aa669 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -2736,6 +2736,116 @@ public function test_remove_insecure_properties_removes_unsafe_styles_sub_proper $this->assertEqualSetsWithIndex( $expected, $actual ); } + /** + * @covers WP_Theme_JSON_Gutenberg::remove_insecure_properties + */ + public function test_remove_insecure_properties_preserves_responsive_block_element_styles() { + $actual = WP_Theme_JSON_Gutenberg::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON_Gutenberg::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_Gutenberg::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_Gutenberg::remove_insecure_properties + */ + public function test_remove_insecure_properties_preserves_responsive_elements_within_block_state() { + $actual = WP_Theme_JSON_Gutenberg::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON_Gutenberg::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_Gutenberg::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 ); + } + public function test_remove_insecure_properties_removes_non_preset_settings() { $actual = WP_Theme_JSON_Gutenberg::remove_insecure_properties( array( diff --git a/schemas/json/theme.json b/schemas/json/theme.json index a259842fb9a8b8..6171b845521cd3 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -1932,6 +1932,21 @@ ":visited" ] }, + "stylesElementsResponsiveSelectorsProperties": { + "description": "Responsive element states keyed by breakpoint name. Each breakpoint supports the same style properties as the default element state.", + "type": "object", + "properties": { + "mobile": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + "tablet": { + "$ref": "#/definitions/stylesPropertiesComplete" + } + } + }, + "stylesElementsResponsiveSelectorsPropertyNames": { + "enum": [ "mobile", "tablet" ] + }, "stylesElementsPropertiesComplete": { "description": "Styles defined on a per-element basis using the element's selector.", "type": "object", @@ -1941,6 +1956,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, { "$ref": "#/definitions/stylesElementsPseudoSelectorsProperties" }, @@ -1951,6 +1969,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + }, { "$ref": "#/definitions/stylesElementsPseudoSelectorsPropertyNames" } @@ -1964,6 +1985,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, { "$ref": "#/definitions/stylesElementsPseudoSelectorsProperties" }, @@ -1974,6 +1998,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + }, { "$ref": "#/definitions/stylesElementsPseudoSelectorsPropertyNames" } @@ -1983,37 +2010,257 @@ ] }, "heading": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h1": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h2": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h3": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h4": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h5": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h6": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "caption": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "cite": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "select": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "textInput": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] } }, "additionalProperties": false From 64d4cac4642a5a39adf0120aab71d65f1384314b Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 14:40:18 +1000 Subject: [PATCH 05/26] account for block gap --- lib/class-wp-theme-json-gutenberg.php | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index ecb3c2fe1d3638..87b771d77c0bee 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -1998,6 +1998,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; } @@ -3408,6 +3413,19 @@ static function ( $split_selector ) use ( $clean_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 ) { @@ -3665,6 +3683,17 @@ static function ( $pseudo_selector ) use ( $selector ) { $breakpoint_custom_css = static::process_blocks_custom_css( $breakpoint_node['css'], $css_selector ); $responsive_feature_css .= $breakpoint_media . '{' . $breakpoint_custom_css . '}'; } + + // Process blockGap responsive layout styles. + if ( ! empty( $block_metadata['name'] ) ) { + $responsive_feature_css .= $this->get_layout_styles( + $block_metadata, + array( + 'node' => $breakpoint_node, + 'media_query' => $breakpoint_media, + ) + ); + } } $block_rules .= $responsive_feature_css; From 4c321f2907fcd28f09def1ea9509a97bca5b5d95 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 15:15:35 +1000 Subject: [PATCH 06/26] improve hover style output --- lib/class-wp-theme-json-gutenberg.php | 61 ++++++++++++++++----------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 87b771d77c0bee..4b478b94e4dd8a 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -3700,9 +3700,16 @@ static function ( $pseudo_selector ) use ( $selector ) { $block_rules .= static::process_responsive_selectors( $node, $selector, $settings ); } - // 9. When processing a block element node, emit responsive breakpoint overrides for - // that element immediately after its default styles. This ensures media queries always - // follow the default rules they are meant to override. + // 9. When processing a block element node, emit responsive breakpoint overrides + // immediately after that node's default styles so media queries always follow the + // non-media rules they override. + // + // Each element has two node passes in the style_nodes list: + // (a) base node (selector = "a") → emits base responsive styles only + // (b) pseudo node (selector = "a:hover") → emits that pseudo's responsive styles only + // + // Splitting the work this way preserves cascade order: + // a {} → @media{ a{} } → a:hover {} → @media{ a:hover{} } $path = $block_metadata['path']; $path_count = count( $path ); @@ -3722,32 +3729,36 @@ static function ( $pseudo_selector ) use ( $selector ) { continue; } - $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ]; - $element_declarations = static::compute_style_properties( $responsive_element_node, $settings, null, $this->theme_json ); + $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ]; - if ( ! empty( $element_declarations ) ) { - $element_ruleset = static::to_ruleset( ':root :where(' . $selector . ')', $element_declarations ); - $block_rules .= $breakpoint_media . '{' . $element_ruleset . '}'; - } - - if ( isset( $responsive_element_node['css'] ) ) { - $element_custom_css = static::process_blocks_custom_css( $responsive_element_node['css'], $selector ); - $block_rules .= $breakpoint_media . '{' . $element_custom_css . '}'; - } + if ( $pseudo_selector ) { + // Pseudo-selector node: only emit styles for this specific pseudo-state. + if ( ! isset( $responsive_element_node[ $pseudo_selector ] ) ) { + continue; + } - if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] ) ) { - foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] as $pseudo_sel ) { - if ( ! isset( $responsive_element_node[ $pseudo_sel ] ) ) { - continue; - } + $pseudo_declarations = static::compute_style_properties( $responsive_element_node[ $pseudo_selector ], $settings, null, $this->theme_json ); + if ( ! empty( $pseudo_declarations ) ) { + $pseudo_ruleset = static::to_ruleset( ':root :where(' . $selector . ')', $pseudo_declarations ); + $block_rules .= $breakpoint_media . '{' . $pseudo_ruleset . '}'; + } - $pseudo_declarations = static::compute_style_properties( $responsive_element_node[ $pseudo_sel ], $settings, null, $this->theme_json ); - if ( empty( $pseudo_declarations ) ) { - continue; - } + if ( isset( $responsive_element_node[ $pseudo_selector ]['css'] ) ) { + $pseudo_css = static::process_blocks_custom_css( $responsive_element_node[ $pseudo_selector ]['css'], $selector ); + $block_rules .= $breakpoint_media . '{' . $pseudo_css . '}'; + } + } else { + // Base element node: only emit base responsive styles. + // Pseudo-selector responsive styles are handled by their own node pass above. + $element_declarations = static::compute_style_properties( $responsive_element_node, $settings, null, $this->theme_json ); + if ( ! empty( $element_declarations ) ) { + $element_ruleset = static::to_ruleset( ':root :where(' . $selector . ')', $element_declarations ); + $block_rules .= $breakpoint_media . '{' . $element_ruleset . '}'; + } - $pseudo_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $selector, $pseudo_sel ) . ')', $pseudo_declarations ); - $block_rules .= $breakpoint_media . '{' . $pseudo_ruleset . '}'; + if ( isset( $responsive_element_node['css'] ) ) { + $element_custom_css = static::process_blocks_custom_css( $responsive_element_node['css'], $selector ); + $block_rules .= $breakpoint_media . '{' . $element_custom_css . '}'; } } } From 4a4d6a6658c0372521c3cd67eab247b88ec46475 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 15:48:04 +1000 Subject: [PATCH 07/26] fix duplicate selector styles --- lib/class-wp-theme-json-gutenberg.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 4b478b94e4dd8a..c61fbfe2f2bd4a 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -3679,6 +3679,14 @@ static function ( $pseudo_selector ) use ( $selector ) { $responsive_feature_css .= $breakpoint_media . '{' . $feature_ruleset . '}'; } + // Emit base responsive declarations using the already-stripped $breakpoint_node + // (get_feature_declarations_for_node removes feature props by reference, so only + // non-feature properties remain here, matching non-responsive cascade behaviour). + $breakpoint_declarations = static::compute_style_properties( $breakpoint_node, $settings, null, null ); + if ( ! empty( $breakpoint_declarations ) ) { + $responsive_feature_css .= $breakpoint_media . '{' . static::to_ruleset( ":root :where($selector)", $breakpoint_declarations ) . '}'; + } + if ( isset( $breakpoint_node['css'] ) ) { $breakpoint_custom_css = static::process_blocks_custom_css( $breakpoint_node['css'], $css_selector ); $responsive_feature_css .= $breakpoint_media . '{' . $breakpoint_custom_css . '}'; @@ -3697,7 +3705,6 @@ static function ( $pseudo_selector ) use ( $selector ) { } $block_rules .= $responsive_feature_css; - $block_rules .= static::process_responsive_selectors( $node, $selector, $settings ); } // 9. When processing a block element node, emit responsive breakpoint overrides From 208b381a1124422a672e0154d684803d7eec5096 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 16:38:26 +1000 Subject: [PATCH 08/26] add docs --- .../themes/global-settings-and-styles.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/docs/how-to-guides/themes/global-settings-and-styles.md b/docs/how-to-guides/themes/global-settings-and-styles.md index 02b93d245735c2..86e4cb7088ce11 100644 --- a/docs/how-to-guides/themes/global-settings-and-styles.md +++ b/docs/how-to-guides/themes/global-settings-and-styles.md @@ -1058,6 +1058,83 @@ Pseudo selectors `:hover`, `:focus`, `:focus-visible`, `:visited`, `:active`, `: } ``` +#### Responsive styles + +Block styles can be scoped to two named breakpoints: `mobile` and `tablet`. Any style property that is valid at the block or element level can be nested under one of these keys. + +| Key | Media query applied | +| --- | --- | +| `mobile` | `@media (width <= 480px)` | +| `tablet` | `@media (480px < width <= 782px)` | + +Responsive overrides can be placed directly on a block node: + +```json +{ + "version": 3, + "styles": { + "blocks": { + "core/group": { + "color": { + "text": "black" + }, + "mobile": { + "color": { + "text": "hotpink" + } + } + } + } + } +} +``` + +```css +:root :where(.wp-block-group) { color: black; } +@media (width <= 480px) { :root :where(.wp-block-group) { color: hotpink; } } +``` + +They can also be placed on element nodes within a block: + +```json +{ + "version": 3, + "styles": { + "blocks": { + "core/group": { + "elements": { + "link": { + "color": { "text": "blue" }, + ":hover": { + "color": { "text": "navy" } + } + } + }, + "mobile": { + "elements": { + "link": { + "color": { "text": "red" }, + ":hover": { + "color": { "text": "darkred" } + } + } + } + } + } + } + } +} +``` + +```css +:root :where(.wp-block-group a) { color: blue; } +@media (width <= 480px) { :root :where(.wp-block-group a) { color: red; } } +:root :where(.wp-block-group a:hover) { color: navy; } +@media (width <= 480px) { :root :where(.wp-block-group a:hover) { color: darkred; } } +``` + +Responsive overrides are always output after the default styles they override, so the cascade order is preserved without needing to increase specificity. + #### Variations A block can have a "style variation," as defined in the [block.json specification](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/#styles-optional). Theme authors can define the style attributes for an existing style variation using the `theme.json` file. Styles for unregistered style variations will be ignored. From 272e57b37e4cefdbdea4057129f29951839d76b0 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 16:44:36 +1000 Subject: [PATCH 09/26] add unit test for new function --- phpunit/class-wp-theme-json-test.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 85bcfe105aa669..8d1cf776b2caa8 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -957,6 +957,30 @@ public function test_get_styles_for_block_handles_whitelisted_element_pseudo_sel $this->assertSameCSS( $focus_style, $theme_json->get_styles_for_block( $focus_node ) ); } + public function test_process_responsive_selectors_outputs_media_wrapped_css() { + $node = array( + 'mobile' => array( + 'color' => array( + 'text' => 'red', + ), + ), + 'tablet' => array( + 'spacing' => array( + 'margin' => '1rem', + ), + ), + ); + + $reflection = new ReflectionMethod( WP_Theme_JSON_Gutenberg::class, 'process_responsive_selectors' ); + $reflection->setAccessible( true ); + + $actual = $reflection->invoke( null, $node, '.wp-block-group', array() ); + + $expected = '@media (width <= 480px){:root :where(.wp-block-group){color: red;}}@media (480px < width <= 782px){:root :where(.wp-block-group){margin: 1rem;}}'; + + $this->assertSameCSS( $expected, $actual ); + } + /** * Tests that if an element has nothing but pseudo selector styles, they are still output by get_stylesheet. */ From 656996e7b9d41c8579e5960cc6dd241a0bf167d5 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 17:03:39 +1000 Subject: [PATCH 10/26] Clean up and add tests --- lib/class-wp-theme-json-gutenberg.php | 31 --- phpunit/class-wp-theme-json-test.php | 271 ++++++++++++++++++++++++-- 2 files changed, 258 insertions(+), 44 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index c61fbfe2f2bd4a..2916b7dcdc29e4 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -772,37 +772,6 @@ private static function process_pseudo_selectors( $node, $base_selector, $settin return $pseudo_declarations; } - /** - * Returns CSS rules for responsive breakpoint states stored in a block node. - * Unlike pseudo-selectors, breakpoint styles are available for all blocks and - * are wrapped in CSS media queries rather than appended to the selector. - * - * @param array $node The block's styles node from theme.json. - * @param string $base_selector The base CSS selector for the block. - * @param array $settings The theme.json settings. - * @return string CSS rules string with media query wrappers. - */ - private static function process_responsive_selectors( $node, $base_selector, $settings ) { - $responsive_css = ''; - - foreach ( static::RESPONSIVE_BREAKPOINTS as $breakpoint_key => $media_query ) { - if ( ! isset( $node[ $breakpoint_key ] ) ) { - continue; - } - - $declarations = static::compute_style_properties( $node[ $breakpoint_key ], $settings, null, null ); - - if ( empty( $declarations ) ) { - continue; - } - - $inner_rule = static::to_ruleset( ":root :where($base_selector)", $declarations ); - $responsive_css .= $media_query . '{' . $inner_rule . '}'; - } - - return $responsive_css; - } - /** * Returns a class name by an element name. * diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 8d1cf776b2caa8..ba5dde84e420d2 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -957,28 +957,273 @@ public function test_get_styles_for_block_handles_whitelisted_element_pseudo_sel $this->assertSameCSS( $focus_style, $theme_json->get_styles_for_block( $focus_node ) ); } - public function test_process_responsive_selectors_outputs_media_wrapped_css() { - $node = array( - 'mobile' => array( - 'color' => array( - 'text' => 'red', + 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_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/responsive-feature' => array( + 'mobile' => array( + 'color' => array( + 'text' => 'red', + ), + ), + ), + ), ), + ) + ); + + $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', ), - 'tablet' => array( - 'spacing' => array( - 'margin' => '1rem', + ); + + $actual_styles = $theme_json->get_styles_for_block( $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_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::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', + ), + ), + ), + ), + ), + ) + ); + + $metadata = array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + ); + + $actual_styles = $theme_json->get_styles_for_block( $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_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::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_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'elements', 'link' ), + 'selector' => '.wp-block-group a:where(:not(.wp-element-button))', + ); + + $hover_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'elements', 'link' ), + 'selector' => '.wp-block-group a:where(:not(.wp-element-button)):hover', + ); + + $actual_styles = $theme_json->get_styles_for_block( $link_node ) . $theme_json->get_styles_for_block( $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_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::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', ), ), ); - $reflection = new ReflectionMethod( WP_Theme_JSON_Gutenberg::class, 'process_responsive_selectors' ); - $reflection->setAccessible( true ); + $actual_styles = $theme_json->get_styles_for_block( $metadata ); + + unregister_block_style( 'core/group', 'withGap' ); - $actual = $reflection->invoke( null, $node, '.wp-block-group', array() ); + $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;}'; - $expected = '@media (width <= 480px){:root :where(.wp-block-group){color: red;}}@media (480px < width <= 782px){:root :where(.wp-block-group){margin: 1rem;}}'; + $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 ) ); + } - $this->assertSameCSS( $expected, $actual ); + 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_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/tablet-only' => array( + 'tablet' => array( + 'color' => array( + 'text' => 'purple', + ), + ), + ), + ), + ), + ) + ); + + $metadata = array( + 'name' => 'test/tablet-only', + 'path' => array( 'styles', 'blocks', 'test/tablet-only' ), + 'selector' => '.wp-block-test-tablet-only', + ); + + $actual_styles = $theme_json->get_styles_for_block( $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 ); } /** From 9a6730eddf2be81185545af20a8f4f7c19faa619 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Fri, 24 Apr 2026 11:23:23 +1000 Subject: [PATCH 11/26] simplify metadata Co-authored-by: Copilot --- lib/class-wp-theme-json-gutenberg.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 2916b7dcdc29e4..77d56ba472af5e 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -3132,6 +3132,7 @@ 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, 'variations' => $variation_selectors, 'css' => $selector, @@ -3164,6 +3165,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt 'path' => array( 'styles', 'blocks', $name, $pseudo_selector ), 'selector' => static::append_to_selector( $selector, $pseudo_selector ), 'selectors' => $pseudo_feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), 'duotone' => $duotone_selector, 'variations' => $variation_selectors, 'css' => static::append_to_selector( $selector, $pseudo_selector ), @@ -3185,6 +3187,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt '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, @@ -3200,6 +3203,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt '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, @@ -3274,8 +3278,7 @@ public function get_styles_for_block( $block_metadata ) { // 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 ); - $blocks_metadata = static::get_blocks_metadata(); - $block_elements = $block_name && isset( $blocks_metadata[ $block_name ]['elements'] ) ? $blocks_metadata[ $block_name ]['elements'] : array(); + $block_elements = $block_metadata['elements'] ?? array(); // Update button width declarations for percentage values to use calc() with block gap. $feature_declarations = static::update_button_width_declarations( $feature_declarations, $settings ); From 65ecacb269403c2b142a8f3ea3425a6597231bee Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Fri, 24 Apr 2026 17:31:12 +1000 Subject: [PATCH 12/26] fix feature element and style variation combo Co-authored-by: Copilot --- lib/class-wp-theme-json-gutenberg.php | 52 ++++++++++++---- .../global-styles-engine/src/core/render.tsx | 61 +++++++++++++++---- 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 77d56ba472af5e..3316abcbc6dacb 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -3313,15 +3313,24 @@ public function get_styles_for_block( $block_metadata ) { $clean_current_selector = preg_replace( '/,\s+/', ',', $current_selector ); $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_current_selector ); - // 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 ); + if ( $block_metadata['selector'] && ! str_contains( $clean_current_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 ); + } // Add the new declarations to the overall results under the modified selector. $style_variation_declarations[ $combined_selectors ] = $new_declarations; @@ -3362,13 +3371,34 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $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 ) { - $feature_ruleset = static::to_ruleset( ':root :where(' . $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 . '}'; } diff --git a/packages/global-styles-engine/src/core/render.tsx b/packages/global-styles-engine/src/core/render.tsx index 5f4d786451db40..ff3ed990c11d11 100644 --- a/packages/global-styles-engine/src/core/render.tsx +++ b/packages/global-styles-engine/src/core/render.tsx @@ -1001,6 +1001,8 @@ function appendPseudoSelectorStyles( * @param featureSelectors Optional feature-level selectors for the block. * @param treeSettings Global styles settings tree. * @param styleVariationSelector Optional style variation selector. + * @param blockRootSelector Optional block root selector used to detect block-level feature selectors. + * @param styleVariationName Optional variation name used when applying variation class to block-level feature selectors. * @return Updated ruleset string with responsive CSS rules appended. */ function appendResponsiveStyles( @@ -1012,7 +1014,9 @@ function appendResponsiveStyles( | Record< string, string | Record< string, string > > | undefined, treeSettings: Record< string, any > | undefined, - styleVariationSelector?: string + styleVariationSelector?: string, + blockRootSelector?: string, + styleVariationName?: string ): string { const responsiveStyles = Object.entries( styles ).filter( ( [ key ] ) => Object.prototype.hasOwnProperty.call( RESPONSIVE_BREAKPOINTS, key ) @@ -1054,12 +1058,28 @@ function appendResponsiveStyles( if ( ! declarations.length ) { return; } - const cssSelector = styleVariationSelector - ? concatFeatureVariationSelectorString( - baseSelector, - styleVariationSelector - ) - : baseSelector; + let cssSelector: string; + if ( ! styleVariationSelector ) { + cssSelector = baseSelector; + } else if ( + blockRootSelector && + styleVariationName && + ! baseSelector.includes( blockRootSelector ) + ) { + /* + * Feature selector is block-level (e.g. `.wp-block-button` for + * dimensions/width) — apply the variation class directly to it. + */ + cssSelector = getBlockStyleVariationSelector( + styleVariationName, + baseSelector + ); + } else { + cssSelector = concatFeatureVariationSelectorString( + baseSelector, + styleVariationSelector + ); + } const rules = declarations.join( ';' ); ruleset += `${ mediaQuery }{:root :where(${ cssSelector }){${ rules };}}`; } @@ -1754,11 +1774,26 @@ export const transformToStyles = ( string[], ] ) => { if ( declarations.length ) { + /* + * If the feature selector does not include the block's + * root selector (e.g. core/button dimensions width uses + * `.wp-block-button` while root is + * `.wp-block-button .wp-block-button__link`), apply the + * variation class directly to the feature selector. + */ const cssSelector = - concatFeatureVariationSelectorString( - baseSelector, - styleVariationSelector as string - ); + ! selector || + baseSelector.includes( + selector + ) + ? concatFeatureVariationSelectorString( + baseSelector, + styleVariationSelector as string + ) + : getBlockStyleVariationSelector( + styleVariationName, + baseSelector + ); const rules = declarations.join( ';' ); ruleset += `:root :where(${ cssSelector }){${ rules };}`; @@ -1803,7 +1838,9 @@ export const transformToStyles = ( ruleset, featureSelectors, tree.settings, - styleVariationSelector as string + styleVariationSelector as string, + selector, + styleVariationName ); // Generate layout styles for the variation if it supports layout and has blockGap defined. From 03610a1bc4e29ae97505bbce94206572589c40ad Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 28 Apr 2026 14:53:07 +1000 Subject: [PATCH 13/26] Hide settings that don't change per breakpoint Co-authored-by: Copilot --- packages/global-styles-ui/src/screen-block.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/global-styles-ui/src/screen-block.tsx b/packages/global-styles-ui/src/screen-block.tsx index 9d80d21e5f94c4..3ad0b572888de2 100644 --- a/packages/global-styles-ui/src/screen-block.tsx +++ b/packages/global-styles-ui/src/screen-block.tsx @@ -263,9 +263,14 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { // If there are settings changes, we need to update both styles and // settings atomically to avoid race conditions. if ( newSettings?.typography ) { + // Build the state-aware path so that breakpoint styles (e.g. mobile) + // are written to the correct sub-path and do not overwrite the default. + const stylePathForBreakpoint = [ prefix, stateParam ] + .filter( Boolean ) + .join( '.' ); let updatedConfig = setStyleHelper( userConfig, - prefix, + stylePathForBreakpoint, styleWithoutSettings, name ); @@ -366,7 +371,10 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { value={ style } onChange={ onChangeTypography } settings={ settings } - isGlobalStyles + // Only expose global-settings controls (e.g. "Indent all + // paragraphs") when not editing a breakpoint-specific state, + // because those settings are global and cannot be per-breakpoint. + isGlobalStyles={ selectedState === 'default' } /> ) } { hasDimensionsPanel && ( @@ -395,7 +403,7 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { includeLayoutControls /> ) } - { hasImageSettingsPanel && ( + { hasImageSettingsPanel && selectedState === 'default' && ( Date: Wed, 29 Apr 2026 14:19:41 +1000 Subject: [PATCH 14/26] support setting both pseudo states and viewports Co-authored-by: Copilot --- lib/class-wp-theme-json-gutenberg.php | 121 +++++++++++++++++- .../components/global-styles/state-control.js | 114 +++++++++++------ .../global-styles-engine/src/core/render.tsx | 29 ++++- packages/global-styles-ui/src/hooks.ts | 25 ++-- .../global-styles-ui/src/screen-block.tsx | 50 +++++--- .../global-styles-ui/src/screen-header.tsx | 29 +++-- packages/global-styles-ui/src/utils.ts | 27 +++- 7 files changed, 307 insertions(+), 88 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 3316abcbc6dacb..3b8d3e91490527 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -1109,6 +1109,12 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n 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. @@ -1162,6 +1168,12 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $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. @@ -3141,7 +3153,18 @@ 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 ) { - if ( isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ) ) { + // Create a node if default pseudo styles exist OR if any responsive breakpoint + // has pseudo styles — so the pseudo node is always present to handle cascade ordering. + $has_pseudo = isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ); + if ( ! $has_pseudo ) { + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $bp ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $bp ][ $pseudo_selector ] ) ) { + $has_pseudo = true; + break; + } + } + } + if ( $has_pseudo ) { /* * Append the pseudo-selector to each feature selector so that * get_feature_declarations_for_node generates CSS scoped to the @@ -3409,6 +3432,15 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $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'] ); @@ -3689,6 +3721,10 @@ static function ( $pseudo_selector ) use ( $selector ) { $responsive_feature_css .= $breakpoint_media . '{' . static::to_ruleset( ":root :where($selector)", $breakpoint_declarations ) . '}'; } + // Responsive pseudo-selector styles are emitted in section 8b below, + // after the pseudo node's default styles, to preserve cascade order: + // .block{} → @media{.block{}} → .block:hover{} → @media{.block:hover{}} + if ( isset( $breakpoint_node['css'] ) ) { $breakpoint_custom_css = static::process_blocks_custom_css( $breakpoint_node['css'], $css_selector ); $responsive_feature_css .= $breakpoint_media . '{' . $breakpoint_custom_css . '}'; @@ -3709,6 +3745,53 @@ static function ( $pseudo_selector ) use ( $selector ) { $block_rules .= $responsive_feature_css; } + // 8b. When processing a block pseudo-selector node (e.g. ':hover'), emit responsive + // breakpoint overrides immediately after the default pseudo styles so media queries + // always follow the non-media rules they override. + // + // Each pseudo-state has two node passes in the style_nodes list: + // (a) base node (selector = ".block") → emits base + base-responsive styles + // (b) pseudo node (selector = ".block:hover") → emits default pseudo + responsive pseudo + // + // Splitting the work this way preserves cascade order: + // .block{} → @media{.block{}} → .block:hover{} → @media{.block:hover{}} + $block_path = $block_metadata['path']; + $block_path_count = count( $block_path ); + + if ( + ! $is_root_selector + && ! $is_processing_element + && $block_path_count >= 4 + && in_array( 'blocks', $block_path, true ) + && $block_name + && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) + ) { + $pseudo_state_key = $block_path[ $block_path_count - 1 ]; + if ( in_array( $pseudo_state_key, static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ], true ) ) { + $parent_block_path = array_slice( $block_path, 0, $block_path_count - 1 ); + + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + $responsive_pseudo_path = array_merge( $parent_block_path, array( $breakpoint, $pseudo_state_key ) ); + $responsive_pseudo_node = _wp_array_get( $this->theme_json, $responsive_pseudo_path, null ); + + if ( empty( $responsive_pseudo_node ) ) { + continue; + } + + $breakpoint_media_8b = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ]; + $pseudo_bp_declarations = static::compute_style_properties( $responsive_pseudo_node, $settings, null, null ); + if ( ! empty( $pseudo_bp_declarations ) ) { + $block_rules .= $breakpoint_media_8b . '{' . static::to_ruleset( ":root :where($selector)", $pseudo_bp_declarations ) . '}'; + } + + if ( isset( $responsive_pseudo_node['css'] ) ) { + $pseudo_bp_custom_css = static::process_blocks_custom_css( $responsive_pseudo_node['css'], $selector ); + $block_rules .= $breakpoint_media_8b . '{' . $pseudo_bp_custom_css . '}'; + } + } + } + } + // 9. When processing a block element node, emit responsive breakpoint overrides // immediately after that node's default styles so media queries always follow the // non-media rules they override. @@ -4268,6 +4351,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; @@ -4306,6 +4393,14 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme $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']; @@ -4347,6 +4442,14 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme $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']; @@ -4418,6 +4521,14 @@ protected static function remove_insecure_element_styles( $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 ] ); + } + } + } } } @@ -4448,6 +4559,14 @@ protected static function remove_insecure_inner_block_styles( $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 ] ); + } + } + } } } diff --git a/packages/block-editor/src/components/global-styles/state-control.js b/packages/block-editor/src/components/global-styles/state-control.js index 34b87b1ffc9ffe..b126373373c78a 100644 --- a/packages/block-editor/src/components/global-styles/state-control.js +++ b/packages/block-editor/src/components/global-styles/state-control.js @@ -1,53 +1,59 @@ /** * WordPress dependencies */ -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { check, chevronDown } from '@wordpress/icons'; import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; /** - * State control for managing block state styles (hover, focus, etc.). - * Displays a dropdown menu to select between different states. + * State control for managing viewport and pseudo-state styles. + * Displays a dropdown menu with separate groups for each selector. * - * @param {Object} props Component props. - * @param {Array} props.states Array of available states with value and label. - * @param {string} props.value Currently selected state value. - * @param {Function} props.onChange Callback when selection changes. + * @param {Object} props Component props. + * @param {Array} props.viewportStates Array of available viewport states. + * @param {Array} props.pseudoStates Array of available pseudo states. + * @param {string} props.viewportValue Currently selected viewport value. + * @param {string} props.pseudoStateValue Currently selected pseudo state value. + * @param {Function} props.onChangeViewport Callback when viewport selection changes. + * @param {Function} props.onChangePseudoState Callback when pseudo state selection changes. * @return {Element|null} State control component. */ export default function StateControl( { - states = [], - value = 'default', - onChange, + viewportStates = [], + pseudoStates = [], + viewportValue = 'default', + pseudoStateValue = 'default', + onChangeViewport, + onChangePseudoState, } ) { - if ( ! states || states.length === 0 ) { + if ( ! viewportStates.length && ! pseudoStates.length ) { return null; } - const stateOptions = [ + const viewportOptions = [ { label: __( 'Default' ), value: 'default' }, - ...states.map( ( state ) => ( { + ...viewportStates.map( ( state ) => ( { + label: state.label, + value: state.value, + } ) ), + ]; + const pseudoStateOptions = [ + { label: __( 'Default' ), value: 'default' }, + ...pseudoStates.map( ( state ) => ( { label: state.label, value: state.value, } ) ), ]; - const getCurrentStateLabel = () => { - const currentOption = stateOptions.find( - ( option ) => option.value === value - ); - return currentOption?.label || __( 'Default' ); - }; + const hasViewportOptions = viewportStates.length > 0; + const hasPseudoStateOptions = pseudoStates.length > 0; + const triggerLabel = __( 'Properties' ); return ( { ( { onClose } ) => ( - - { stateOptions.map( ( option ) => ( - { - onChange( option.value ); - onClose(); - } } - icon={ value === option.value ? check : null } - > - { option.label } - - ) ) } - + <> + { hasViewportOptions && ( + + { viewportOptions.map( ( option ) => ( + { + onChangeViewport?.( option.value ); + if ( ! hasPseudoStateOptions ) { + onClose(); + } + } } + icon={ + viewportValue === option.value + ? check + : null + } + > + { option.label } + + ) ) } + + ) } + { hasPseudoStateOptions && ( + + { pseudoStateOptions.map( ( option ) => ( + { + onChangePseudoState?.( option.value ); + onClose(); + } } + icon={ + pseudoStateValue === option.value + ? check + : null + } + > + { option.label } + + ) ) } + + ) } + ) } ); diff --git a/packages/global-styles-engine/src/core/render.tsx b/packages/global-styles-engine/src/core/render.tsx index ff3ed990c11d11..cd53f2ddc47100 100644 --- a/packages/global-styles-engine/src/core/render.tsx +++ b/packages/global-styles-engine/src/core/render.tsx @@ -992,18 +992,20 @@ function appendPseudoSelectorStyles( /** * Appends CSS rules for responsive breakpoint states to a ruleset string. - * Block styles stored under 'mobile' or 'tablet' keys are wrapped in the - * corresponding media queries instead of being appended to the selector. + * Block styles stored under responsive keys (for example, 'mobile' and + * 'tablet') are wrapped in corresponding media queries instead of being + * appended directly to the selector. * * @param styles The styles object potentially containing responsive keys. * @param selector The base CSS selector for the block. * @param ruleset The accumulating CSS ruleset string. * @param featureSelectors Optional feature-level selectors for the block. * @param treeSettings Global styles settings tree. + * @param blockName Optional block name used to resolve valid pseudo selectors. * @param styleVariationSelector Optional style variation selector. * @param blockRootSelector Optional block root selector used to detect block-level feature selectors. * @param styleVariationName Optional variation name used when applying variation class to block-level feature selectors. - * @return Updated ruleset string with responsive CSS rules appended. + * @return Updated ruleset string with responsive base, feature-level, and pseudo-state CSS appended. */ function appendResponsiveStyles( styles: Record< string, any >, @@ -1014,6 +1016,7 @@ function appendResponsiveStyles( | Record< string, string | Record< string, string > > | undefined, treeSettings: Record< string, any > | undefined, + blockName?: string, styleVariationSelector?: string, blockRootSelector?: string, styleVariationName?: string @@ -1045,7 +1048,7 @@ function appendResponsiveStyles( breakpointFeatureDeclarations = updateParagraphTextIndentSelector( breakpointFeatureDeclarations, treeSettings, - undefined + blockName ); breakpointFeatureDeclarations = updateButtonWidthDeclarations( @@ -1103,6 +1106,20 @@ function appendResponsiveStyles( ruleset += `${ mediaQuery }{:root :where(${ cssSelector }){${ breakpointDeclarations.join( ';' ) };}}`; + + const breakpointPseudoRules = appendPseudoSelectorStyles( + remainingBreakpointStyles, + selector, + '', + featureSelectors, + treeSettings, + blockName, + styleVariationSelector + ); + + if ( breakpointPseudoRules ) { + ruleset += `${ mediaQuery }{${ breakpointPseudoRules }}`; + } } ); return ruleset; @@ -1838,6 +1855,7 @@ export const transformToStyles = ( ruleset, featureSelectors, tree.settings, + name, styleVariationSelector as string, selector, styleVariationName @@ -1878,7 +1896,8 @@ export const transformToStyles = ( selector, ruleset, featureSelectors, - tree.settings + tree.settings, + name ); } ); diff --git a/packages/global-styles-ui/src/hooks.ts b/packages/global-styles-ui/src/hooks.ts index 3806837c634983..526f5c109c9715 100644 --- a/packages/global-styles-ui/src/hooks.ts +++ b/packages/global-styles-ui/src/hooks.ts @@ -36,8 +36,9 @@ extend( [ a11yPlugin ] ); * @param blockName The name of the block, if applicable. * @param readFrom Which source to read from: "base" (theme), "user" (customizations), or "merged" (final result). * @param shouldDecodeEncode Whether to decode and encode the style value. - * @param state Optional pseudo-selector state (e.g. `:hover`, `:focus`). When provided, - * reads from and writes to the state sub-object automatically. + * @param state Optional style state path. Supports viewport states (e.g. `mobile`), + * pseudo-selector states (e.g. `:hover`) or both (e.g. `mobile.:hover`). + * Pseudo selectors are always read/written as nested keys. * @return An array containing the style value and a function to set the style * value. * @@ -53,12 +54,16 @@ export function useStyle< T = any >( state?: string ) { const { user, base, merged, onChange } = useContext( GlobalStylesContext ); - const isPseudoSelectorState = state?.startsWith( ':' ); - const pseudoSelectorState = isPseudoSelectorState ? state : undefined; - const stylePath = - state && ! isPseudoSelectorState - ? [ path, state ].filter( Boolean ).join( '.' ) - : path; + const statePathParts = state?.split( '.' ).filter( Boolean ) ?? []; + const pseudoSelectorState = statePathParts.find( ( value ) => + value.startsWith( ':' ) + ); + const statePathWithoutPseudo = statePathParts + .filter( ( value ) => ! value.startsWith( ':' ) ) + .join( '.' ); + const stylePath = [ path, statePathWithoutPseudo ] + .filter( Boolean ) + .join( '.' ); let sourceValue = merged; if ( readFrom === 'base' ) { @@ -96,7 +101,7 @@ export function useStyle< T = any >( if ( pseudoSelectorState ) { const fullCurrentValue = getStyle( user, - path, + stylePath, blockName, false ); @@ -113,7 +118,7 @@ export function useStyle< T = any >( ); onChange( newGlobalStyles ); }, - [ user, onChange, path, stylePath, blockName, pseudoSelectorState ] + [ user, onChange, stylePath, blockName, pseudoSelectorState ] ); return [ styleValue, setStyleValue ] as const; diff --git a/packages/global-styles-ui/src/screen-block.tsx b/packages/global-styles-ui/src/screen-block.tsx index 3ad0b572888de2..7957c41e5507fe 100644 --- a/packages/global-styles-ui/src/screen-block.tsx +++ b/packages/global-styles-ui/src/screen-block.tsx @@ -32,7 +32,7 @@ import { import { useStyle, useSetting } from './hooks'; import { GlobalStylesContext } from './context'; import { unlock } from './lock-unlock'; -import { getValidStates } from './utils'; +import { getValidPseudoStates, getValidViewportStates } from './utils'; // Initial control values. const BACKGROUND_BLOCK_DEFAULT_VALUES = { @@ -110,23 +110,36 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { const prefix = prefixParts.join( '.' ); // State selector state - const [ selectedState, setSelectedState ] = useState< string >( 'default' ); - const validStates = useMemo( () => getValidStates( name ), [ name ] ); + const [ selectedViewport, setSelectedViewport ] = + useState< string >( 'default' ); + const [ selectedPseudoState, setSelectedPseudoState ] = + useState< string >( 'default' ); + const validViewportStates = useMemo( + () => getValidViewportStates( name ), + [ name ] + ); + const validPseudoStates = useMemo( + () => getValidPseudoStates( name ), + [ name ] + ); - const stateParam = selectedState !== 'default' ? selectedState : undefined; + const stateParam = [ selectedViewport, selectedPseudoState ] + .filter( ( value ) => value !== 'default' ) + .join( '.' ); + const hasSelectedState = stateParam.length > 0; const [ style, setStyle ] = useStyle( prefix, name, 'user', false, - stateParam + hasSelectedState ? stateParam : undefined ); const [ inheritedStyle ] = useStyle( prefix, name, 'merged', false, - stateParam + hasSelectedState ? stateParam : undefined ); const [ userSettings ] = useSetting( '', name, 'user' ); @@ -263,14 +276,14 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { // If there are settings changes, we need to update both styles and // settings atomically to avoid race conditions. if ( newSettings?.typography ) { - // Build the state-aware path so that breakpoint styles (e.g. mobile) + // Build the state-aware path so that viewport styles (e.g. mobile) // are written to the correct sub-path and do not overwrite the default. - const stylePathForBreakpoint = [ prefix, stateParam ] + const stylePathForState = [ prefix, stateParam ] .filter( Boolean ) .join( '.' ); let updatedConfig = setStyleHelper( userConfig, - stylePathForBreakpoint, + stylePathForState, styleWithoutSettings, name ); @@ -330,15 +343,18 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { title={ variation ? currentBlockStyle?.label! : blockType?.title! } - states={ validStates } - selectedState={ selectedState } - onChangeState={ setSelectedState } + viewportStates={ validViewportStates } + pseudoStates={ validPseudoStates } + selectedViewport={ selectedViewport } + selectedPseudoState={ selectedPseudoState } + onChangeViewport={ setSelectedViewport } + onChangePseudoState={ setSelectedPseudoState } /> { hasVariationsPanel && (
@@ -372,9 +388,9 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { onChange={ onChangeTypography } settings={ settings } // Only expose global-settings controls (e.g. "Indent all - // paragraphs") when not editing a breakpoint-specific state, + // paragraphs") when not editing a state-specific variation, // because those settings are global and cannot be per-breakpoint. - isGlobalStyles={ selectedState === 'default' } + isGlobalStyles={ ! hasSelectedState } /> ) } { hasDimensionsPanel && ( @@ -403,7 +419,7 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { includeLayoutControls /> ) } - { hasImageSettingsPanel && selectedState === 'default' && ( + { hasImageSettingsPanel && ! hasSelectedState && ( void; - states?: StateDefinition[]; - selectedState?: string; - onChangeState?: ( value: string ) => void; + viewportStates?: StateDefinition[]; + pseudoStates?: StateDefinition[]; + selectedViewport?: string; + selectedPseudoState?: string; + onChangeViewport?: ( value: string ) => void; + onChangePseudoState?: ( value: string ) => void; } export function ScreenHeader( { title, description, onBack, - states, - selectedState = 'default', - onChangeState, + viewportStates, + pseudoStates, + selectedViewport = 'default', + selectedPseudoState = 'default', + onChangeViewport, + onChangePseudoState, }: ScreenHeaderProps ) { return ( @@ -65,9 +71,14 @@ export function ScreenHeader( { { title } diff --git a/packages/global-styles-ui/src/utils.ts b/packages/global-styles-ui/src/utils.ts index 50dc51334e3cd7..73041baca6c803 100644 --- a/packages/global-styles-ui/src/utils.ts +++ b/packages/global-styles-ui/src/utils.ts @@ -56,22 +56,20 @@ export const VALID_BLOCK_STATES: Record< string, StateDefinition[] > = { * These map to CSS media queries wrapping the block's styles. */ export const RESPONSIVE_STATES: StateDefinition[] = [ - { value: 'mobile', label: __( 'Mobile' ) }, { value: 'tablet', label: __( 'Tablet' ) }, + { value: 'mobile', label: __( 'Mobile' ) }, ]; /** - * Get the valid states for a given block or element. + * Get the valid pseudo states for a given block or element. * * @param name The block name (e.g., 'core/button') or element name (e.g., 'button') - * @return Array of valid state definitions, or empty array if none + * @return Array of valid pseudo state definitions, or empty array if none */ -export function getValidStates( name: string ): StateDefinition[] { +export function getValidPseudoStates( name: string ): StateDefinition[] { // Check if it's a block (contains a slash, e.g. 'core/button'). - // All blocks receive responsive states by default. if ( name.includes( '/' ) ) { - const blockPseudoStates = VALID_BLOCK_STATES[ name ] ?? []; - return [ ...blockPseudoStates, ...RESPONSIVE_STATES ]; + return VALID_BLOCK_STATES[ name ] ?? []; } // Check if it's an element @@ -82,6 +80,21 @@ export function getValidStates( name: string ): StateDefinition[] { return []; } +/** + * Get the valid viewport states for a given block or element. + * + * @param name The block name (e.g., 'core/button') or element name (e.g., 'button') + * @return Array of valid viewport state definitions, or empty array if none + */ +export function getValidViewportStates( name: string ): StateDefinition[] { + // Responsive styles currently apply to blocks only. + if ( name.includes( '/' ) ) { + return RESPONSIVE_STATES; + } + + return []; +} + /** * Removes all instances of properties from an object. * From fedc688bcbc57a656827c6212aedfd283bca0583 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 30 Apr 2026 11:40:17 +1000 Subject: [PATCH 15/26] update failing e2e Co-authored-by: Copilot --- test/e2e/specs/site-editor/global-styles-button-states.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/specs/site-editor/global-styles-button-states.spec.js b/test/e2e/specs/site-editor/global-styles-button-states.spec.js index 29af8472c13c58..b53c8be397c351 100644 --- a/test/e2e/specs/site-editor/global-styles-button-states.spec.js +++ b/test/e2e/specs/site-editor/global-styles-button-states.spec.js @@ -42,7 +42,7 @@ test.describe( 'Global Styles - Button States', () => { const stateDropdown = page .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: /State:/ } ); + .getByRole( 'button', { name: 'Properties' } ); await expect( stateDropdown ).toBeVisible(); From cbfb3145e91055094f426f3fd6126bf2f90e0f4f Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 30 Apr 2026 16:03:27 +1000 Subject: [PATCH 16/26] hide filters panel on tablet and mobile Co-authored-by: Copilot --- packages/global-styles-ui/src/screen-block.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/global-styles-ui/src/screen-block.tsx b/packages/global-styles-ui/src/screen-block.tsx index 7957c41e5507fe..c3d32c2e85f536 100644 --- a/packages/global-styles-ui/src/screen-block.tsx +++ b/packages/global-styles-ui/src/screen-block.tsx @@ -196,6 +196,8 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { const hasBorderPanel = useHasBorderPanel( settings ); const hasDimensionsPanel = useHasDimensionsPanel( settings ); const hasFiltersPanel = useHasFiltersPanel( settings ); + const shouldShowFiltersPanel = + hasFiltersPanel && selectedViewport === 'default'; const hasImageSettingsPanel = useHasImageSettingsPanel( name, userSettings, @@ -410,7 +412,7 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { settings={ settings } /> ) } - { hasFiltersPanel && ( + { shouldShowFiltersPanel && ( Date: Fri, 1 May 2026 14:54:31 +1000 Subject: [PATCH 17/26] Add badges to show active states Co-authored-by: Copilot --- .../components/global-styles/state-control.js | 174 ++++++++++++------ .../src/components/global-styles/style.scss | 9 + .../global-styles-ui/src/screen-header.tsx | 7 +- packages/global-styles-ui/src/style.scss | 2 + 4 files changed, 131 insertions(+), 61 deletions(-) diff --git a/packages/block-editor/src/components/global-styles/state-control.js b/packages/block-editor/src/components/global-styles/state-control.js index b126373373c78a..a19d5331f487f6 100644 --- a/packages/block-editor/src/components/global-styles/state-control.js +++ b/packages/block-editor/src/components/global-styles/state-control.js @@ -3,7 +3,20 @@ */ import { __ } from '@wordpress/i18n'; import { check, chevronDown } from '@wordpress/icons'; -import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; +import { + DropdownMenu, + MenuGroup, + MenuItem, + privateApis as componentsPrivateApis, +} from '@wordpress/components'; +import { Stack } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; + +const { Badge: WCBadge } = unlock( componentsPrivateApis ); /** * State control for managing viewport and pseudo-state styles. @@ -47,65 +60,114 @@ export default function StateControl( { const hasViewportOptions = viewportStates.length > 0; const hasPseudoStateOptions = pseudoStates.length > 0; - const triggerLabel = __( 'Properties' ); + const triggerLabel = __( 'States' ); + const activeStates = []; + + if ( hasViewportOptions && viewportValue !== 'default' ) { + const selectedViewport = viewportOptions.find( + ( option ) => option.value === viewportValue + ); + + if ( selectedViewport ) { + activeStates.push( { + key: `viewport-${ selectedViewport.value }`, + label: selectedViewport.label, + } ); + } + } + + if ( hasPseudoStateOptions && pseudoStateValue !== 'default' ) { + const selectedPseudoState = pseudoStateOptions.find( + ( option ) => option.value === pseudoStateValue + ); + + if ( selectedPseudoState ) { + activeStates.push( { + key: `pseudo-${ selectedPseudoState.value }`, + label: selectedPseudoState.label, + } ); + } + } return ( - - { ( { onClose } ) => ( - <> - { hasViewportOptions && ( - - { viewportOptions.map( ( option ) => ( - { - onChangeViewport?.( option.value ); - if ( ! hasPseudoStateOptions ) { + + { ( { onClose } ) => ( + <> + { hasViewportOptions && ( + + { viewportOptions.map( ( option ) => ( + { + onChangeViewport?.( option.value ); + if ( ! hasPseudoStateOptions ) { + onClose(); + } + } } + icon={ + viewportValue === option.value + ? check + : null + } + > + { option.label } + + ) ) } + + ) } + { hasPseudoStateOptions && ( + + { pseudoStateOptions.map( ( option ) => ( + { + onChangePseudoState?.( + option.value + ); onClose(); + } } + icon={ + pseudoStateValue === option.value + ? check + : null } - } } - icon={ - viewportValue === option.value - ? check - : null - } - > - { option.label } - - ) ) } - - ) } - { hasPseudoStateOptions && ( - - { pseudoStateOptions.map( ( option ) => ( - { - onChangePseudoState?.( option.value ); - onClose(); - } } - icon={ - pseudoStateValue === option.value - ? check - : null - } - > - { option.label } - - ) ) } - - ) } - - ) } - + > + { option.label } + + ) ) } + + ) } + + ) } + + + { activeStates.map( ( activeState ) => ( + + { activeState.label } + + ) ) } + + ); } diff --git a/packages/block-editor/src/components/global-styles/style.scss b/packages/block-editor/src/components/global-styles/style.scss index b30d9c34d8699d..66a88217efc0f0 100644 --- a/packages/block-editor/src/components/global-styles/style.scss +++ b/packages/block-editor/src/components/global-styles/style.scss @@ -23,6 +23,15 @@ text-align: right; } +.block-editor-global-styles-state-control { + // Ensure alignment with block heading. + margin-top: -$grid-unit-05; +} + +.block-editor-global-styles-state-control__badges { + min-height: $grid-unit-30; +} + .block-editor-global-styles-filters-panel__dropdown, .block-editor-global-styles__shadow-dropdown { display: block; diff --git a/packages/global-styles-ui/src/screen-header.tsx b/packages/global-styles-ui/src/screen-header.tsx index 830b964b674188..8e57e094c09ab8 100644 --- a/packages/global-styles-ui/src/screen-header.tsx +++ b/packages/global-styles-ui/src/screen-header.tsx @@ -51,7 +51,7 @@ export function ScreenHeader( { - + - + Date: Fri, 1 May 2026 15:25:04 +1000 Subject: [PATCH 18/26] update comments Co-authored-by: Copilot --- lib/class-wp-theme-json-gutenberg.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 3b8d3e91490527..c9da5ab7334ca4 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -1066,11 +1066,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; @@ -1092,13 +1093,13 @@ 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; From c919c21fcd0d23a87818351d997e6fd59cf3311a Mon Sep 17 00:00:00 2001 From: Daniel Richards Date: Mon, 4 May 2026 15:28:45 +0800 Subject: [PATCH 19/26] Responsive global styles: use responsive nodes (#77881) * Refactor responsive global block styles to use style nodes instead of manual rendering Responsive breakpoint states (mobile/tablet) are now represented as first-class style nodes in get_block_nodes(), matching the existing pattern used for pseudo states. This replaces ~160 lines of manual breakpoint loops in get_styles_for_block() with a simple media_query wrapper, reducing duplication and improving maintainability. * Omit duotone since it was not previously supported for responsive * Remove irrelevant sentence --- lib/class-wp-theme-json-gutenberg.php | 273 +++++++++----------------- phpunit/class-wp-theme-json-test.php | 63 ++++-- 2 files changed, 143 insertions(+), 193 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index c9da5ab7334ca4..3ab3177941220e 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -3151,20 +3151,28 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt '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 ) { - // Create a node if default pseudo styles exist OR if any responsive breakpoint - // has pseudo styles — so the pseudo node is always present to handle cascade ordering. $has_pseudo = isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ); - if ( ! $has_pseudo ) { - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $bp ) { - if ( isset( $theme_json['styles']['blocks'][ $name ][ $bp ][ $pseudo_selector ] ) ) { - $has_pseudo = true; - break; - } - } - } if ( $has_pseudo ) { /* * Append the pseudo-selector to each feature selector so that @@ -3194,6 +3202,24 @@ 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 ), + ); + } + } } } } @@ -3241,35 +3267,73 @@ 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 ) { - $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' => $node_path, + '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 ], + ); + } + } } } } @@ -3296,6 +3360,7 @@ public function get_styles_for_block( $block_metadata ) { $selector = $block_metadata['selector']; $settings = $this->theme_json['settings'] ?? null; $is_root_selector = static::ROOT_BLOCK_SELECTOR === $selector; + $media_query = $block_metadata['media_query'] ?? null; $feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $node ); @@ -3308,11 +3373,13 @@ public function get_styles_for_block( $block_metadata ) { $feature_declarations = static::update_button_width_declarations( $feature_declarations, $settings ); // If there are style variations, generate the declarations for them, including any feature selectors the block may have. + // Responsive nodes (those with a media_query) do not process variations — variation responsive + // CSS is handled by the variation's own responsive nodes or the existing variation loop. $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'] ); @@ -3694,167 +3761,11 @@ static function ( $pseudo_selector ) use ( $selector ) { $block_rules .= $this->process_blocks_custom_css( $node['css'], $css_selector ); } - // 8. Generate and append responsive breakpoint rules. - if ( ! $is_root_selector ) { - $responsive_feature_css = ''; - - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { - if ( ! isset( $node[ $breakpoint ] ) ) { - continue; - } - - $breakpoint_node = $node[ $breakpoint ]; - $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $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 => $individual_feature_declarations ) { - $feature_ruleset = static::to_ruleset( ":root :where($feature_selector)", $individual_feature_declarations ); - $responsive_feature_css .= $breakpoint_media . '{' . $feature_ruleset . '}'; - } - - // Emit base responsive declarations using the already-stripped $breakpoint_node - // (get_feature_declarations_for_node removes feature props by reference, so only - // non-feature properties remain here, matching non-responsive cascade behaviour). - $breakpoint_declarations = static::compute_style_properties( $breakpoint_node, $settings, null, null ); - if ( ! empty( $breakpoint_declarations ) ) { - $responsive_feature_css .= $breakpoint_media . '{' . static::to_ruleset( ":root :where($selector)", $breakpoint_declarations ) . '}'; - } - - // Responsive pseudo-selector styles are emitted in section 8b below, - // after the pseudo node's default styles, to preserve cascade order: - // .block{} → @media{.block{}} → .block:hover{} → @media{.block:hover{}} - - if ( isset( $breakpoint_node['css'] ) ) { - $breakpoint_custom_css = static::process_blocks_custom_css( $breakpoint_node['css'], $css_selector ); - $responsive_feature_css .= $breakpoint_media . '{' . $breakpoint_custom_css . '}'; - } - - // Process blockGap responsive layout styles. - if ( ! empty( $block_metadata['name'] ) ) { - $responsive_feature_css .= $this->get_layout_styles( - $block_metadata, - array( - 'node' => $breakpoint_node, - 'media_query' => $breakpoint_media, - ) - ); - } - } - - $block_rules .= $responsive_feature_css; - } - - // 8b. When processing a block pseudo-selector node (e.g. ':hover'), emit responsive - // breakpoint overrides immediately after the default pseudo styles so media queries - // always follow the non-media rules they override. - // - // Each pseudo-state has two node passes in the style_nodes list: - // (a) base node (selector = ".block") → emits base + base-responsive styles - // (b) pseudo node (selector = ".block:hover") → emits default pseudo + responsive pseudo - // - // Splitting the work this way preserves cascade order: - // .block{} → @media{.block{}} → .block:hover{} → @media{.block:hover{}} - $block_path = $block_metadata['path']; - $block_path_count = count( $block_path ); - - if ( - ! $is_root_selector - && ! $is_processing_element - && $block_path_count >= 4 - && in_array( 'blocks', $block_path, true ) - && $block_name - && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) - ) { - $pseudo_state_key = $block_path[ $block_path_count - 1 ]; - if ( in_array( $pseudo_state_key, static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ], true ) ) { - $parent_block_path = array_slice( $block_path, 0, $block_path_count - 1 ); - - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { - $responsive_pseudo_path = array_merge( $parent_block_path, array( $breakpoint, $pseudo_state_key ) ); - $responsive_pseudo_node = _wp_array_get( $this->theme_json, $responsive_pseudo_path, null ); - - if ( empty( $responsive_pseudo_node ) ) { - continue; - } - - $breakpoint_media_8b = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ]; - $pseudo_bp_declarations = static::compute_style_properties( $responsive_pseudo_node, $settings, null, null ); - if ( ! empty( $pseudo_bp_declarations ) ) { - $block_rules .= $breakpoint_media_8b . '{' . static::to_ruleset( ":root :where($selector)", $pseudo_bp_declarations ) . '}'; - } - - if ( isset( $responsive_pseudo_node['css'] ) ) { - $pseudo_bp_custom_css = static::process_blocks_custom_css( $responsive_pseudo_node['css'], $selector ); - $block_rules .= $breakpoint_media_8b . '{' . $pseudo_bp_custom_css . '}'; - } - } - } - } - - // 9. When processing a block element node, emit responsive breakpoint overrides - // immediately after that node's default styles so media queries always follow the - // non-media rules they override. - // - // Each element has two node passes in the style_nodes list: - // (a) base node (selector = "a") → emits base responsive styles only - // (b) pseudo node (selector = "a:hover") → emits that pseudo's responsive styles only - // - // Splitting the work this way preserves cascade order: - // a {} → @media{ a{} } → a:hover {} → @media{ a:hover{} } - $path = $block_metadata['path']; - $path_count = count( $path ); - - $is_block_element_node = $is_processing_element - && $path_count >= 5 - && 'elements' === $path[ $path_count - 2 ] - && in_array( 'blocks', $path, true ); - - if ( $is_block_element_node ) { - $parent_block_path = array_slice( $path, 0, $path_count - 2 ); - - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { - $responsive_element_path = array_merge( $parent_block_path, array( $breakpoint, 'elements', $current_element ) ); - $responsive_element_node = _wp_array_get( $this->theme_json, $responsive_element_path, null ); - - if ( empty( $responsive_element_node ) ) { - continue; - } - - $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ]; - - if ( $pseudo_selector ) { - // Pseudo-selector node: only emit styles for this specific pseudo-state. - if ( ! isset( $responsive_element_node[ $pseudo_selector ] ) ) { - continue; - } - - $pseudo_declarations = static::compute_style_properties( $responsive_element_node[ $pseudo_selector ], $settings, null, $this->theme_json ); - if ( ! empty( $pseudo_declarations ) ) { - $pseudo_ruleset = static::to_ruleset( ':root :where(' . $selector . ')', $pseudo_declarations ); - $block_rules .= $breakpoint_media . '{' . $pseudo_ruleset . '}'; - } - - if ( isset( $responsive_element_node[ $pseudo_selector ]['css'] ) ) { - $pseudo_css = static::process_blocks_custom_css( $responsive_element_node[ $pseudo_selector ]['css'], $selector ); - $block_rules .= $breakpoint_media . '{' . $pseudo_css . '}'; - } - } else { - // Base element node: only emit base responsive styles. - // Pseudo-selector responsive styles are handled by their own node pass above. - $element_declarations = static::compute_style_properties( $responsive_element_node, $settings, null, $this->theme_json ); - if ( ! empty( $element_declarations ) ) { - $element_ruleset = static::to_ruleset( ':root :where(' . $selector . ')', $element_declarations ); - $block_rules .= $breakpoint_media . '{' . $element_ruleset . '}'; - } - - if ( isset( $responsive_element_node['css'] ) ) { - $element_custom_css = static::process_blocks_custom_css( $responsive_element_node['css'], $selector ); - $block_rules .= $breakpoint_media . '{' . $element_custom_css . '}'; - } - } - } + // 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; diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index ba5dde84e420d2..0456655d1c7983 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -986,7 +986,7 @@ public function test_get_styles_for_block_responsive_feature_selector_not_duplic ) ); - $metadata = array( + $base_metadata = array( 'name' => 'test/responsive-feature', 'path' => array( 'styles', 'blocks', 'test/responsive-feature' ), 'selector' => '.wp-block-test-responsive-feature', @@ -995,7 +995,18 @@ public function test_get_styles_for_block_responsive_feature_selector_not_duplic ), ); - $actual_styles = $theme_json->get_styles_for_block( $metadata ); + $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' ); @@ -1035,14 +1046,23 @@ public function test_get_styles_for_block_outputs_responsive_block_gap_after_def ) ); - $metadata = array( + $base_metadata = array( 'name' => 'core/group', 'path' => array( 'styles', 'blocks', 'core/group' ), 'selector' => '.wp-block-group', 'css' => '.wp-block-group', ); - $actual_styles = $theme_json->get_styles_for_block( $metadata ); + $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;}'; @@ -1092,17 +1112,35 @@ public function test_get_styles_for_block_responsive_element_pseudo_styles_prese ) ); + $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' => '.wp-block-group a:where(:not(.wp-element-button))', + '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' => '.wp-block-group a:where(:not(.wp-element-button)):hover', + 'selector' => $link_selector . ':hover', ); - $actual_styles = $theme_json->get_styles_for_block( $link_node ) . $theme_json->get_styles_for_block( $hover_node ); + $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;}}'; @@ -1209,13 +1247,14 @@ public function test_get_styles_for_block_outputs_tablet_responsive_styles_only( ) ); - $metadata = array( - 'name' => 'test/tablet-only', - 'path' => array( 'styles', 'blocks', 'test/tablet-only' ), - 'selector' => '.wp-block-test-tablet-only', + $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( $metadata ); + $actual_styles = $theme_json->get_styles_for_block( $tablet_metadata ); unregister_block_type( 'test/tablet-only' ); From 26763ed954ec50044cbfe85a754a4015c5ac764c Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 5 May 2026 11:31:40 +1000 Subject: [PATCH 20/26] don't close on setting pseudo state --- .../src/components/global-styles/state-control.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/global-styles/state-control.js b/packages/block-editor/src/components/global-styles/state-control.js index a19d5331f487f6..fa6f2c4e772a9b 100644 --- a/packages/block-editor/src/components/global-styles/state-control.js +++ b/packages/block-editor/src/components/global-styles/state-control.js @@ -139,7 +139,9 @@ export default function StateControl( { onChangePseudoState?.( option.value ); - onClose(); + if ( ! hasViewportOptions ) { + onClose(); + } } } icon={ pseudoStateValue === option.value From 2ca059042d928921773c6be016f23af4bd848489 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 5 May 2026 11:36:20 +1000 Subject: [PATCH 21/26] try moving popover --- .../block-editor/src/components/global-styles/state-control.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/block-editor/src/components/global-styles/state-control.js b/packages/block-editor/src/components/global-styles/state-control.js index fa6f2c4e772a9b..f6f66209a7caca 100644 --- a/packages/block-editor/src/components/global-styles/state-control.js +++ b/packages/block-editor/src/components/global-styles/state-control.js @@ -99,6 +99,9 @@ export default function StateControl( { Date: Tue, 5 May 2026 13:05:49 +1000 Subject: [PATCH 22/26] update button name in test --- test/e2e/specs/site-editor/global-styles-button-states.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/specs/site-editor/global-styles-button-states.spec.js b/test/e2e/specs/site-editor/global-styles-button-states.spec.js index b53c8be397c351..9f9a7a7108ab45 100644 --- a/test/e2e/specs/site-editor/global-styles-button-states.spec.js +++ b/test/e2e/specs/site-editor/global-styles-button-states.spec.js @@ -42,7 +42,7 @@ test.describe( 'Global Styles - Button States', () => { const stateDropdown = page .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: 'Properties' } ); + .getByRole( 'button', { name: 'States' } ); await expect( stateDropdown ).toBeVisible(); From b6aa4147dae8cb093a38d9a63fb1d41b8e0e523e Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 5 May 2026 13:49:10 +1000 Subject: [PATCH 23/26] backport changelog --- backport-changelog/7.1/11706.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 backport-changelog/7.1/11706.md diff --git a/backport-changelog/7.1/11706.md b/backport-changelog/7.1/11706.md new file mode 100644 index 00000000000000..c9c86d294e9c50 --- /dev/null +++ b/backport-changelog/7.1/11706.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/11706 + +* https://github.com/WordPress/gutenberg/pull/77513 \ No newline at end of file From 18d74e9bb6383fecf41d08a334360e7c741cc3d4 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 5 May 2026 15:02:41 +1000 Subject: [PATCH 24/26] Fix pseudo responsive styles not working unless default styles exist --- lib/class-wp-theme-json-gutenberg.php | 78 +++++++++++-------- .../global-styles-engine/src/core/render.tsx | 35 ++++----- phpunit/class-wp-theme-json-test.php | 33 ++++++++ 3 files changed, 92 insertions(+), 54 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 3ab3177941220e..aea530ff67f491 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -3172,26 +3172,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 ), @@ -3202,23 +3214,23 @@ 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 ), + ); } } } diff --git a/packages/global-styles-engine/src/core/render.tsx b/packages/global-styles-engine/src/core/render.tsx index cd53f2ddc47100..20d2ff083c2c77 100644 --- a/packages/global-styles-engine/src/core/render.tsx +++ b/packages/global-styles-engine/src/core/render.tsx @@ -884,17 +884,12 @@ function pickStyleAndPseudoKeys( const allowedPseudoSelectors = blockName ? VALID_BLOCK_PSEUDO_SELECTORS[ blockName ] ?? [] : []; - // Responsive breakpoint keys are available for all blocks (blockName contains '/'). - const includeResponsive = blockName?.includes( '/' ) ?? false; + const pickedEntries = entries.filter( ( [ key ] ) => STYLE_KEYS.includes( key ) || allowedPseudoSelectors.includes( key ) || - ( includeResponsive && - Object.prototype.hasOwnProperty.call( - RESPONSIVE_BREAKPOINTS, - key - ) ) + RESPONSIVE_BREAKPOINTS[ key ] ); // clone the style objects so that `getFeatureDeclarations` can remove consumed keys from it const clonedEntries = pickedEntries.map( ( [ key, style ] ) => [ @@ -1021,8 +1016,8 @@ function appendResponsiveStyles( blockRootSelector?: string, styleVariationName?: string ): string { - const responsiveStyles = Object.entries( styles ).filter( ( [ key ] ) => - Object.prototype.hasOwnProperty.call( RESPONSIVE_BREAKPOINTS, key ) + const responsiveStyles = Object.entries( styles ).filter( + ( [ key ] ) => RESPONSIVE_BREAKPOINTS[ key ] ); if ( ! responsiveStyles.length ) { @@ -1093,20 +1088,18 @@ function appendResponsiveStyles( remainingBreakpointStyles ); - if ( ! breakpointDeclarations.length ) { - return; + if ( breakpointDeclarations.length ) { + const cssSelector = styleVariationSelector + ? concatFeatureVariationSelectorString( + selector, + styleVariationSelector + ) + : selector; + ruleset += `${ mediaQuery }{:root :where(${ cssSelector }){${ breakpointDeclarations.join( + ';' + ) };}}`; } - const cssSelector = styleVariationSelector - ? concatFeatureVariationSelectorString( - selector, - styleVariationSelector - ) - : selector; - ruleset += `${ mediaQuery }{:root :where(${ cssSelector }){${ breakpointDeclarations.join( - ';' - ) };}}`; - const breakpointPseudoRules = appendPseudoSelectorStyles( remainingBreakpointStyles, selector, diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 0456655d1c7983..4463fb7ffa615b 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -1739,6 +1739,39 @@ public function test_get_stylesheet_pseudo_selector_scopes_feature_selector_css( $this->assertSameCSS( ':root :where(.wp-block-button:hover){writing-mode: vertical-rl;}', $css ); } + /** + * 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_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'mobile' => array( + ':hover' => array( + 'typography' => array( + 'writingMode' => 'vertical-rl', + ), + ), + ), + ), + ), + ), + ), + 'default' + ); + + $css = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + $this->assertSameCSS( + '@media (width <= 480px){:root :where(.wp-block-button:hover){writing-mode: vertical-rl;}}', + $css + ); + } + public function test_get_stylesheet_custom_root_selector() { $theme_json = new WP_Theme_JSON_Gutenberg( array( From 35ff5e6b02a7e71e89641bde94a9c3533f8c7764 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 5 May 2026 15:49:48 +1000 Subject: [PATCH 25/26] simplify states checks --- packages/global-styles-ui/src/screen-block.tsx | 5 +---- packages/global-styles-ui/src/utils.ts | 16 +++++----------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/global-styles-ui/src/screen-block.tsx b/packages/global-styles-ui/src/screen-block.tsx index c3d32c2e85f536..49afd23c509ff0 100644 --- a/packages/global-styles-ui/src/screen-block.tsx +++ b/packages/global-styles-ui/src/screen-block.tsx @@ -114,10 +114,7 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { useState< string >( 'default' ); const [ selectedPseudoState, setSelectedPseudoState ] = useState< string >( 'default' ); - const validViewportStates = useMemo( - () => getValidViewportStates( name ), - [ name ] - ); + const validViewportStates = useMemo( () => getValidViewportStates(), [] ); const validPseudoStates = useMemo( () => getValidPseudoStates( name ), [ name ] diff --git a/packages/global-styles-ui/src/utils.ts b/packages/global-styles-ui/src/utils.ts index 73041baca6c803..df4577daad152f 100644 --- a/packages/global-styles-ui/src/utils.ts +++ b/packages/global-styles-ui/src/utils.ts @@ -68,7 +68,7 @@ export const RESPONSIVE_STATES: StateDefinition[] = [ */ export function getValidPseudoStates( name: string ): StateDefinition[] { // Check if it's a block (contains a slash, e.g. 'core/button'). - if ( name.includes( '/' ) ) { + if ( VALID_BLOCK_STATES[ name ] ) { return VALID_BLOCK_STATES[ name ] ?? []; } @@ -81,18 +81,12 @@ export function getValidPseudoStates( name: string ): StateDefinition[] { } /** - * Get the valid viewport states for a given block or element. + * Get the valid viewport state definitions. * - * @param name The block name (e.g., 'core/button') or element name (e.g., 'button') - * @return Array of valid viewport state definitions, or empty array if none + * @return Array of valid viewport state definitions. */ -export function getValidViewportStates( name: string ): StateDefinition[] { - // Responsive styles currently apply to blocks only. - if ( name.includes( '/' ) ) { - return RESPONSIVE_STATES; - } - - return []; +export function getValidViewportStates(): StateDefinition[] { + return RESPONSIVE_STATES; } /** From b35c0f54514f33f75c8f128000ee0b2437979e9a Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 5 May 2026 16:33:40 +1000 Subject: [PATCH 26/26] remove selector variation fix --- lib/class-wp-theme-json-gutenberg.php | 27 +++++++------------ .../global-styles-engine/src/core/render.tsx | 23 +++------------- 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index aea530ff67f491..4dadf402f8fe51 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -3416,24 +3416,15 @@ public function get_styles_for_block( $block_metadata ) { $clean_current_selector = preg_replace( '/,\s+/', ',', $current_selector ); $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_current_selector ); - if ( $block_metadata['selector'] && ! str_contains( $clean_current_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 ); - } + // 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 ); // Add the new declarations to the overall results under the modified selector. $style_variation_declarations[ $combined_selectors ] = $new_declarations; diff --git a/packages/global-styles-engine/src/core/render.tsx b/packages/global-styles-engine/src/core/render.tsx index 20d2ff083c2c77..25074871fc9711 100644 --- a/packages/global-styles-engine/src/core/render.tsx +++ b/packages/global-styles-engine/src/core/render.tsx @@ -1784,26 +1784,11 @@ export const transformToStyles = ( string[], ] ) => { if ( declarations.length ) { - /* - * If the feature selector does not include the block's - * root selector (e.g. core/button dimensions width uses - * `.wp-block-button` while root is - * `.wp-block-button .wp-block-button__link`), apply the - * variation class directly to the feature selector. - */ const cssSelector = - ! selector || - baseSelector.includes( - selector - ) - ? concatFeatureVariationSelectorString( - baseSelector, - styleVariationSelector as string - ) - : getBlockStyleVariationSelector( - styleVariationName, - baseSelector - ); + concatFeatureVariationSelectorString( + baseSelector, + styleVariationSelector as string + ); const rules = declarations.join( ';' ); ruleset += `:root :where(${ cssSelector }){${ rules };}`;