From b2801c0aa54f21382fb81bafaef4abf289ea5cf0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 23 Jul 2026 17:14:59 -0400 Subject: [PATCH 1/2] Project unmatched author type selectors through their source-tag marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An author rule whose selector matched no source element (e.g. `.page-header p` in a fixture whose `.page-header` has no source `

`) was emitted verbatim. Once a sibling `

` — such as an eyebrow `
` — collapses to a paragraph, that dormant `.page-header p` rule suddenly captures it and overrides its own class-owned type scale, inflating the element and shifting the page. BE already projects descendant `p`/`li`/`nav` type selectors through a source-tag marker carried only by elements that were that tag in the source, so a promoted paragraph is never captured. That rewrite was skipped on the no-match branch, leaving the selector bare. Route the no-match case through the same rewriteSourceTagTypes() path, so `.page-header p` becomes `.page-header :where( .blocks-engine-source-p-...)` and matches only real source paragraphs. This replaces the earlier parallel collapsed-paragraph exclusion mechanism, which duplicated the source-tag-marker system and regressed the already-solved 15-saas fixture by adding markers and shifting the marker counter. The 15-saas transform is now byte-identical to trunk, while the 3-artist-music eyebrow keeps its 0.68rem scale. Full php-transformer suite green: 248 parity fixtures, 77 block-style-support tests, projection unit coverage, all contracts. Refs #630. --- .../src/HtmlToBlocks/HtmlTransformer.php | 16 ++++++++++++++- .../artifact-author-stylesheet-projection.php | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index b71aaec4..d5ab581c 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -1090,7 +1090,15 @@ private function rewriteAuthorSelectorPrelude(string $prelude, bool $controlWrap } $matches = $this->matchingAuthorSourceElements($parsed); if ( array() === $matches ) { - $rewritten[] = $selector; + // A type selector (e.g. `.page-header p`) that matches no source + // element must still be projected through its source-tag marker + // rather than emitted bare. Otherwise a `
` later collapsed to a + // `

` (an eyebrow `

`) would be newly captured by + // the dormant `.page-header p` rule and lose its own type scale. + // Rewriting to `:where(.source-p-marker)` — carried only by elements + // that were `

` in the source — makes the rule match exactly what + // the author intended and nothing that was structurally promoted. + $rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed); continue; } if ( $this->isRootChildSelector($parsed) ) { @@ -3754,6 +3762,12 @@ private function visualTextWrapperBlockFromElement(DOMElement $element): ?array // decoration, which the wrapper class still applies to the paragraph. Real // flex containers hold child elements and are already excluded above by the // `childElementCount === 0` guard (e.g. `.tier-price` wrapping a ``). + // + // Descendant paragraph rules the source used a non-`p` tag to escape (e.g. + // `.page-header p { font-size: ... }` styling body copy while an eyebrow + // authored as `

` avoided it) do not capture the collapsed + // paragraph: author `p` type selectors are projected through the source-`p` + // tag marker, which only elements that were `

` in the source carry. if ( 0 === $this->childElementCount($element) && ! $this->hasBoxChromeWrapperStyling($element) ) { return $this->createBlock( 'core/paragraph', diff --git a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php index 19fe5ca7..31f1399a 100644 --- a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php +++ b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php @@ -117,6 +117,26 @@ $assert(1 === count($multiPageAuthorAssets), 'identical generated author stylesheets are emitted once across HTML routes'); $assert('blocks-engine/wordpress-site-plan/v2' === ($multiPage['source_reports']['wordpress_site_plan']['schema'] ?? null), 'deduplicated multi-route assets produce a canonical WordPress site plan'); +// Collapsed-paragraph cascade isolation via the source-`p` tag marker. A shared +// stylesheet carries a descendant-`p` body-copy rule (`.page-header p`) and an +// eyebrow (`.label`) that collapses to a paragraph. The projected `.page-header +// p` rule must target the source-`p` marker — carried only by elements that were +// `

` in the source — so it styles the real intro paragraph but never captures +// the collapsed eyebrow, which keeps its own class-owned type scale. No bare +// `.page-header p` may survive to capture the promoted paragraph. +$collapse = ( new ArtifactCompiler() )->compile(array( + 'entrypoint' => 'index.html', + 'files' => array( + array( 'path' => 'index.html', 'kind' => 'html', 'content' => '

' ), + array( 'path' => 'about.html', 'kind' => 'html', 'content' => '' ), + array( 'path' => 'shared.css', 'kind' => 'css', 'content' => '*,*::before,*::after{margin:0;padding:0}.label{display:inline-flex;gap:.5rem;font-size:.68rem;letter-spacing:.2em;text-transform:uppercase}.page-header p{font-size:1.2rem;font-style:italic}' ), + ), +) )->toArray(); +$collapseCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), array_values(array_filter($collapse['assets'] ?? array(), static fn (array $asset): bool => 'css' === ($asset['kind'] ?? '') && str_contains((string) ($asset['content'] ?? ''), 'page-header'))))); +$assert(str_contains($collapseCss, 'font-size:.68rem'), 'collapsed eyebrow keeps its own class-owned font-size rule'); +$assert(! preg_match('/(?:^|[\s>~+])\.page-header p\s*\{/', $collapseCss), 'no bare .page-header p rule survives to capture the collapsed eyebrow'); +$assert(preg_match('/\.page-header\s+:where\(\.blocks-engine-source-p-[a-f0-9]+-\d+\)/', $collapseCss) === 1, 'descendant .page-header p is projected through the source-p tag marker so it matches only real source paragraphs'); + if ( $failures > 0 ) { exit(1); } From 46d28eb724385f92d585963c0f452cfd1e257e3c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 23 Jul 2026 17:15:53 -0400 Subject: [PATCH 2/2] Keep button-styled link rows out of core/navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row of button-styled links whose container carries an incidental navigational token (e.g. ``, matched only because `links` looks navigational) was flattened into a core/navigation menu. That halved the controls' height (39.7px pill buttons became 20.5px text links) and dropped their styling, shifting the section. NavigationPattern now defers when a non-`nav`, non-list container's direct link children all carry button signals (via ButtonSignalClassifier). Those anchors belong to the buttons pattern, which converts the row to core/buttons and preserves each control's pill geometry and class. Genuine nav menus, list navigation, and single incidental button-classed links are unaffected. On the 3-artist-music music route this restores the streaming-links row to source parity. Full navigation + button unit suites and 248 parity fixtures green. Refs #630. --- .../Patterns/NavigationPattern.php | 38 +++++++++++++++++++ php-transformer/tests/contract/run.php | 10 +++++ 2 files changed, 48 insertions(+) diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index 40554e00..38b30114 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -30,6 +30,16 @@ public function match(DOMElement $element, PatternContext $context): ?array return null; } + // A row of button-styled links (e.g. ``) is a call-to-action button group, not + // site navigation. It matched here only because a container token like + // `links` looks navigational, but its anchors carry button signals and + // belong to the buttons pattern, which preserves their pill geometry and + // styling. Defer so navigation does not flatten them into menu items. + if ( 'nav' !== strtolower($element->tagName) && ! $this->hasDirectListNavigationSignal($element) && $this->hasButtonStyledLinkChildren($element) ) { + return null; + } + if ( $this->hasDirectBrandingAnchorBesideListNavigation($element, $innerHtml) ) { return null; } @@ -846,6 +856,34 @@ private function collectAnchorsExcluding(DOMElement $element, array &$anchors, a } } + /** + * Whether the container's direct link children are button-styled call-to- + * action anchors rather than navigation links. Requires every direct anchor + * to carry a button signal so a genuine nav menu with one incidental + * button-classed link is not misclassified. + */ + private function hasButtonStyledLinkChildren(DOMElement $element): bool + { + $classifier = new ButtonSignalClassifier(); + $anchors = array(); + foreach ( $element->childNodes as $child ) { + if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) ) { + $anchors[] = $child; + } + } + if ( 2 > count($anchors) ) { + return false; + } + + foreach ( $anchors as $anchor ) { + if ( ! $classifier->hasTransformSignal($anchor) ) { + return false; + } + } + + return true; + } + private function hasNavigationSignal(DOMElement $element): bool { if ( 'navigation' === strtolower($element->hasAttribute('role') ? $element->getAttribute('role') : '') ) { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index d8e9af14..d674a498 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -322,6 +322,16 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert('About' === ($navigationBlock['innerBlocks'][0]['attrs']['label'] ?? null), 'navigation conversion still preserves link labels'); $assert('/about' === ($navigationBlock['innerBlocks'][0]['attrs']['url'] ?? null), 'navigation conversion still preserves link URLs'); +// A row of button-styled links whose container merely carries a `links` token is +// a call-to-action button group, not site navigation. It must convert to +// core/buttons (preserving pill geometry) instead of being flattened into a +// core/navigation menu of half-height text links. +$ctaLinkRowResult = ( new HtmlTransformer() )->transform('')->toArray(); +$ctaSerialized = (string) ($ctaLinkRowResult['serialized_blocks'] ?? ''); +$assert(str_contains($ctaSerialized, '