Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/block-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Enhancements

- Inspector controls in the standard block-supports panels (Typography, Dimensions, Border, Color, Background, Filters) now reflect the value a block inherits from Global Styles when no local override is set. Inherited controls show that value at rest (as a placeholder, preselected option, or resolved value) and mark the label with a dotted underline; setting a local override reveals a reset affordance that clears the override back to the inherited value ([#77894](https://github.com/WordPress/gutenberg/pull/77894)).
- Inherited Global Styles now resolve per-level heading element styles for the heading-family blocks (`core/heading`, `core/site-title`, `core/post-title`, `core/query-title`, `core/comments-title`, `core/term-name`, `core/site-tagline`), so inspector controls reflect values set on a specific heading level (`styles.elements.h1`–`h6`) in addition to the shared `heading` element. A block rendered at level 0 (a paragraph) folds no heading element styles.

### Bug Fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
globalStylesLinksDataKey,
} from '../../store/private-keys';
import { getVariationNameFromClass } from '../../hooks/block-style-variation';
import { useBlockEditContext } from '../block-edit/context';
import { unlock } from '../../lock-unlock';

const { resolveStyle } = unlock( globalStylesEnginePrivateApis );
Expand Down Expand Up @@ -43,32 +44,98 @@ function useRawGlobalStyles() {
);
}

/**
* Maps a block to the root-level Global Styles *element* layers that paint it
* on the canvas, in addition to (or instead of) the block's own class — e.g. a
* Button renders `.wp-element-button`, a level-2 Heading renders `<h2>`.
*
* Element keys are returned ordered low to high precedence, so a level-specific
* `h2` correctly wins over the generic `heading`. A level of `0` (e.g. a Site
* or Post Title rendered as a paragraph) renders no heading tag at all, so
* neither `heading` nor any `h1`-`h6` styles reach the block and no element
* layer applies.
*
* This list is hand-maintained by necessity: the block-to-element relationship
* is not inferable from block metadata. `supports.color.link` and friends mark
* blocks that *contain* a stylable element, not blocks that *are* one (Button
* declares no `color.button`, Heading no `color.heading`), and the system only
* stores the inverse element-to-selector map (`__EXPERIMENTAL_ELEMENTS`); the
* correspondence otherwise lives only in each block's rendered markup.
* Standardizing it as a declarative block property is tracked in
* https://github.com/WordPress/gutenberg/issues/80438.
*
* The link-bearing heading blocks (Site Title, Post Title, Term Name) are
* deliberately not given a `link` layer here: their inner-link color control
* reads the `inheritedValue.elements.link` passthrough, so folding `link` into
* the block's own layers would bleed link color into the heading's text.
*
* @param {string} blockName Block name.
* @param {?number} headingLevel Block's `level` attribute, when it has one.
* @return {string[]} Ordered element keys, low to high precedence.
*/
function getElementLayers( blockName, headingLevel ) {
switch ( blockName ) {
case 'core/button':
return [ 'button' ];
case 'core/heading':
case 'core/post-title':
case 'core/site-title':
case 'core/query-title':
case 'core/comments-title':
case 'core/term-name':
case 'core/site-tagline':

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.

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 think the accordion one might need a little more work, but I'll double-check tomorrow. Thanks for taking this for a spin!

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 got it working for the accordion heading: it needed different handling as it derives its heading level via context rather than it being set on the individual accordion heading (so that all accordion headings in the set of accordion items receive the same heading level). It's a bit hard-coded and possibly smelly, but I tried to keep it neat where I could. Let me know if you think it's too much, and we can roll back and look at it in a follow-up if need be.

I'm a little on the fence about all this hard-coding, but at least it's fairly internal for now, and we could always look at improving it in the future... in any case, here's how it's looking now, inheriting the heading element style:

image

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'm a little on the fence about all this hard-coding, but at least it's fairly internal for now

Nice! I think it's okay. Is there an alternative in code?

My 2c is that this small fix makes headings consistent, and therefore better for the user. That to me is more important.

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.

Nice! I think it's okay. Is there an alternative in code?
My 2c is that this small fix makes headings consistent, and therefore better for the user. That to me is more important.

Same here. I think I was just musing out loud about the idea that at some point we might codify this in block.json in some way and make it a bit more API-ey than hard-coded.

But agreed — for now I think focusing on improving the UX while keeping the code fairly self-contained is likely the right path forward.

I haven't looked at an alternative in code, mostly I wanted to confidence check that we're okay with the hard-coding. It seems we both are for now 😄

if ( headingLevel === 0 ) {
return [];
}
return headingLevel
? [ 'heading', `h${ headingLevel }` ]
: [ 'heading' ];
default:
return [];
}
}

/**
* Internal hook that derives the active block-style-variation slug from a
* block's `className` by matching registered styles via
* `getVariationNameFromClass`. Returns `null` when no registered variation
* class is present (the most common case).
* `getVariationNameFromClass`, together with the element layers that paint
* the block. Both come from a single store subscription.
*
* The lookup is scoped to a single `useSelect` subscription; the
* `@wordpress/blocks` registered-styles slice changes only when a block's
* styles are (un)registered, so this subscription is cold in steady-state
* editor use.
*
* The block's `clientId` comes from the block edit context rather than an
* argument: these panels always render inside `BlockEditContextProvider`, so
* the caller does not have to thread it through.
*
* @param {?string} blockName Block name (e.g. `core/heading`).
* @param {?string} className Space-separated class string from block attributes.
* @return {?string} Variation slug (without the `is-style-` prefix) or `null`.
* @return {{ variationName: ?string, headingLevel: ?number }} Variation slug
* (without the `is-style-` prefix) and the block's heading level, if any.
*/
function useOwnVariation( blockName, className ) {
function useVariationAndElements( blockName, className ) {
const { clientId } = useBlockEditContext();
return useSelect(
( select ) => {
if ( ! blockName || ! className ) {
return null;
if ( ! blockName ) {
return { variationName: null, headingLevel: undefined };
}
const registeredStyles =
select( blocksStore ).getBlockStyles( blockName );
return getVariationNameFromClass( className, registeredStyles );
const { level } =
select( blockEditorStore ).getBlockAttributes( clientId ) || {};
// Primitives only: `useSelect` shallow-compares this object, so
// returning a fresh array here would re-render on every action.
return {
variationName: className
? getVariationNameFromClass( className, registeredStyles )
: null,
headingLevel: level,
};
},
[ blockName, className ]
[ blockName, className, clientId ]
);
}

Expand All @@ -93,7 +160,10 @@ function useOwnVariation( blockName, className ) {
* @return {{ value: Object, sources: Object }} Merged panel-scoped payload and source map.
*/
export function useResolvedStyle( blockName, className, selectedState = null ) {
const variationName = useOwnVariation( blockName, className );
const { variationName, headingLevel } = useVariationAndElements(
blockName,
className
);
const globalStyles = useRawGlobalStyles();

return useMemo( () => {
Expand All @@ -103,8 +173,15 @@ export function useResolvedStyle( blockName, className, selectedState = null ) {
return resolveStyle( globalStyles, {
blockName,
variationName,
elements: getElementLayers( blockName, headingLevel ),
viewport: selectedState?.viewport ?? null,
pseudoState: selectedState?.pseudo ?? null,
} );
}, [ blockName, variationName, globalStyles, selectedState ] );
}, [
blockName,
variationName,
headingLevel,
globalStyles,
selectedState,
] );
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ jest.mock( '../../../store', () => ( {
} ) );

// `inherited-value-context.js` imports `store as blocksStore` from
// `@wordpress/blocks` for `useOwnVariation`. The blocks store's transitive
// import chain fails under this file's `@wordpress/data` mock (missing
// `createSelector`), so stub the blocks module with just the shape needed.
// `@wordpress/blocks` for `useVariationAndElements`. The blocks store's
// transitive import chain fails under this file's `@wordpress/data` mock
// (missing `createSelector`), so stub the blocks module with just the shape
// needed.
jest.mock( '@wordpress/blocks', () => ( {
store: { name: 'core/blocks' },
getBlockType: ( blockName ) =>
Expand Down Expand Up @@ -96,6 +97,10 @@ describe( 'useResolvedStyle hook', () => {
[ globalStylesDataKey ]: rawGlobalStyles,
} ),
getBlockStyles: () => blockStyles,
// The hook reads the block's `level` attribute to pick the
// heading element layers; no block edit context is provided
// here, so `clientId` is undefined and attributes are empty.
getBlockAttributes: () => ( {} ),
} ) )
);
}
Expand Down Expand Up @@ -226,6 +231,7 @@ describe( 'useResolvedStyle – production bare-tree shape', () => {
[ globalStylesDataKey ]: rawGlobalStyles,
} ),
getBlockStyles: () => [],
getBlockAttributes: () => ( {} ),
} ) )
);
return render( ui );
Expand Down
2 changes: 1 addition & 1 deletion packages/global-styles-engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

### New Features

- Added a private `resolveStyle` API that resolves the merged Global Styles cascade (root, block type, and applied block style variation) for a block, returning the inherited value and a per-leaf source map.
- Added a private `resolveStyle` API that resolves the merged Global Styles cascade (root, root-level element layers, block type, and applied block style variation) for a block, returning the inherited value and a per-leaf source map. Element layers are supplied by the caller as an ordered `elements` list on the resolve context (low to high precedence, e.g. `[ 'heading', 'h2' ]`), keeping the engine agnostic of block-to-element mappings.

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.

Needs a #80495 link I think

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.

Added the PR to a separate changelog entry. I left the existing entries alone just in case it affects branch conflicts when we go to backport this (but I might be overthinking this one 😄)

- Added a private `getVariationStyle` API that returns a block style variation's styles, resolving `{ ref }` references against the Global Styles tree by default (pass `{ resolveRefs: false }` to keep them in place).

## 1.18.0 (2026-07-14)
Expand Down
69 changes: 35 additions & 34 deletions packages/global-styles-engine/src/resolve-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ interface ResolveStyleContext {
// Slug of the active block style variation, if any. Its styles are resolved
// against `globalStyles` internally and folded in as the highest layer.
variationName?: string | null;
// Root-level `styles.elements.*` keys whose styles paint this block on the
// canvas, ordered low to high precedence (e.g. `[ 'heading', 'h2' ]` for a
// level-2 Heading). Folded in above the root defaults and below the block's
// own styles. The caller owns this mapping; the engine stays block-agnostic.
elements?: string[] | null;
// Responsive breakpoint (e.g. `@mobile`), or null for the default viewport.
viewport?: string | null;
// CSS pseudo-state (e.g. `:hover`), or null for the default state. Matches
Expand Down Expand Up @@ -83,15 +88,6 @@ const SOURCE_DESCRIPTORS: Record< string, SourceDescriptor > = {
blockVariation: { layer: 'blockVariation' },
};

// Blocks whose canvas rendering is driven by a Global Styles *element*
// selector (e.g. `.wp-element-button`, `h1`–`h6`) rather than the block's own
// class. For these, the matching root-level `styles.elements[ element ]` layer
// is folded in as its own inheritance layer.
const BLOCK_TO_ROOT_ELEMENT: Record< string, string > = {
'core/button': 'button',
'core/heading': 'heading',
};

function createSourceDescriptor( type: string ): SourceDescriptor | null {
const descriptor = SOURCE_DESCRIPTORS[ type ];
if ( ! descriptor ) {
Expand Down Expand Up @@ -401,6 +397,7 @@ function resolveThemeFileBackgroundImage(
* @param context Which slice to resolve.
* @param context.blockName Block name (e.g. `core/heading`).
* @param context.variationName Slug of the active block style variation, or null.
* @param context.elements Root-level `styles.elements.*` keys that paint this block, ordered low to high precedence.
* @param context.viewport Responsive breakpoint, or null for the default viewport.
* @param context.pseudoState CSS pseudo-state, or null for the default state.
* @return Merged panel-scoped payload and source map.
Expand All @@ -410,6 +407,7 @@ function computeResolvedStyle(
{
blockName,
variationName = null,
elements = null,
viewport = null,
pseudoState = null,
}: ResolveStyleContext = {}
Expand All @@ -429,13 +427,14 @@ function computeResolvedStyle(

const root = styles;
const block = styles.blocks?.[ blockName ] ?? null;
// For element-based blocks (e.g. `core/button`), the root-level element
// styles paint the block on the canvas, so fold them in as a layer just
// above the root defaults but below the block's own styles.
const rootElement = BLOCK_TO_ROOT_ELEMENT[ blockName ] ?? null;
const element = rootElement
? styles.elements?.[ rootElement ] ?? null
: null;
// For element-based blocks (e.g. `core/button`, or a Heading at a given
// level), the root-level element styles paint the block on the canvas, so
// fold them in as layers just above the root defaults but below the
// block's own styles. `elements` is ordered low to high precedence, so a
// level-specific `h2` correctly wins over the generic `heading`.
const elementLayers = ( elements ?? [] )
.map( ( elementName ) => styles.elements?.[ elementName ] ?? 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.

So we only getting layers from styles.elements so the resolver doesn't know about block's ancestors.

So changes to styles.blocks['core/group'].elements.h2 will show the inherited style it knows about, not the one that is applied.

Image

I suspect it could get rabbit holey. Not sure.

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.

So we only getting layers from styles.elements so the resolver doesn't know about block's ancestors.

Yeah, that's a limitation of the current approach for sure. Not sure how well it'll work to walk up the block hierarchy to see what's there 🤔

Will poke at the idea tomorrow and make a call on whether we should go down the rabbithole now, or defer it to a future voyage!

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.

whether we should go down the rabbithole now, or defer it to a future voyage!

It is looking a bit hairy performing block lookups up the block hierarchy to make sure we're inheriting things correctly. My vote would be to leave blocks-within-blocks behaviour to a follow up and mark it as a task in the linked issue. What do you reckon?

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.

Agree. A follow up sounds best. Thanks for digging in.

.filter( ( layer ): layer is StyleTree => !! layer );
// Resolve the active block style variation's styles (with `{ ref }`
// values resolved) against the Global Styles tree.
const variation = variationName
Expand All @@ -452,12 +451,12 @@ function computeResolvedStyle(
pickLayerRootContribution( root ),
createSourceDescriptor( 'root' )
),
element
? createContribution(
pickLayerRootContribution( element ),
createSourceDescriptor( 'element' )
)
: null,
...elementLayers.map( ( layer ) =>
createContribution(
pickLayerRootContribution( layer ),
createSourceDescriptor( 'element' )
)
),
block
? createContribution(
pickLayerRootContribution( block ),
Expand Down Expand Up @@ -485,14 +484,14 @@ function computeResolvedStyle(
),
createSourceDescriptor( 'root' )
),
element
? createContribution(
pickLayerRootContribution(
getStateSlice( element, selectedState )
),
createSourceDescriptor( 'element' )
)
: null,
...elementLayers.map( ( layer ) =>
createContribution(
pickLayerRootContribution(
getStateSlice( layer, selectedState )
),
createSourceDescriptor( 'element' )
)
),
block
? createContribution(
pickLayerRootContribution(
Expand Down Expand Up @@ -588,15 +587,17 @@ export function resolveStyle(
inner = new Map();
byLinks.set( linksKey, inner );
}
const stateKey = `${ context.viewport ?? '' }:${
context.pseudoState ?? ''
}`;
// Element layers vary per block instance (e.g. a Heading's level), so they
// must take part in the cache key alongside the state slice.
const sliceKey = `${ context.elements?.join( ',' ) ?? '' }:${
context.viewport ?? ''
}:${ context.pseudoState ?? '' }`;
const key =
( context.blockName || '' ) +
'\u0001' +
( context.variationName || '' ) +
'\u0001' +
stateKey;
sliceKey;
if ( inner.has( key ) ) {
return inner.get( key ) as ResolvedStyle;
}
Expand Down
Loading
Loading