Skip to content

Preserve auto-fit grids, inline flex containers, and authored button font-family - #886

Merged
borkweb merged 4 commits into
trunkfrom
fix/grid-flex-carrier
Aug 17, 2026
Merged

Preserve auto-fit grids, inline flex containers, and authored button font-family#886
borkweb merged 4 commits into
trunkfrom
fix/grid-flex-carrier

Conversation

@borkweb

@borkweb borkweb commented Aug 17, 2026

Copy link
Copy Markdown
Member

Three defects that all change rendered geometry. Each was confirmed against real renders before any code changed, and each has a committed RED test that failed at the test-only commit.

Problem 1: auto-fit is converted to a grid layout WordPress renders as auto-fill

StyleResolutionTrait::autoRepeatMinimumColumnWidth() accepted repeat(auto-fit|auto-fill, minmax(W, 1fr)) and converted BOTH to a native "layout":{"type":"grid","minimumColumnWidth":"W"}.

wp-includes/block-supports/layout.php hardcodes auto-fill in every branch that renders minimumColumnWidth, so the attribute cannot express auto-fit at all. The two differ in rendered geometry: auto-fit collapses tracks left empty, auto-fill retains them. An authored auto-fit grid therefore rendered with the empty tracks kept and the real content squeezed into part of the measure.

Measured on a design fixture with three affected sections, design tracks versus delivered tracks:

Section Design (authored) Delivered before Delivered after
minmax(260px), 2 items repeat(auto-fit,minmax(260px,1fr)) = 592 592 0 0 minimumColumnWidth:260px, rendered as auto-fill = 267 267 267 267 authored track list, verbatim
minmax(270px), 3 items repeat(auto-fit,minmax(270px,1fr)) = 389 389 389 0 minimumColumnWidth:270px = 284 284 284 284 authored track list, verbatim
minmax(280px), 2 items repeat(auto-fit,minmax(280px,1fr)) = 586 586 0 minimumColumnWidth:280px = 366 366 366 authored track list, verbatim

Across that whole page: native minimumColumnWidth attributes 5 to 0, is-layout-grid classes 10 to 0, delivered auto-fit track lists 0 to 5. A fourth grid on the page has 4 items, where auto-fit and auto-fill agree, which is why only three sections were visibly wrong.

Control group: a second design fixture authors minmax(min(100%,14rem),1fr), which never matched the conversion regex. Its 6 auto-fit track lists are byte-identical before and after this change.

Problem 2: inline display:flex is dropped while the block stays marked css-owned-flow

cssOwnedGroupAttributes() drops the native layout attribute when an author flex or grid container is demoted to a css-owned core/group. Grids survive because CSS_OWNED_GRID_CARRIER_PROPERTIES rides their inline declarations to the generated stylesheet. There was no flex equivalent, so a block marked blocks-engine-css-owned-flow lost the declaration that made it a flex container, and its children stacked.

A footer in one design fixture, before and after, same carrier class:

before: .be-inline-geometry-<hash>{max-width:var(--wide-size) !important}
after:  .be-inline-geometry-<hash>{max-width:var(--wide-size) !important;display:flex !important;
        flex-wrap:wrap !important;gap:clamp(1.8rem,5vw,4rem) !important;justify-content:space-between !important}

No display rule existed anywhere in the generated stylesheet before. Three such containers on that page alone.

Problem 3: core/button drops authored font-family

ButtonStyleResolver::BUTTON_TYPOGRAPHY projected fontSize, fontWeight, letterSpacing, lineHeight and textTransform onto buttons and dropped font-family. core/button registers a fontFamily attribute (packages/blocks-engine/src/core-block-attrs.json, generated from @wordpress/block-library 9.42.0), which core injects only when the typography fontFamily support is enabled, so this was a dropped declaration rather than an unsupported one.

The authored .btn class is consumed into block attributes, so its surviving rule is rewritten behind :where() at zero specificity and matches nothing that can win. theme.json's styles.elements.button.typography.fontFamily then substituted the theme typeface, widening hero CTAs enough to wrap them onto two lines.

Buttons on one design fixture, before and after:

before: 8 buttons, no fontFamily attribute at all
after:  8 buttons carrying "Trebuchet MS", "Segoe UI", system-ui, -apple-system, Roboto, "Helvetica Neue", Arial, sans-serif

This has to be fixed per authored declaration rather than by a blanket downstream change: across seven design fixtures the .btn family matches the body family in three, and differs in four. One fixture whose buttons are correct today only because the theme preset happens to match now carries its authored stack explicitly ("Archivo", ..., its own heading stack, resolved), so it stays correct rather than being correct by coincidence.

Fix

  1. autoRepeatMinimumColumnWidth() now matches auto-fill only. auto-fit returns '' and falls through to the same css-owned-grid carrier that already holds every other track list WordPress cannot express, so the author rule stays the single owner of the exact authored track list. auto-fill keeps converting natively.
  2. CSS_OWNED_FLEX_CARRIER_PROPERTIES, isCssOwnedFlexElement() and cssOwnedFlexAttributes() mirror the grid carrier for flex containers. An inline display that overrides class-owned layout is skipped, because Preserve inline display overrides through the geometry carrier #883 already carries those properties complete at the non-important specificity tier; forcing them would move them to the important tier and change that output.
  3. fontFamily joins the button typography subset. A raw authored family is a custom value rather than a preset slug, so it rides in style.typography.fontFamily. BlockFactory's typography map did not map that attribute either, so the block attribute alone never reached the rendered link; the serializer now emits font-family inline the way core's style engine does. This part is deliberately broad rather than button-scoped: the map is the generic serializer, and the drop affected any block that mapped the declaration.

Inline style outranks theme.json element styles, so no !important bridge was added for the font-family path.

Verification

Local, on this branch rebased onto c0d66eafe:

  • composer test:unit exit 0, 39 scripts, 0 failures.
  • composer test exit 0, 51 scripts, 0 failures, 275 parity fixtures.
  • New contracts: auto-fit-grid-carrier.php 11 assertions, css-owned-flex-carrier.php 12, button-font-family-carry.php 9.
  • RED evidence at the test-only commits: 6 failed / 5 passed, 6 failed / 6 passed, and 5 failed / 4 passed respectively.
  • block-style-support-conversion.php 130 assertions after resolving the three-way append collision as a union. Trunk's own copy of that file reports 129 assertions, so the union is trunk's 129 plus the one assertion added here, with neither side dropped.

Seven parity fixtures changed, each because it encoded one of the defects rather than to make a test pass. Three asserted the auto-fit to native conversion, one of which was renamed because its name asserted the buggy contract. Four asserted flex output in which the authored display:flex had already been dropped, including one whose own description claimed a horizontal layout it was not getting. No parity fixture changed for the font-family fix.

Rendered-class inventory across the 89-site corpus, comparing this branch against c0d66eafe: zero has-*, is-layout-*, wp-block-* or wp-element-* class tokens are ADDED anywhere. Every class-token change is a removal of is-layout-grid / wp-block-group-is-layout-grid on containers that should never have had core grid layout. This is the specific failure shape from #885, where has-border-color triggered core's all-sides border-style:solid and painted phantom borders; nothing here emits a class that makes WordPress paint something the author never wrote.

Solved-gate fixtures, transformed output compared against c0d66eafe:

  • 89-static-site-importer-architecture: byte-identical.
  • 15-saas: differs only by 8 rendered font-family declarations on buttons, value 'IBM Plex Mono', 'Courier New', monospace. That fixture's source authors .btn { font-family: var(--font-mono) } with --font-mono: 'IBM Plex Mono', 'Courier New', monospace, so the delivered typeface now equals the source's. No auto-fit, flex-carrier or is-layout-grid change on either fixture.

The Solved Site Promotion Gate cannot be run locally, since it lives in another repository and needs a WordPress rendering rig. It is the gate that matters most here, because all three defects change rendered geometry and static parity compares output structure rather than pixels. It is left to CI on this PR to arbitrate at the 0.00% threshold.

Static style parity, this branch against c0d66eafe

Run directly rather than through composer, against a baseline worktree whose vendor/ was copied rather than symlinked, since a symlinked vendor makes Composer's $baseDir resolve to the other tree and silently invalidates the comparison. Both trees were checked by reflection before the runs to confirm they resolved to their own sources.

Not byte-identical. Across 87 fixtures: mean score 0.664049 to 0.667159 (+0.003109), total findings 12385 to 12313 (-72), no status transitions. 23 fixtures moved: 16 improved, largest 69-markdown-editor 0.2704 to 0.5039 with findings 173 to 116, and 7 regressed between -0.0028 and -0.0012.

All 7 regressions are font-family findings, and all are comparator artifacts rather than fidelity losses:

  • In 4 of them the source side reads var(--X) while the candidate reads the literal value of --X. Each variable was resolved by hand and is byte-equal, for example --sans: 'Space Grotesk', system-ui, sans-serif against a candidate value of 'Space Grotesk', system-ui, sans-serif. The comparator does a string comparison and cannot resolve var().
  • In the other 3 the findings land on a <span> inside a button. Each of those fixtures' .btn rule does author the family the candidate now carries. The comparator resolves the inner span's inherited family to the document default instead of inheriting from .btn, so the candidate is the more faithful side.

The pixel-level check that this signal cannot provide is the Solved Site Promotion Gate, which is why that gate is the one that decides here.

## Summary
Adds two deliberately RED contracts, one per confirmed defect: `repeat(auto-fit, …)` track lists must not be converted to a native WordPress grid layout attribute, and a flex container demoted to a css-owned core/group must keep its authored `display:flex`.

## Why
`StyleResolutionTrait::autoRepeatMinimumColumnWidth()` accepts both `auto-fit` and `auto-fill` and converts both to `layout.minimumColumnWidth`. `wp-includes/block-supports/layout.php` hardcodes `auto-fill` in every branch that renders that attribute, so an authored `auto-fit` grid renders with empty tracks retained and the content crammed into part of the measure.

`cssOwnedGroupAttributes()` drops the native `layout` attribute on demotion. Grids survive because `CSS_OWNED_GRID_CARRIER_PROPERTIES` rides their inline declarations to the generated stylesheet; there is no flex equivalent, so the block is marked `blocks-engine-css-owned-flow` while the declaration that makes it a flex container is dropped and the children stack.

## How
`tests/unit/auto-fit-grid-carrier.php` covers inline and class-owned `auto-fit` track lists plus `auto-fill` controls proving the native conversion is retained, not abandoned. `tests/unit/css-owned-flex-carrier.php` covers inline row and column flex containers plus controls for a container with no authored display and for a class-owned flex declaration the author stylesheet already owns.

## Testing
RED evidence at this commit: `php tests/unit/auto-fit-grid-carrier.php` exits 1 with 6 failed and 5 passed assertions; `php tests/unit/css-owned-flex-carrier.php` exits 1 with 6 failed and 6 passed assertions.
… intact

Restricts the native grid-layout conversion to `auto-fill`, and carries the inline flex declarations of a container demoted to a css-owned core/group.

`autoRepeatMinimumColumnWidth()` accepted `auto-fit` and `auto-fill` alike and converted both to `layout.minimumColumnWidth`. `wp-includes/block-supports/layout.php` hardcodes `auto-fill` in every branch that renders that attribute, so an authored `auto-fit` grid rendered with the empty tracks `auto-fit` collapses still retained, squeezing the content into part of the measure. Measured against a design, delivered tracks came back `267 267 267 267` where the design resolved `592 592 0 0`.

`cssOwnedGroupAttributes()` drops the native `layout` attribute on demotion. Grids survive because `CSS_OWNED_GRID_CARRIER_PROPERTIES` rides their inline declarations to the generated stylesheet. There was no flex equivalent, so a container marked `blocks-engine-css-owned-flow` lost the declaration that made it a flex container and its children stacked.

`auto-fit` now returns '' from the conversion and falls through to the same css-owned-grid carrier that already holds every other track list WordPress cannot express, so the author rule stays the single owner of the exact authored track list. `auto-fill` keeps converting natively.

`CSS_OWNED_FLEX_CARRIER_PROPERTIES` plus `isCssOwnedFlexElement()`/`cssOwnedFlexAttributes()` mirror the grid carrier for flex containers. An inline display that overrides class-owned layout is skipped: #883 already carries those properties complete, at the non-important specificity tier, and forcing them would move them to the important tier.

- [ ] `composer test:unit` — 0 failures across 37 scripts, including the two new contracts at 11 and 12 assertions.
- [ ] `composer test` — 0 failures, 275 parity fixtures.

Seven parity fixtures moved, each because it encoded one of the two defects: three auto-fit fixtures asserted the native conversion (one renamed, since `html-autofit-grid-maps-to-minimum-column-width` named the buggy contract), and four flex fixtures asserted output in which the authored `display:flex` had been dropped.
## Summary
Adds a deliberately RED contract proving an authored `font-family` on a button-mapped rule must reach the core/button block attributes, and that the other carried declarations keep mapping exactly as they do today.

## Why
`ButtonStyleResolver::BUTTON_TYPOGRAPHY` projects `fontSize`, `fontWeight`, `letterSpacing`, `lineHeight` and `textTransform` onto buttons and drops `font-family`. core/button registers a `fontFamily` attribute, which core injects only when the typography fontFamily support is enabled, so this is a dropped declaration rather than an unsupported one.

The drop is not benign. The authored `.btn` class is consumed into block attributes, so the surviving author rule is rewritten behind `:where()` at zero specificity and matches nothing that can win. theme.json's `styles.elements.button.typography.fontFamily` then substitutes the design-direction typeface, which widens the measure enough to wrap hero CTAs onto two lines.

## How
`tests/unit/button-font-family-carry.php` asserts the support exists before relying on it, pins the resolver's complete attribute output so no other carried declaration can drift, covers a rule that authors no font-family, and repeats the end-to-end shape from the report where the class is consumed.

## Testing
RED evidence at this commit: `php tests/unit/button-font-family-carry.php` exits 1 with 5 failed and 4 passed assertions. The passing four include the support check, which must hold before the fix is safe to write.
## Summary
Adds `fontFamily` to the typography subset projected onto core/button, and to the save-markup serializer that renders those attributes.

## Why
`ButtonStyleResolver` carried a button rule's background, text color, border, radius, font-size, weight, letter-spacing and text-transform, but dropped `font-family`. core/button registers a `fontFamily` attribute, which core injects only when the typography fontFamily support is enabled, so the declaration was expressible all along.

Because the authored class is consumed into block attributes, its surviving rule is rewritten behind `:where()` at zero specificity and cannot win. theme.json's `styles.elements.button.typography.fontFamily` therefore substituted the design-direction typeface, widening hero CTAs enough to wrap them onto two lines.

## How
A raw authored family is a custom value rather than a preset slug, so it rides in `style.typography.fontFamily`. `BlockFactory` did not map that attribute either, so the block attribute alone would not have reached the rendered link; the serializer now emits `font-family` inline the way core's style engine does, ahead of the other typography declarations. Inline style outranks theme.json's element styles, so no `!important` bridge is needed and none was added.

## Testing
- [ ] `composer test:unit` — 0 failures across 39 scripts.
- [ ] `composer test` — 0 failures across 51 scripts, 275 parity fixtures, none edited for this defect.
- [ ] `php tests/unit/button-font-family-carry.php` — 9 assertions, up from 4 passing and 5 failing at the RED commit.
@borkweb
borkweb merged commit a393de4 into trunk Aug 17, 2026
4 checks passed
@borkweb
borkweb deleted the fix/grid-flex-carrier branch August 17, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant