From 9e7b2c6858ffaae717672fabb8069550773b86e3 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 21 Apr 2026 16:16:00 +1000 Subject: [PATCH 01/13] 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 b147b5b77ba006e863e3390739359257c412ff57 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Wed, 22 Apr 2026 13:17:25 +1000 Subject: [PATCH 02/13] 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 81008e5822d732c3bf1d3fe249f1e372e832dea3 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Wed, 22 Apr 2026 16:31:47 +1000 Subject: [PATCH 03/13] 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 c14fbdffdc17decbabc8540f16a4340e20f1016f Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 11:44:51 +1000 Subject: [PATCH 04/13] 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 f7cd7d3688f3ffe9cb589a3beedca5e79b85b99a Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 14:40:18 +1000 Subject: [PATCH 05/13] 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 fad1be4ff9e525ce928fb7687074f6df351ee2bc Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 15:15:35 +1000 Subject: [PATCH 06/13] 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 2546fc92ebc68f0e9ccba868918f04e629f0b5cc Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 15:48:04 +1000 Subject: [PATCH 07/13] 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 c54bbe879b6c645caa5be6691579529eddb230ad Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 16:38:26 +1000 Subject: [PATCH 08/13] 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 7c3dfd637eb86baff9cc7c4ba969b3dde1065d0b Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 16:44:36 +1000 Subject: [PATCH 09/13] 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 2011fd33c920ec0cb6dd84c626a1b4ce3e372633 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 23 Apr 2026 17:03:39 +1000 Subject: [PATCH 10/13] 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 d0a47854ce494e757ccfee03a059535e1cb04d02 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Fri, 24 Apr 2026 11:23:23 +1000 Subject: [PATCH 11/13] 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 c185831460acaefe5df935fbedd14b89b8d69d63 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Fri, 24 Apr 2026 17:31:12 +1000 Subject: [PATCH 12/13] 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 e5e4f4f7efc3849d65d3d035662373ebcf61d75f Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 28 Apr 2026 14:53:07 +1000 Subject: [PATCH 13/13] 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' && (