From 83606c82f1d6cbdae5aa9d182b7adc521e7bbb84 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:02:02 +1000 Subject: [PATCH 1/5] Global Styles: resolve per-level heading element styles in inspector controls Completes the heading-family portion of the element inheritance layer tracked in #80438 (follow-up to #77894). The block inspector now reflects styles set on a specific heading level (`styles.elements.h1`-`h6`) in addition to the shared `heading` element. Previously the resolver folded in only `styles.elements.heading` for `core/heading`, so anything written by the per-level tabs in Styles > Typography > Headings was invisible to inspector controls. Because `fontSize` is deliberately disabled at the "All headings" level, every Global Styles heading font size lands in `elements.hN` and never reached a control at all. - Engine: replace the hardcoded `BLOCK_TO_ROOT_ELEMENT` map with an optional, caller-supplied `elements` list on the resolve context, ordered low to high precedence so `h2` wins over `heading`. This keeps the engine agnostic of block-to-element mappings and removes core block names from the package. The element list joins the memo cache key so two heading levels no longer share an entry. - block-editor: map block name + `level` to element keys for the heading family (`core/heading`, `site-title`, `post-title`, `query-title`, `comments-title`, `term-name`, `site-tagline`). A level of 0 (a block rendered as `

`) folds no heading layer. `clientId` is read from the block edit context, so `useResolvedStyle`'s signature is unchanged and no new store subscription is added. Co-Authored-By: Claude Opus 4.8 --- packages/block-editor/CHANGELOG.md | 1 + .../global-styles/inherited-value-context.js | 84 ++++++++++++-- packages/global-styles-engine/CHANGELOG.md | 2 +- .../global-styles-engine/src/resolve-style.ts | 69 ++++++------ .../src/test/resolve-style.test.ts | 104 ++++++++++++++++++ 5 files changed, 215 insertions(+), 45 deletions(-) diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 3d0e9199054ace..34a253f68706af 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -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 diff --git a/packages/block-editor/src/components/global-styles/inherited-value-context.js b/packages/block-editor/src/components/global-styles/inherited-value-context.js index 8017dcc1308f0d..8d8c7432dd22b8 100644 --- a/packages/block-editor/src/components/global-styles/inherited-value-context.js +++ b/packages/block-editor/src/components/global-styles/inherited-value-context.js @@ -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 ); @@ -43,32 +44,85 @@ function useRawGlobalStyles() { ); } +/** + * Blocks whose canvas rendering is driven by root-level Global Styles + * *element* styles rather than (or in addition to) the block's own class. + * + * Each entry returns the element keys that paint the block, ordered low to + * high precedence, so a level-specific `h2` correctly wins over the generic + * `heading`. + * + * A level of `0` (e.g. Site Title or Post Title rendered as a paragraph) + * renders no heading tag at all, so neither the generic `heading` nor any + * `h1`-`h6` element styles reach the block and no element layer applies. + * + * @param {string} blockName Block name. + * @param {?number} headingLevel Block's `level` attribute, when it has one. + * @return {string[]} Ordered element keys. + */ +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': + 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 ] ); } @@ -93,7 +147,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( () => { @@ -103,8 +160,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, + ] ); } diff --git a/packages/global-styles-engine/CHANGELOG.md b/packages/global-styles-engine/CHANGELOG.md index a24bae8c58b0dc..110f82b1f72ab7 100644 --- a/packages/global-styles-engine/CHANGELOG.md +++ b/packages/global-styles-engine/CHANGELOG.md @@ -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. - 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) diff --git a/packages/global-styles-engine/src/resolve-style.ts b/packages/global-styles-engine/src/resolve-style.ts index dc3388f1f203eb..2fd00919e61798 100644 --- a/packages/global-styles-engine/src/resolve-style.ts +++ b/packages/global-styles-engine/src/resolve-style.ts @@ -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 @@ -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 ) { @@ -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. @@ -410,6 +407,7 @@ function computeResolvedStyle( { blockName, variationName = null, + elements = null, viewport = null, pseudoState = null, }: ResolveStyleContext = {} @@ -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 ) + .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 @@ -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 ), @@ -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( @@ -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; } diff --git a/packages/global-styles-engine/src/test/resolve-style.test.ts b/packages/global-styles-engine/src/test/resolve-style.test.ts index 4e907a4b709c0a..881f80864a0bb9 100644 --- a/packages/global-styles-engine/src/test/resolve-style.test.ts +++ b/packages/global-styles-engine/src/test/resolve-style.test.ts @@ -336,6 +336,7 @@ describe( 'resolveStyle – merged output', () => { }; const { value, sources } = resolveStyle( gs, { blockName: 'core/button', + elements: [ 'button' ], } ); // Element styles surface as the block's own inherited values, so // the Typography/Background/Border controls reflect the canvas. @@ -363,6 +364,7 @@ describe( 'resolveStyle – merged output', () => { }; const { value, sources } = resolveStyle( gs, { blockName: 'core/button', + elements: [ 'button' ], } ); expect( value.color.text ).toBe( 'blockText' ); expect( sources[ 'color.text' ]?.layer ).toBe( 'block' ); @@ -378,6 +380,7 @@ describe( 'resolveStyle – merged output', () => { }; const { value } = resolveStyle( gs, { blockName: 'core/heading', + elements: [ 'heading' ], } ); expect( value.typography.fontWeight ).toBe( '700' ); } ); @@ -409,10 +412,79 @@ describe( 'resolveStyle – merged output', () => { }; const { value } = resolveStyle( gs, { blockName: 'core/button', + elements: [ 'button' ], ...HOVER_STATE, } ); expect( value.color.background ).toBe( 'elementHover' ); } ); + + test( 'level-specific `h2` element wins over the generic `heading` element', () => { + const gs = { + styles: { + elements: { + heading: { + typography: { + fontWeight: '700', + lineHeight: '1.4', + }, + }, + h2: { typography: { fontWeight: '900' } }, + }, + }, + }; + // `elements` is ordered low to high precedence, so the level layer + // (`h2`) overrides the shared `heading` layer for the leaves it + // sets, while `heading`-only leaves still surface. + const { value, sources } = resolveStyle( gs, { + blockName: 'core/heading', + elements: [ 'heading', 'h2' ], + } ); + expect( value.typography.fontWeight ).toBe( '900' ); + expect( value.typography.lineHeight ).toBe( '1.4' ); + expect( sources[ 'typography.fontWeight' ]?.layer ).toBe( + 'element' + ); + } ); + + test( 'no element layer folds when the caller passes an empty array (level 0)', () => { + // A Site/Post Title at level 0 renders `

`, so the caller maps it + // to no element keys and neither `heading` nor `hN` should fold. + const gs = { + styles: { + elements: { + heading: { typography: { fontWeight: '700' } }, + h2: { typography: { fontWeight: '900' } }, + }, + }, + }; + const { value } = resolveStyle( gs, { + blockName: 'core/post-title', + elements: [], + } ); + expect( value.typography?.fontWeight ).toBeUndefined(); + } ); + + test( 'block-type styles still override the folded element layers', () => { + const gs = { + styles: { + elements: { + heading: { typography: { fontWeight: '700' } }, + h2: { typography: { fontWeight: '900' } }, + }, + blocks: { + 'core/heading': { + typography: { fontWeight: '400' }, + }, + }, + }, + }; + const { value, sources } = resolveStyle( gs, { + blockName: 'core/heading', + elements: [ 'heading', 'h2' ], + } ); + expect( value.typography.fontWeight ).toBe( '400' ); + expect( sources[ 'typography.fontWeight' ]?.layer ).toBe( 'block' ); + } ); } ); describe( 'explicit-empty normalization', () => { @@ -688,6 +760,38 @@ describe( 'resolveStyle – memoization', () => { } ); expect( a ).toEqual( {} ); } ); + + test( 'different `elements` arrays key distinct cache entries', () => { + // Two heading levels share a block name and payload, so the element + // list must take part in the cache key or they would collide. + const gs = { + styles: { + elements: { + heading: { typography: { fontWeight: '700' } }, + h1: { typography: { fontWeight: '900' } }, + h2: { typography: { fontWeight: '400' } }, + }, + }, + }; + const h1 = resolveStyle( gs, { + blockName: 'core/heading', + elements: [ 'heading', 'h1' ], + } ); + const h2 = resolveStyle( gs, { + blockName: 'core/heading', + elements: [ 'heading', 'h2' ], + } ); + expect( h1 ).not.toBe( h2 ); + expect( h1.value.typography.fontWeight ).toBe( '900' ); + expect( h2.value.typography.fontWeight ).toBe( '400' ); + + // Identical element lists still return the memoized identity. + const h2Again = resolveStyle( gs, { + blockName: 'core/heading', + elements: [ 'heading', 'h2' ], + } ); + expect( h2Again ).toBe( h2 ); + } ); } ); describe( 'resolveStyle – non-cascading root drop', () => { From ad93ca6c4b02dd8ef89f8a3c49428787eead2fce Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:16:26 +1000 Subject: [PATCH 2/5] Update comment --- .../global-styles/inherited-value-context.js | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/block-editor/src/components/global-styles/inherited-value-context.js b/packages/block-editor/src/components/global-styles/inherited-value-context.js index 8d8c7432dd22b8..71678ee65079e4 100644 --- a/packages/block-editor/src/components/global-styles/inherited-value-context.js +++ b/packages/block-editor/src/components/global-styles/inherited-value-context.js @@ -45,20 +45,33 @@ function useRawGlobalStyles() { } /** - * Blocks whose canvas rendering is driven by root-level Global Styles - * *element* styles rather than (or in addition to) the block's own class. + * 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 `

`. * - * Each entry returns the element keys that paint the block, ordered low to - * high precedence, so a level-specific `h2` correctly wins over the generic - * `heading`. + * 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. * - * A level of `0` (e.g. Site Title or Post Title rendered as a paragraph) - * renders no heading tag at all, so neither the generic `heading` nor any - * `h1`-`h6` element 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. + * @return {string[]} Ordered element keys, low to high precedence. */ function getElementLayers( blockName, headingLevel ) { switch ( blockName ) { From 8451e7ecdcd3ca62887c057875087444dbce30ce Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:26:11 +1000 Subject: [PATCH 3/5] Try to fix tests --- .../global-styles/test/inherited-value-context.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/block-editor/src/components/global-styles/test/inherited-value-context.js b/packages/block-editor/src/components/global-styles/test/inherited-value-context.js index 5ab733823739c7..38215e19efca37 100644 --- a/packages/block-editor/src/components/global-styles/test/inherited-value-context.js +++ b/packages/block-editor/src/components/global-styles/test/inherited-value-context.js @@ -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 ) => @@ -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: () => ( {} ), } ) ) ); } @@ -226,6 +231,7 @@ describe( 'useResolvedStyle – production bare-tree shape', () => { [ globalStylesDataKey ]: rawGlobalStyles, } ), getBlockStyles: () => [], + getBlockAttributes: () => ( {} ), } ) ) ); return render( ui ); From fb652035ae716c1c2c1dedfb0a7dbd343fe27a4a Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:46:26 +1000 Subject: [PATCH 4/5] Handle accordion heading --- packages/block-editor/CHANGELOG.md | 2 +- .../global-styles/inherited-value-context.js | 93 +++++++++++++++---- .../test/inherited-value-context.js | 82 +++++++++++++++- 3 files changed, 152 insertions(+), 25 deletions(-) diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 34a253f68706af..5d2327e993424a 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -5,7 +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. +- 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`, `core/accordion-heading`), 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; `core/accordion-heading` has no level-0 state and takes its level from the parent Accordion's block context ([#80495](https://github.com/WordPress/gutenberg/pull/80495)). ### Bug Fixes diff --git a/packages/block-editor/src/components/global-styles/inherited-value-context.js b/packages/block-editor/src/components/global-styles/inherited-value-context.js index 71678ee65079e4..d35ff102715539 100644 --- a/packages/block-editor/src/components/global-styles/inherited-value-context.js +++ b/packages/block-editor/src/components/global-styles/inherited-value-context.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useMemo } from '@wordpress/element'; +import { useContext, useMemo } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { store as blocksStore } from '@wordpress/blocks'; import { privateApis as globalStylesEnginePrivateApis } from '@wordpress/global-styles-engine'; @@ -16,6 +16,7 @@ import { } from '../../store/private-keys'; import { getVariationNameFromClass } from '../../hooks/block-style-variation'; import { useBlockEditContext } from '../block-edit/context'; +import BlockContext from '../block-context'; import { unlock } from '../../lock-unlock'; const { resolveStyle } = unlock( globalStylesEnginePrivateApis ); @@ -44,39 +45,86 @@ function useRawGlobalStyles() { ); } +/** + * Blocks whose heading level is handed down by a parent through block context + * rather than held in their own `level` attribute, mapped to that context key + * and the level they fall back to. + * + * Accordion Heading is the only one: the parent Accordion owns the level, and + * the child's `level` attribute has no default and is absent on pattern-, + * template- and paste-inserted blocks. + */ +const CONTEXT_HEADING_LEVEL_BLOCKS = { + 'core/accordion-heading': { + contextKey: 'core/accordion-heading-level', + fallbackLevel: 3, + }, +}; + +/** + * Resolves the heading level that selects a block's `h1`-`h6` element layer. + * + * @param {string} blockName Block name. + * @param {?number} levelAttribute The block's own `level` attribute, if it has one. + * @param {?number} contextLevel Level supplied by a parent through block context. + * @return {?number} Resolved heading level, or `undefined` when the block has none. + */ +function getHeadingLevel( blockName, levelAttribute, contextLevel ) { + const contextOwned = CONTEXT_HEADING_LEVEL_BLOCKS[ blockName ]; + if ( ! contextOwned ) { + return levelAttribute; + } + // Mirrors the block's own chain (Accordion Heading saves `h${ level || 3 }`), + // so `0` coerces to the fallback instead of reading as a level-0 state. + return levelAttribute || contextLevel || contextOwned.fallbackLevel; +} + +/** + * Reads the heading level a parent supplies through block context. + * + * Only the blocks in `CONTEXT_HEADING_LEVEL_BLOCKS` read a key: the value + * reaches every descendant, so an ordinary Heading inside an Accordion Panel + * must not pick it up. Returns a primitive so callers can use it as a + * `useSelect` dependency. + * + * @param {?string} blockName Block name. + * @return {?number} Level from block context, or `undefined`. + */ +function useContextHeadingLevel( blockName ) { + const blockContext = useContext( BlockContext ); + const contextKey = CONTEXT_HEADING_LEVEL_BLOCKS[ blockName ]?.contextKey; + return contextKey ? blockContext[ contextKey ] : undefined; +} + /** * 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 `

`. * - * 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. + * Keys are ordered low to high precedence, so a level-specific `h2` wins over + * the generic `heading`. A level of `0` (e.g. a Site or Post Title rendered as + * a paragraph) renders no heading tag, so 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 + * Hand-maintained by necessity: the block-to-element relationship is not + * inferable from block metadata. `supports.color.link` and friends mark blocks + * that *contain* an element, not blocks that *are* one, and only the inverse + * element-to-selector map exists (`__EXPERIMENTAL_ELEMENTS`). Standardizing it + * as a 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. + * The link-bearing heading blocks get no `link` layer: their inner-link control + * reads the `inheritedValue.elements.link` passthrough, so folding `link` in + * would bleed link color into the heading's own text. * * @param {string} blockName Block name. - * @param {?number} headingLevel Block's `level` attribute, when it has one. + * @param {?number} headingLevel Resolved heading level, from `getHeadingLevel`. * @return {string[]} Ordered element keys, low to high precedence. */ function getElementLayers( blockName, headingLevel ) { switch ( blockName ) { case 'core/button': return [ 'button' ]; + case 'core/accordion-heading': case 'core/heading': case 'core/post-title': case 'core/site-title': @@ -117,6 +165,7 @@ function getElementLayers( blockName, headingLevel ) { */ function useVariationAndElements( blockName, className ) { const { clientId } = useBlockEditContext(); + const contextHeadingLevel = useContextHeadingLevel( blockName ); return useSelect( ( select ) => { if ( ! blockName ) { @@ -132,10 +181,14 @@ function useVariationAndElements( blockName, className ) { variationName: className ? getVariationNameFromClass( className, registeredStyles ) : null, - headingLevel: level, + headingLevel: getHeadingLevel( + blockName, + level, + contextHeadingLevel + ), }; }, - [ blockName, className, clientId ] + [ blockName, className, clientId, contextHeadingLevel ] ); } diff --git a/packages/block-editor/src/components/global-styles/test/inherited-value-context.js b/packages/block-editor/src/components/global-styles/test/inherited-value-context.js index 38215e19efca37..c9ff7c4507dad8 100644 --- a/packages/block-editor/src/components/global-styles/test/inherited-value-context.js +++ b/packages/block-editor/src/components/global-styles/test/inherited-value-context.js @@ -12,6 +12,7 @@ import { useSelect } from '@wordpress/data'; * Internal dependencies */ import { useResolvedStyle } from '../inherited-value-context'; +import { BlockContextProvider } from '../../block-context'; import { globalStylesDataKey } from '../../../store/private-keys'; @@ -84,7 +85,10 @@ describe( 'useResolvedStyle hook', () => { // The hook issues two `useSelect` reads: the merged Global Styles // payload (block-editor store) and the block's registered styles // (blocks store). Feed both from one stub. - function mockStores( rawGlobalStyles, blockStyles = [] ) { + function mockStores( + rawGlobalStyles, + { blockStyles = [], attributes = {} } = {} + ) { useSelect.mockImplementation( ( mapSelect ) => mapSelect( () => ( { // `globalStylesDataKey` holds the BARE merged styles tree @@ -98,13 +102,83 @@ describe( 'useResolvedStyle hook', () => { } ), 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: () => ( {} ), + // heading element layers. No block edit context is provided + // here, so `clientId` is undefined; the stub returns whatever + // attributes the test supplies regardless. + getBlockAttributes: () => attributes, } ) ) ); } + // Resolves a block's styles as rendered inside an Accordion, which supplies + // the heading level to every descendant through block context. + function resolveInAccordion( blockName, level = 3 ) { + render( + + + + ); + return JSON.parse( screen.getByTestId( 'probe' ).textContent ); + } + + // Accordion Heading resolves its level from the parent Accordion's block + // context, not from its own `level` attribute, and has no level-0 state. + test( 'accordion-heading takes its level from block context', () => { + mockStores( { + elements: { + heading: { color: { text: '#111111' } }, + h3: { typography: { fontSize: '19px' } }, + }, + } ); + const parsed = resolveInAccordion( 'core/accordion-heading' ); + expect( parsed.value.typography.fontSize ).toBe( '19px' ); + expect( parsed.value.color.text ).toBe( '#111111' ); + } ); + + test( 'accordion-heading with no context still resolves to h3', () => { + mockStores( { + elements: { h3: { typography: { fontSize: '19px' } } }, + } ); + render( ); + const parsed = JSON.parse( screen.getByTestId( 'probe' ).textContent ); + expect( parsed.value.typography.fontSize ).toBe( '19px' ); + } ); + + test( "accordion-heading's own level attribute wins over block context", () => { + mockStores( + { + elements: { + h3: { typography: { fontSize: '19px' } }, + h5: { typography: { fontSize: '13px' } }, + }, + }, + { attributes: { level: 5 } } + ); + const parsed = resolveInAccordion( 'core/accordion-heading' ); + // The attribute is what the front end serializes (`save.js` renders + // `h${ level || 3 }`), so it takes precedence when the two disagree. + expect( parsed.value.typography.fontSize ).toBe( '13px' ); + } ); + + // An ordinary Heading can be placed inside an Accordion Panel, which puts + // `core/accordion-heading-level` in its block context. Only the blocks that + // opt in may read that key. + test( 'a plain heading ignores an accordion level in block context', () => { + mockStores( + { + elements: { + h2: { typography: { fontSize: '24px' } }, + h3: { typography: { fontSize: '19px' } }, + }, + }, + { attributes: { level: 2 } } + ); + const parsed = resolveInAccordion( 'core/heading' ); + expect( parsed.value.typography.fontSize ).toBe( '24px' ); + } ); + test( 'returns empty value and sources when no block name is given', () => { mockStores( { typography: { fontSize: '16px' } } ); render( ); From f1a60ffe27d7edba863ec604b0c10a3144f6c6c3 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:01:02 +1000 Subject: [PATCH 5/5] ADd a test for the 0 heading level case, tidy up changelog --- .../test/inherited-value-context.js | 23 +++++++++++++++++++ packages/global-styles-engine/CHANGELOG.md | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/global-styles/test/inherited-value-context.js b/packages/block-editor/src/components/global-styles/test/inherited-value-context.js index c9ff7c4507dad8..7cd61b37c985b4 100644 --- a/packages/block-editor/src/components/global-styles/test/inherited-value-context.js +++ b/packages/block-editor/src/components/global-styles/test/inherited-value-context.js @@ -179,6 +179,29 @@ describe( 'useResolvedStyle hook', () => { expect( parsed.value.typography.fontSize ).toBe( '24px' ); } ); + // A title block at level 0 renders a paragraph, so no heading element styles + // reach it. `0` is the one falsy level, and an `||` anywhere on the path + // from the `level` attribute to `getElementLayers` would silently turn it + // into "no level given" and wrongly fold the generic `heading` layer. + test( 'a title block at level 0 folds no heading element styles', () => { + mockStores( + { + typography: { lineHeight: '1.6' }, + elements: { + heading: { typography: { fontSize: '30px' } }, + h2: { typography: { fontSize: '24px' } }, + }, + }, + { attributes: { level: 0 } } + ); + render( ); + const parsed = JSON.parse( screen.getByTestId( 'probe' ).textContent ); + expect( parsed.value.typography.fontSize ).toBeUndefined(); + // Root styles still resolve, so the assertion above means "no heading + // layer", not "nothing resolved at all". + expect( parsed.value.typography.lineHeight ).toBe( '1.6' ); + } ); + test( 'returns empty value and sources when no block name is given', () => { mockStores( { typography: { fontSize: '16px' } } ); render( ); diff --git a/packages/global-styles-engine/CHANGELOG.md b/packages/global-styles-engine/CHANGELOG.md index 110f82b1f72ab7..b44390585f2d9e 100644 --- a/packages/global-styles-engine/CHANGELOG.md +++ b/packages/global-styles-engine/CHANGELOG.md @@ -4,8 +4,9 @@ ### New Features -- 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. +- 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 `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). +- `resolveStyle` now folds root-level element layers, 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 ([#80495](https://github.com/WordPress/gutenberg/pull/80495)). ## 1.18.0 (2026-07-14)