Skip to content

Enable style state-specific custom CSS - #83539

Merged
tellthemachines merged 16 commits into
trunkfrom
add/viewport-custom-css
Oct 12, 2026
Merged

tellthemachines merged 16 commits into
trunkfrom
add/viewport-custom-css

Conversation

@tellthemachines

@tellthemachines tellthemachines commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

What?

Part of #80388.

  • Enables viewport and pseudo state-specific custom CSS on block instances.
  • Renames the "viewport" inspector slot to "styleStates" and displays it for pseudo states too. (we might want to make the block toolbar slot consistent with this one as a follow-up)
  • Ensures global state-specific custom CSS renders in the editor.
  • Scopes display of inherited CSS in the global styles panels to each viewport state.

Testing Instructions

  1. Set custom CSS for all states on a block (say a Heading) in global styles.
  2. Open a post, add that block and check the global styles display in the editor. Save and check the front end too.
  3. Now, add some custom CSS to the block instance, for each viewport state. Save and verify block instance styles display correctly and override the global styles for the same block.
Screenshot 2026-10-02 at 4 40 17 pm

Use of AI Tools

used copilot with gpt-6 luna and claude with sonnet 5.

@tellthemachines tellthemachines self-assigned this Sep 25, 2026
@tellthemachines tellthemachines added [Type] Enhancement A suggestion for improvement. [Feature] Style States Related to block style states (currently viewport and pseudo-states) labels Sep 25, 2026
@github-actions github-actions Bot added the [Package] Block editor /packages/block-editor label Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

🎉 Props

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: tellthemachines <isabel_brison@git.wordpress.org>
Co-authored-by: talldan <talldanwp@git.wordpress.org>
Co-authored-by: ramonjd <ramonopoly@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Updated as activity occurs, without notifying anyone named here. Add the props-bot label to refresh.

📦 Bundle size

Size Change: +457 B (+0.01%)

Total Size: 8.51 MB

📦 View Changed
Filename Size Change
build/modules/lazy-editor/index.min.js 15.2 kB -13 B (-0.09%)
build/scripts/block-editor/index.min.js 518 kB +535 B (+0.1%)
build/scripts/block-library/index.min.js 440 kB +4 B (0%)
build/scripts/edit-site/index.min.js 386 kB -3 B (0%)
build/scripts/editor/index.min.js 636 kB -66 B (-0.01%)

c48911b Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric 5016b5b trunk % Change
timeToFirstByte 53.15 ms +7.9% -3.48% 52.35 ms +10.32% -1.34% 1.53%
largestContentfulPaint 90 ms +6.67% -4.44% 92 ms +4.35% -8.7% -2.17%
lcpMinusTtfb 35.85 ms +17.02% -7.25% 34.65 ms +21.07% -6.2% 3.46%
wpBeforeTemplate 28.42 ms +10.06% -2.96% 28.07 ms +15.5% -1.07% 1.25%
wpTemplate 20.77 ms +5.54% -4.09% 20.89 ms +1.72% -3.49% -0.57%
wpTotal 49.5 ms +8.2% -3.15% 48.94 ms +9.4% -1.88% 1.14%
wpMemoryUsage 7.61 MB +0% -0% 7.57 MB +0% -0% 0.46%
wpDbQueries 17 +0% -0% 17 +0% -0% 0%

front-end-classic-theme

Metric 5016b5b trunk % Change
timeToFirstByte 44.7 ms +9.4% -1.01% 44.8 ms +5.36% -1.12% -0.22%
largestContentfulPaint 100 ms +2% -2% 100 ms +2% -4% 0%
lcpMinusTtfb 54.9 ms +1.18% -5.19% 53.5 ms +2.9% -2.71% 2.62%
wpBeforeTemplate 26.07 ms +7.71% -1.23% 25.9 ms +2.32% -1.39% 0.66%
wpTemplate 15.92 ms +1.13% -2.2% 15.83 ms +4.55% -1.58% 0.57%
wpTotal 41.64 ms +10.33% -0.67% 41.86 ms +4.75% -1.53% -0.53%
wpMemoryUsage 6.25 MB +0% -0% 6.20 MB +0% -0% 0.71%
wpDbQueries 14 +0% -0% 14 +0% -0% 0%

media-processing

Metric 5016b5b trunk % Change
mediaProcessingJpeg 404.71 ms +3.56% -1.36% 400.85 ms +0.73% -0.63% 0.96%
mediaProcessingAvif 6028.53 ms +0.36% -0.09% 6049.77 ms +0.14% -0.18% -0.35%
mediaProcessingJpegToAvif 4155.64 ms +0.44% -0.25% 4154.63 ms +0.05% -0.27% 0.02%

media-upload

Metric 5016b5b trunk % Change
jpegUploadProcessing 1467.2 ms +32.03% -3.24% 1408.53 ms +2.89% -0.41% 4.17%
pngUploadProcessing 210.02 ms +7.64% -17.37% 203.06 ms +9.67% -13.48% 3.43%
largeJpegUploadProcessing 1403.44 ms +0.57% -0.22% 1405.96 ms +0.89% -0.59% -0.18%
multipleImageUploadProcessing 1528.26 ms +1.46% -1.13% 1543.26 ms +2.15% -0.74% -0.97%

post-editor

Metric 5016b5b trunk % Change
serverResponse 338.69 ms +4.92% -11.49% 322.39 ms +3.9% -5.62% 5.06%
firstPaint 171.44 ms +4.07% -16.94% 220.03 ms +8.14% -31.81% -22.08%
domContentLoaded 901.14 ms +1.98% -4.87% 879.39 ms +1.74% -2.87% 2.47%
loaded 902.38 ms +1.98% -4.88% 880.51 ms +1.74% -2.9% 2.48%
firstContentfulPaint 379.61 ms +7.13% -4.59% 385.54 ms +1.45% -5.03% -1.54%
firstBlock 2643.22 ms +1.02% -2.63% 2546.56 ms +0.93% -1.93% 3.8%
type 17.02 ms +7.64% -3.23% 15.5 ms +7.03% -4.39% 9.81%
typeWithoutInspector 17.4 ms +3.28% -1.9% 16.02 ms +5.74% -5.24% 8.61%
typeWithTopToolbar 23.56 ms +1.91% -4.46% 19.72 ms +3.6% -2.03% 19.47%
typeContainer 7.79 ms +2.18% -3.72% 7.05 ms +6.1% -9.5% 10.5%
focus 105.61 ms +2.83% -4.79% 89.93 ms +5.3% -4.95% 17.44%
firstFocus 204.69 ms +0% -0% 171.66 ms +0% -0% 19.24%
selectAll 486.22 ms +6.4% -2.24% 445.36 ms +0.62% -0.22% 9.17%
listViewOpen 65.3 ms +3.11% -4.53% 58.35 ms +7.63% -7.27% 11.91%
inserterOpen 34.38 ms +6.02% -2.71% 33.21 ms +3.79% -4.07% 3.52%
inserterHover 10.24 ms +8.59% -7.42% 10.48 ms +3.24% -13.55% -2.29%
inserterSearch 7.46 ms +6.57% -8.18% 7.59 ms +2.64% -10.54% -1.71%
loadPatterns 519.58 ms +3.72% -2.81% 518.27 ms +4.72% -0.66% 0.25%
wpTotal 328.39 ms +5.09% -11.69% 312.72 ms +3.71% -5.88% 5.01%
wpMemoryUsage 13.33 MB +0% -0% 13.29 MB +0% -0% 0.28%
wpDbQueries 54 +0% -0% 54 +0% -0% 0%

site-editor

Metric 5016b5b trunk % Change
serverResponse 492.97 ms +5.87% -2.68% 494.41 ms +2.34% -6.82% -0.29%
firstPaint 271.45 ms +15.41% -8.84% 289.57 ms +69.14% -12.79% -6.26%
domContentLoaded 1209.96 ms +1.12% -1.33% 1196.49 ms +1.67% -1.56% 1.13%
loaded 1211.14 ms +1.12% -1.32% 1197.82 ms +1.66% -1.57% 1.11%
firstContentfulPaint 483.53 ms +2.88% -0.57% 492.09 ms +1.33% -3.56% -1.74%
firstBlock 4523.06 ms +0.67% -0.7% 4508.4 ms +0.69% -0.25% 0.33%
type 19.32 ms +2.64% -9.52% 18.5 ms +2.81% -7.57% 4.43%
navigate 123.26 ms +10.04% -4.2% 111.17 ms +18.49% -3.27% 10.88%
loadPatterns 1247.76 ms +5.35% -6.3% 1261.09 ms +1.89% -2.6% -1.06%
loadPages 996.02 ms +0.36% -1.12% 1017.35 ms +2.25% -2.1% -2.1%
wpTotal 481.93 ms +6.06% -2.75% 484.1 ms +2.24% -6.99% -0.45%
wpMemoryUsage 12.38 MB +0% -0% 12.34 MB +0% -0% 0.3%
wpDbQueries 43 +2.33% -0% 43 +2.33% -0% 0%

c48911b Run

🏁 Flaky tests

Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

refuses the drop and uploads nothing in /test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js, passed after 1 failed attempt.
Error: expect(received).toHaveLength(expected)

Expected length: 0
Received length: 2
Received array:  [{"_links": {"about": [{"href": "http://localhost:8889/wp-json/wp/v2/types/attachment"}], "author": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/users/1"}], "collection": [{"href": "http://localhost:8889/wp-json/wp/v2/media"}], "curies": [{"href": "https://api.w.org/{rel}", "name": "wp", "templated": true}], "replies": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/comments?post=264"}], "self": [{"href": "http://localhost:8889/wp-json/wp/v2/media/264", "targetHints": {"allow": ["GET", "POST", "PUT", "PATCH", "DELETE"]}}], "wp:attached-to": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/posts/262", "id": 262, "post_type": "post"}]}, "alt_text": "", "author": 1, "caption": {"rendered": ""}, "class_list": ["post-264", "attachment", "type-attachment", "status-inherit", "hentry", "entry"], "comment_status": "open", "date": "2026-10-12T03:15:54", "date_gmt": "2026-10-12T03:15:54", "description": {"rendered": "<p class=\"attachment\"><a href='http://localhost:8889/wp-content/uploads/2026/10/10x10_e2e_test_image_z9T8jK-1.png'><img loading=\"lazy\" decoding=\"async\" width=\"10\" height=\"10\" src=\"http://localhost:8889/wp-content/uploads/2026/10/10x10_e2e_test_image_z9T8jK-1.png\" class=\"attachment-medium size-medium\" alt=\"\" style=\"width:100%;height:100%;max-width:10px;\" /></a></p>
"}, "featured_media": 0, "filename": "10x10_e2e_test_image_z9T8jK-1.png", "filesize": 80, "guid": {"rendered": "http://localhost:8889/wp-content/uploads/2026/10/10x10_e2e_test_image_z9T8jK-1.png"}, "id": 264, "link": "http://localhost:8889/?attachment_id=264", "media_details": {"file": "2026/10/10x10_e2e_test_image_z9T8jK-1.png", "filesize": 80, "height": 10, "image_meta": {"alt": "", "aperture": "0", "camera": "", "caption": "", "copyright": "", "created_timestamp": "0", "credit": "", "focal_length": "0", "iso": "0", "keywords": [], "orientation": "0", "shutter_speed": "0", "title": ""}, "sizes": {}, "width": 10}, "media_type": "image", "meta": [], "mime_type": "image/png", "modified": "2026-10-12T03:15:54", "modified_gmt": "2026-10-12T03:15:54", "ping_status": "closed", "post": 262, "slug": "10x10_e2e_test_image_z9t8jk-2", "source_url": "http://localhost:8889/wp-content/uploads/2026/10/10x10_e2e_test_image_z9T8jK-1.png", "status": "inherit", "template": "", "title": {"rendered": "10x10_e2e_test_image_z9T8jK"}, "type": "attachment"}, {"_links": {"about": [{"href": "http://localhost:8889/wp-json/wp/v2/types/attachment"}], "author": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/users/1"}], "collection": [{"href": "http://localhost:8889/wp-json/wp/v2/media"}], "curies": [{"href": "https://api.w.org/{rel}", "name": "wp", "templated": true}], "replies": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/comments?post=263"}], "self": [{"href": "http://localhost:8889/wp-json/wp/v2/media/263", "targetHints": {"allow": ["GET", "POST", "PUT", "PATCH", "DELETE"]}}], "wp:attached-to": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/posts/262", "id": 262, "post_type": "post"}]}, "alt_text": "", "author": 1, "caption": {"rendered": ""}, "class_list": ["post-263", "attachment", "type-attachment", "status-inherit", "hentry", "entry"], "comment_status": "open", "date": "2026-10-12T03:15:54", "date_gmt": "2026-10-12T03:15:54", "description": {"rendered": "<p class=\"attachment\"><a href='http://localhost:8889/wp-content/uploads/2026/10/10x10_e2e_test_image_z9T8jK.png'><img loading=\"lazy\" decoding=\"async\" width=\"10\" height=\"10\" src=\"http://localhost:8889/wp-content/uploads/2026/10/10x10_e2e_test_image_z9T8jK.png\" class=\"attachment-medium size-medium\" alt=\"\" style=\"width:100%;height:100%;max-width:10px;\" /></a></p>
"}, "featured_media": 0, "filename": "10x10_e2e_test_image_z9T8jK.png", "filesize": 80, "guid": {"rendered": "http://localhost:8889/wp-content/uploads/2026/10/10x10_e2e_test_image_z9T8jK.png"}, "id": 263, "link": "http://localhost:8889/?attachment_id=263", "media_details": {"file": "2026/10/10x10_e2e_test_image_z9T8jK.png", "filesize": 80, "height": 10, "image_meta": {"alt": "", "aperture": "0", "camera": "", "caption": "", "copyright": "", "created_timestamp": "0", "credit": "", "focal_length": "0", "iso": "0", "keywords": [], "orientation": "0", "shutter_speed": "0", "title": ""}, "sizes": {}, "width": 10}, "media_type": "image", "meta": [], "mime_type": "image/png", "modified": "2026-10-12T03:15:54", "modified_gmt": "2026-10-12T03:15:54", "ping_status": "closed", "post": 262, "slug": "10x10_e2e_test_image_z9t8jk", "source_url": "http://localhost:8889/wp-content/uploads/2026/10/10x10_e2e_test_image_z9T8jK.png", "status": "inherit", "template": "", "title": {"rendered": "10x10_e2e_test_image_z9T8jK"}, "type": "attachment"}]
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js:71:45

c48911b Run

@tellthemachines
tellthemachines force-pushed the add/viewport-custom-css branch from 73fb2f1 to 34db686 Compare October 2, 2026 02:58
@github-actions github-actions Bot added the [Package] Block library /packages/block-library label Oct 2, 2026
@tellthemachines
tellthemachines marked this pull request as ready for review October 2, 2026 06:42
@tellthemachines tellthemachines changed the title Enable viewport-specific custom CSS Enable style state-specific custom CSS Oct 2, 2026
Comment thread lib/block-supports/custom-css.php Outdated
$processed_css = '';
foreach ( $state_entries as $entry ) {
$entry_selector = null !== $entry['pseudo'] ? $selector . $entry['pseudo'] : $selector;
$entry_css = WP_Theme_JSON_Gutenberg::process_blocks_custom_css( $entry['css'], $entry_selector );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing that stands out to me is the amount of looping over the same data, making a small change each time.

The first time is building the entries.

The second time is stripping the html.

The third time is generating the css.

Could it be one loop? For example, for pseudo selectors:

$processed_css = '';
foreach ( $supported_pseudo_states as $pseudo_state ) {
	$pseudo_style = $style[ $pseudo_state ] ?? null;
    if ( ! has_valid_custom_css( $pseudo_style ) ) {
        continue;
    }

    $css = WP_Theme_JSON_Gutenberg::process_blocks_custom_css( $pseudo_style['css'], $selector . $entry['pseudo']);

	if ( ! empty( $css ) ) {
		$processed_css .= $css;
	}
}

Feels like the classic LLM thing where it adds way more code than is needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, well that was an LLM decision 😄 but the readability didn't bother me. No strong opinion though so happy to look into reducing the looping.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I've updated this to consolidate the validation and generation of CSS into one loop. Building the entries needs to be separate on the JS side, where we're using the built entries to determine whether to show a warning notice. For consistency I decided to keep it on the PHP side too.

// Process the custom CSS using the same method as global styles.
// Process the custom CSS using the same method as global styles, for every state.
$selector = '.' . $class_name;
$processed_css = WP_Theme_JSON_Gutenberg::process_blocks_custom_css( $custom_css, $selector );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't the base CSS still need to be generated? Maybe I'm missing where that's done now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does work, so I must be missing it 😄

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I see it now ($add_entry( $style['css'] ?? null, null, null );)


/**
* Removes `css` keys from a block's `style` attribute, including any nested
* under pseudo-state (`:hover`) or viewport-state (`@mobile`) sub-objects.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering why this needs to be done. The original code doesn't explain it either. 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is applied to users without edit CSS permissions! I worked it out from the commit history, but yes it's probably worth adding a comment in the actual code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually there's already a comment below where the filters are registered.

@talldan talldan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I accidentally submitted the previous comments early before finishing the review.

}
return processCSSNesting( customCSS, customCSSSelector );
}, [ customCSS, customCSSSelector, isValidCSS ] );
return renderCustomCSSStateEntries(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the PHP, I'm not convinced all the helper functions and repeated looping is beneficial. It makes the code harder to read for me.

cssFeatureSelector &&
typeof cssFeatureSelector === 'object'
) {
resolvedCssSelector = ( cssFeatureSelector as { root?: string } )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the if statement check for the existence of the root property here?

The typing is really awkward this code. I really don't know it well enough to have good suggestions on improving it though. Feels like it should be typed properly from the top down rather than casting sub-properties like this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a change that I think makes it a bit more readable, let me know if it improves matters!

Comment on lines +1151 to +1153
if ( typedNode?.css ) {
blockStyles.css = typedNode.css;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really understand any of this tbh 😬

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I understand it, css isn't included in STYLE_KEYS because it requires its own rendering path, being an actual CSS string as opposed to a style object. This PR just moves the custom CSS processing from generateGlobalStyles to renderStylesNode so it gets generated for all the style states.

Comment on lines +188 to +190
<div className="block-editor-hooks-custom-css__state-panel">
{ panel }
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is missing a PanelBody.

The 'advanced' InspectorControls group usually renders its own around all its fills.

Because this code uses a different group the control is orphaned in the sidebar without any PanelBody around it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh is that why it looked weird 😅

@ramonjd ramonjd left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for getting this started!

Comment thread lib/block-supports/custom-css.php Outdated

$supported_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array();

static $responsive_media_queries = null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why static here? Would this survive a theme switch?

Other supports just assign a $var to gutenberg_get_global_settings(), which I think caches anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this might not be needed

unset( $style['css'] );

foreach ( $style as $key => $value ) {
if ( is_array( $value ) && ( str_starts_with( $key, ':' ) || str_starts_with( $key, '@' ) ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still care about the old mobile {"mobile": rules?

Just wondering if we need to call gutenberg_resolve_style_state_aliases here like elsewhere

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we shouldn't be calling that function anywhere new. The places it was added to initially were meant for back compat with the plugin versions where we shipped the initial, unprefixed states. We could probably remove it altogether now (it was never meant for core); i'm not 100% sure what size deprecation window @talldan was considering.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two plugin versions, so we can remove it 👍

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do a follow-up!

settings: InspectorControlsDefault, // Alias for default.
styles: InspectorControlsStyles,
typography: InspectorControlsTypography,
viewport: InspectorControlsViewport,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's only two releases ago, but could anybody be using <InspectorControls group="viewport">?

Probably low risk, but the options is to add an alias like settings does above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Veloria says no and I'm going to assume that's correct rather than add another aliasing logic that will end up as tech debt 😅

addEntry( style?.[ pseudoState ], pseudoState )
);

const breakpoints = viewportSettings

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

viewportSettings is undefined.

Wondering if we should add defaults to lib/theme.json.

E.g., "viewport": { "mobile": "480px", "tablet": "782px" } (or whatever).

Or just get the default media queries when the theme sets nothing

const breakpoints = Object.entries(
	getResponsiveMediaQueries( viewportSettings )
);

To match frontend.

Otherwise, in the editor, I'm seeing the following:

  1. For a para, set Additional CSS to background: yellow;
  2. At mobile breakpoint give it background: red;
  3. Switch back to desktop and the background is still red.
Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh good catch. viewportSettings is only undefined if the theme doesn't set them, but there seems to be a bug where breakpoints contains both breakpoints and media queries when viewportSettings is defined, and only breakpoints otherwise.

$this->assertMatchesRegularExpression( '/wp-custom-css-/', $result['attrs']['className'], 'className should contain wp-custom-css- prefix.' );
}

/**

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do any of these tests cover the new behaviour, e.g., custom CSS read from the block and output with the right selectors / and media queries?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they cover edge cases of new behaviour 😄 but might be worth adding a new generic test

styles.css,
`:root :where(${ effectiveSelector })`
);
// A block can declare a dedicated selector for custom CSS (e.g. to

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something Claude picked up after another round.

The editor and front end now use different selectors for a style variation's custom CSS (basically the editor doesn't use a block's "css").

So for blocks like core/icon that define a "css" selector, variations won't use it and use the root one instead (only in the editor)

core/icon is the only one so far.

I tested by adding a variation:

add_action( 'init', function () {
	register_block_style( 'core/icon', array( 'name' => 'framed', 'label' => 'Framed', 'style_data' => array( 'css' => 'outline: 4px dashed red;' ) ) );
} );

The applying the variation "Framed" to an icon block.

Editor Frontend
Image Image

Should blocks use their own variation selector like the PHP version does?

https://github.com/WordPress/gutenberg/blob/add/viewport-custom-css/lib/class-wp-theme-json-gutenberg.php#L3979

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, looks like this is a bug in the PHP logic. If there's a feature selector for custom CSS, then block style variations should use it, and currently it's being ignored on the PHP side.

Hm. Given this PR doesn't touch the theme json class at all, I'm tempted to fix this separately.

@tellthemachines
tellthemachines force-pushed the add/viewport-custom-css branch from c793852 to 006b045 Compare October 6, 2026 23:39
@tellthemachines

Copy link
Copy Markdown
Contributor Author

Ok I think all the feedback has been addressed now; this is ready for another review!

Comment thread lib/block-supports/custom-css.php
Comment thread lib/block-supports/custom-css.php Outdated
Comment on lines +43 to +46
if ( ! is_array( $pseudo_style ) ) {
continue;
}
$add_entry( $pseudo_style['css'] ?? null, $pseudo_state, null );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up to you, but I think $add_entry doesn't really add much compared to this:

if ( isset( $pseudo_style['css'] ) && is_string( $pseudo_style['css'] ) && '' !== trim( $pseudo_style['css'] ) ) {
    $entries[] = array( 
        'css'    => $pseudo_style['css'], 
        'pseudo' => $pseudo_state 
    );
}

It also has some weird qualities, like some of the checks on $pseudo_style['css'] are inside and some are outside the function. The function gets called with null as the first param most of the time and does nothing.

For more brevity it could be reduced to something like and the same could be true of the JS:

if ( has_valid_custom_css( $pseudo_style ) ) {
    $entries[] = array( 
        'css'    => $pseudo_style['css'], 
        'pseudo' => $pseudo_state 
    );
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok done!

// Validate CSS doesn't contain HTML markup (same validation as global styles REST API).
if ( preg_match( '#</?\w+#', $custom_css ) ) {
$viewport_settings = gutenberg_get_global_settings( array( 'viewport' ) );
$responsive_media_queries = WP_Theme_JSON_Gutenberg::get_viewport_media_queries( $viewport_settings );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$responsive_media_queries doesn't get used again and gutenberg_get_custom_css_state_entries is only called from one place, so maybe it'd be tidier to move this code into gutenberg_get_custom_css_state_entries rather than getting it here and passing in as an arg.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved it here so it would match other places where we do essentially the same: layout and position, and even states.php. Prior to this convo it was a static variable in gutenberg_get_custom_css_state_entries 😅

I don't know how valuable consistency is at this level though, it might not make much difference.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's definitely a nit, so feel free to leave it!

unset( $style['css'] );

foreach ( $style as $key => $value ) {
if ( is_array( $value ) && ( str_starts_with( $key, ':' ) || str_starts_with( $key, '@' ) ) ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two plugin versions, so we can remove it 👍

Comment on lines 249 to 261
useEffect( () => {
if ( ! canEditCSS && hasCustomCSS ) {
createWarningNotice(
__(
'This post contains blocks with custom CSS. You do not have permission to edit CSS. If you save this post, the custom CSS will be removed.'
),
{
id: CUSTOM_CSS_WARNING_NOTICE_ID,
isDismissible: true,
}
);
}
}, [ canEditCSS, hasCustomCSS, createWarningNotice ] );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to the PR, but this whole thing is really bizarre. I get why, but it feels wrong that a user with lower capabilities can delete the work of a one with more capabilities so easily. What would be the point of such a user editing/saving if they'll destroy something someone else worked on?

Feels like instead it should prevent the edit / save - "You can't save this post, since it contains custom CSS that your account does not have permission to save".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah there was a whole argument about it in #76650 and this is where it ended up 🤷

help={ cssHelpText }
/>
<InspectorControls group={ inspectorGroup }>
{ isStateSelected ? <PanelBody>{ panel }</PanelBody> : panel }

@talldan talldan Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PanelBody components usually have a title which makes them expandable/collapsible. I think this is also why the padding around the control looks off.

I think it should be at the end if it's called 'Advanced'. On the gallery at the moment it has 'Settings' after it:
Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because they're both in the style states slot, and settings are added by the Gallery block. I think whatever the block adds to that slot goes in after whatever the block supports add? Because we're providing the slot to extenders, I'm not sure we can fully control how things appear. This is why I was asking whether we should make additional CSS a ToolsPanel 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be worth creating another slot just for a style states enabled "advanced" panel? I can't think of any better solution

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, tricky one. I guess another slot is the right approach. It could be a private slot. I believe 'Manage allowed blocks' did similar to ensure it was the last item.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I added a private slot for style states. We can use it for any "advanced" tools (maybe also "apply globally" if we make it work per-state at some point)

@talldan talldan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inspector panels work nicely now, thanks for addressing all the feedback.

@tellthemachines
tellthemachines merged commit fd16584 into trunk Oct 12, 2026
81 checks passed
@tellthemachines
tellthemachines deleted the add/viewport-custom-css branch October 12, 2026 04:33
@github-actions github-actions Bot added this to the Gutenberg 24.3 milestone Oct 12, 2026
* Removes `css` keys from a block's `style` attribute, including any nested
* under pseudo-state (`:hover`) or viewport-state (`@mobile`) sub-objects.
*
* @since 7.1.0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noticed this while testing WordPress/wordpress-develop#14197

Suggested change
* @since 7.1.0
* @since 7.2.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Style States Related to block style states (currently viewport and pseudo-states) [Package] Block editor /packages/block-editor [Package] Block library /packages/block-library [Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants