Skip to content

Global Styles: explore the inheritance cascade in the style panels' options menu - #81044

Closed
JosVelasco wants to merge 3 commits into
WordPress:trunkfrom
JosVelasco:try/global-styles-inheritance-panel-menu
Closed

JosVelasco wants to merge 3 commits into
WordPress:trunkfrom
JosVelasco:try/global-styles-inheritance-panel-menu

Conversation

@JosVelasco

@JosVelasco JosVelasco commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Important note

This is an exploration, opened as a draft to keep the conversation going. It is not proposed for merge as-is — see Known gaps, one of which is architectural and one of which is a deliberate behaviour change. It builds on #80649, #80993 and @jasmussen's comment. Take it over, cherry-pick from it, or close it.

What?

@jasmussen, on #80993:

one of the primary challenges with the previous implementations was where to put this indicator so that it could be accessible on mobile with a tap as well: given the mix of controls with labels and ItemGroup controls with flyouts, that was the primary challenge. Some of the concepts discussed late in the thread suggest moving away from that dot indicator and more towards an element that can live inside the ellipsis dropdown for style control panels: there could be something there too.

This is that, and it replaces the per-control dot rather than sitting alongside it. Nothing is rendered at rest — not on the control rows, not on the panel's options toggle. Inherited values simply read as values. Open a style panel's options menu and each control that overrides an inherited value shows its cascade under its own row, beside the Reset that already puts the value back:

TYPOGRAPHY
  Color                          RESET
     This block               #503AA8
     S̶i̶t̶e̶-̶w̶i̶d̶e̶                #̶2̶E̶2̶A̶2̶4̶
  ─────────────────────────────────────
  Size                           RESET
     Custom CSS                   3rem
     T̶h̶i̶s̶ ̶b̶l̶o̶c̶k̶                   5̶2̶p̶x̶
     S̶u̶b̶t̶i̶t̶l̶e̶ ̶s̶t̶y̶l̶e̶      1̶.̶5̶ ̶–̶ ̶1̶.̶7̶5̶r̶e̶m̶
     S̶i̶t̶e̶-̶w̶i̶d̶e̶              1̶.̶1̶8̶7̶5̶r̶e̶m̶
  ─────────────────────────────────────
  Font
  Appearance
global-styles-inheritance-panels.mp4

Uses the existing gutenberg-global-styles-inheritance-ui experiment — no new flag, and no PHP changes at all. To compare against the per-control dot, compare against trunk.

Why this shape

No new action. The menu already lists every control and already offers each one a Reset — which for a control holding a local value over an inherited one is the "put it back" action. So nothing is added to the menu; the cascade slots in under the row that already exists. An earlier revision had a bulk Restore inherited styles and it became redundant the moment the rows moved under their controls, taking a staged-restore queue with it.

Values, not a marker. A one-bit indicator can say overridden but not what was overridden, what the value would fall back to, or where that comes from — the questions that send people to the browser inspector. A menu row has space for all three; a control row does not. That is the argument for this placement, more than the mobile one.

Nothing at rest is a deliberate trade. @jasmussen: "I'd personally also be fine with—to start—landing with nothing but the actual inheritance shown as values: no indicators at all." The cost is discoverability: nothing announces that a block overrides anything, and you find out by opening the menu. Every at-rest marker so far has had to answer "where does it go on a row that is entirely a flyout toggle, and how is it tapped on mobile" — a placement with no marker doesn't have to.

How?

Engine. resolveStyle keeps the full per-path cascade (cascade: Record<path, CascadeEntry[]>) instead of collapsing to the winning layer. sources is unchanged. Layers carry structural metadata rather than pre-built strings, so the UI composes and translates the labels. Ported unchanged from #80993 — this is its second consumer, and an argument for landing it as its own PR.

Labels name scope, so reading down narrows it: Site-wide → All Paragraph blocks → Subtitle style → This block → Custom CSS. Deliberately not "From block settings" and friends: that names where a value is stored rather than how far it reaches, a different question from the one every other row answers.

Values are shortened to fit a menu row. Colour presets resolve to their hex — a slug names the preset but not what it paints. Fluid typography's clamp() collapses to 1.5 – 1.75rem rather than a 50-character viewport formula.

Paths. InheritanceToolsPanelItem takes a stylePaths prop (Appearance passes two, element colours three), wired at all 26 call sites.

Cascade resolves on the item side. The block inspector renders panels outside the selected block's edit context, so only the items — slot fills in the block's tree — know which block they belong to. They publish into a registry the panel reads, forwarded across the bubblesVirtually boundary the way ToolsPanelContext already is. Only computed while a control actually overrides something, and propagated on a value signature rather than array identity, so typing doesn't re-render the panel.

What this removes

Because the placement replaces rather than supplements, InheritanceResetButton and its styles are deleted, and the four controls that swapped their ordinary reset for the dot — colour/gradient, shadow, duotone, background image — get that reset back unconditionally. That incidentally resolves @t-hamano's point on #80649 that colour controls lost their reset in order to gain an indicator.

has-local-override-from-global-styles is kept as a class hook but now paints nothing — it is where a future at-rest treatment would attach.

Accessibility

Audited in the browser, not assumed:

  • The cascade is an aria-hidden sibling referenced by the item's aria-describedby. role="menu" keeps only menu items as children, and the accessible name stays Reset Size — nesting would have folded the cascade into the name and made every overriding control unqueryable by its own label.
  • Each row carries visually-hidden state: "Custom CSS 3rem in effect. This block 52px overridden…". Without it the distinction was strikethrough-only, and a screen reader heard four values with no way to tell which applied.
  • Covered layers measure 4.61:1, in-effect 16.67:1. An earlier opacity: 0.7 put these at 2.67:1 and was removed; the strikethrough carries the meaning and is not a colour-only encoding.
  • Tap targets: menu rows 226×32–34, panel toggle 24×24 — both meet WCAG 2.2 SC 2.5.8. The whole row is the target, not the RESET text.
  • Arrow-key navigation skips the description; nothing focusable inside it.

Testing instructions

  1. Enable Global Styles inheritance in the block inspector on Gutenberg → Experiments.
  2. Add a Paragraph, apply the Subtitle block style, set a font size and a text colour.
  3. Open the Typography panel's ⋮ menu — each overriding control shows its cascade.
  4. Reset on a row returns it to the next layer down, not to the bottom of the stack.
  5. Compare against trunk for the per-control dot this replaces.

Known gaps

  • This needs a ToolsPanel API change, and Properly support Global Styles inheritance indicators UI #80652 says not to make one. That issue states abstractions should stay local to Global Styles and that higher-level form component APIs should not gain support for this pattern while the design is unsettled. This adds __experimentalMenuItemDescription to ToolsPanel in @wordpress/components — a shared component used by every block's inspector. I could not find a way to do it locally: the menu is generated from ToolsPanel's internal item registry, dropdownMenuProps cannot reach its children, and the header is not replaceable. That cost is itself a finding: the per-control indicator lives in the control row, which Global Styles already owns; this menu belongs to ToolsPanel. If this direction is pursued it needs either a sanctioned extension point or a local reconstruction of the panel header.
  • It removes a visible part of Global Styles: Show a local-override diamond indicator for inherited values and ship them to Core #80649's merged work. 16 tests asserting the dot were rewritten to assert the new behaviour. If the dot should stay, this needs rethinking rather than rebasing.
  • Custom-CSS-only overrides are covered; theme/plugin stylesheet overrides are not. The computed-style check from Global Styles: explore a cascade popover for inherited-value overrides #80993 was deliberately left out — it had known false positives.
  • The en-dash in 1.5 – 1.75rem is swallowed by the strikethrough on losing layers.
  • The mobile rendering path is unverified. Tap target sizes are viewport-independent and were measured, but below 782px useToolsPanelDropdownMenuProps drops its popover placement and the menu positions differently. Needs a real device pass — which matters, since mobile reachability is this exploration's premise.
  • No CHANGELOG.md entries, deliberately.

AI disclosure

  • AI assistance: Yes
  • Tool(s) used: Claude Code (Opus 5)
  • What AI generated: the implementation, tests, and this description.
  • What was human-reviewed: direction, UX decisions, and behaviour were driven and reviewed interactively against a running editor throughout. Several defects were found only by that review — the panel the block inspector actually uses was not the one first wired, React context did not cross the bubblesVirtually slot boundary, a separator overlapped a focused item's ring, and the contrast failure above. Treat the code as exploratory.

`resolveStyle` collapsed every contributing layer down to the winning one,
keeping only that layer in `sources`. An origin view needs the layers it beat
in order to explain why a value is what it is, so record them.

`cascade` is a new key on the returned object, ordered low to high precedence
per leaf path, with the winner flagged. `sources` and `value` are unchanged, so
nothing downstream is affected. Layers carry structural metadata (block name,
element, variation) rather than a pre-built string, leaving the engine
framework- and i18n-agnostic.

Root-level non-cascading paths are dropped from `cascade` alongside `value` and
`sources`; where that removes the winner, the highest remaining layer is
promoted so each path keeps exactly one.

Ported unchanged from WordPress#80993.
Adds `__experimentalMenuItemDescription`, a `( label ) => ReactNode` callback
that the panel's options dropdown calls per generated menu item. Whatever it
returns is rendered under that item.

The content is `aria-hidden` and referenced by the item's `aria-describedby`
rather than nested inside it. That keeps `role="menu"` holding only menu items,
and keeps the description out of the item's accessible *name* — nesting would
fold it in and make each control unqueryable by its own label. Assistive
technology still reaches it, as that item's description.

A described item is bracketed by a rule above and below, matching the one above
`Reset all`. The rule is drawn as a pseudo-element so it reaches the dropdown
edges while the item's own background and focus ring keep their inset width;
back-to-back described items suppress the duplicate.

The callback is invoked within the panel's own React tree, so it can read
context provided around the `ToolsPanel`.
…ions menu

Replaces the per-control override indicator with the cascade itself, shown in
each style panel's options ("ellipsis") menu under the control it belongs to.

Nothing is rendered at rest — not in the control rows, not on the panel's menu
toggle. Inherited values read as values. Opening the menu shows, for every
control holding a local value over an inherited one, the layers behind it: the
value in effect, then each layer it covers, struck through, with the origin that
set it. The `Reset` the menu already offers that control is the action that puts
it back, so no new action is introduced.

Layer labels name scope, so reading down narrows it: Site-wide, All Paragraph
blocks, Subtitle style, This block, Custom CSS. Values are shortened to fit a
menu row — colour presets resolve to their hex, and fluid typography's `clamp()`
collapses to the range it moves between.

The cascade resolves on the panel *item* side: the block inspector renders its
panels outside the selected block's edit context, so only the items, which are
slot fills in the block's tree, know which block they belong to. They publish
into a registry the panel reads, forwarded across the `bubblesVirtually` slot
boundary the way `ToolsPanelContext` already is. It is only computed while a
control actually overrides something, and propagates on a value signature rather
than array identity, so typing does not re-render the panel.

Because the placement replaces rather than supplements, `InheritanceResetButton`
and its styles are removed, and the four controls that swapped their ordinary
reset for the dot — colour/gradient, shadow, duotone, background image — get
that reset back unconditionally.

Everything stays behind the existing `gutenberg-global-styles-inheritance-ui`
experiment.
@jasmussen

Copy link
Copy Markdown
Contributor

Thanks for this PR, for following up, and for the easily reviewable video.

Absent a few details, there's something interesting here in that the footprint is small at rest, but in the dropdown there's room to be verbose which is good. We'd want to fiddle with the details, get the typography, listing, visuals just right, and I don't think the underline is needed here either: just show the inherited value, no indicators. But all that is secondary to getting broader validation: how does the broader group feel about this pattern? CC: @WordPress/gutenberg-design.

The main question that comes to mind for me is: if the ToolsPanel dropdown will become the (sole?) interface for showing inheritance context, are there any design tools that can inherit values that are at present not shown inside a toolspanel? Nothing comes to mind at a glance, but this is key: if that's indeed the case, it feels an obvious place to show expanded context because there's room in the dropdown.

@fcoveram

fcoveram commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

It’s interesting that the approach places the reset action next to the block’s values, but I don’t see it scaling well when multiple elements display their values. Even when it shows a single value and its origin, it already overloads the menu, in addition to the current enable/disable tool and reset actions.

I like the idea of showing the current and overridden values, but a rich popover, like the one here on GitHub when hovering over the profile image, could convey the info more clearly. Basically what you did in #80993 but behaving as a tooltip that only provides information.

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

Labels

[Package] Block editor /packages/block-editor [Package] Components /packages/components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants