From 93e3b4683f4cc4ed00ef0862b2313501fe806e35 Mon Sep 17 00:00:00 2001 From: Matthew Batchelder Date: Sat, 15 Aug 2026 04:56:09 -0400 Subject: [PATCH 1/2] test(php-transformer): freeze inline display carrier contract Adds a deliberately RED contract for block, inline-block, and flex overrides of class-owned grid layout. The expected engine-support selector is :root .be-inline-geometry-* at specificity (0,2,0), without !important, while the existing css-owned-grid rule stays byte-frozen. RED evidence: php tests/unit/inline-display-carrier.php exits 1 with 12 failed and 14 passed assertions. --- php-transformer/composer.json | 1 + .../tests/unit/inline-display-carrier.php | 111 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 php-transformer/tests/unit/inline-display-carrier.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 40033683..7d7a140e 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -79,6 +79,7 @@ "php tests/unit/author-selector-semantics.php", "php tests/unit/engine-support-css-asset.php", "php tests/unit/engine-support-css-specificity.php", + "php tests/unit/inline-display-carrier.php", "php tests/unit/artifact-author-stylesheet-projection.php", "php tests/unit/fallback-finding-normalizer.php", "php tests/unit/navigation-underline-color-resolver.php", diff --git a/php-transformer/tests/unit/inline-display-carrier.php b/php-transformer/tests/unit/inline-display-carrier.php new file mode 100644 index 00000000..21fce6be --- /dev/null +++ b/php-transformer/tests/unit/inline-display-carrier.php @@ -0,0 +1,111 @@ +> */ +$sourceAssets = static fn (array $result, string $source): array => array_values(array_filter( + is_array($result['assets'] ?? null) ? $result['assets'] : array(), + static fn (array $asset): bool => $source === ($asset['source'] ?? '') +)); + +$cssFor = static function (array $result, string $source) use ($sourceAssets): string { + return implode("\n", array_map( + static fn (array $asset): string => (string) ($asset['content'] ?? ''), + $sourceAssets($result, $source) + )); +}; + +/** @return array{int, int, int} */ +$specificity = static function (string $selector): array { + $ids = preg_match_all('/#[A-Za-z0-9_-]+/', $selector); + $classes = preg_match_all('/\.[A-Za-z0-9_-]+|\[[^\]]+\]|:(?!:)[A-Za-z0-9_-]+(?:\([^)]*\))?/', $selector); + $withoutWeightedTokens = preg_replace('/#[A-Za-z0-9_-]+|\.[A-Za-z0-9_-]+|\[[^\]]+\]|::?[A-Za-z0-9_-]+(?:\([^)]*\))?|[>+~*]/', ' ', $selector) ?? $selector; + $elements = preg_match_all('/(?:^|\s)([A-Za-z][A-Za-z0-9_-]*)/', $withoutWeightedTokens); + + return array( (int) $ids, (int) $classes, (int) $elements ); +}; + +$cases = array( + 'block' => array( + 'class' => 'display-owner-block', + 'style' => 'display:block', + 'declarations' => 'display:block', + ), + 'inline-block' => array( + 'class' => 'display-owner-inline-block', + 'style' => 'display:inline-block', + 'declarations' => 'display:inline-block', + ), + 'flex' => array( + 'class' => 'display-owner-flex', + 'style' => 'display:flex;flex-direction:column;flex-wrap:wrap;align-items:center;justify-content:center;gap:2rem', + 'declarations' => 'display:flex;flex-direction:column;flex-wrap:wrap;align-items:center;justify-content:center;gap:2rem', + ), +); + +foreach ( $cases as $name => $case ) { + $className = $case['class']; + $html = '' + . '

First

Second

'; + $result = ( new HtmlTransformer() )->transform($html, array())->toArray(); + $serialized = (string) ($result['serialized_blocks'] ?? ''); + $engineCss = $cssFor($result, 'engine-support'); + $authorCss = $cssFor($result, 'author-css'); + preg_match('/\b(be-inline-geometry-[a-f0-9-]+)\b/', $serialized, $carrierMatch); + $carrierClass = (string) ($carrierMatch[1] ?? ''); + $selector = ':root .' . $carrierClass; + $expectedRule = $selector . '{' . $case['declarations'] . '}'; + $ruleMatch = array(); + if ( '' !== $carrierClass ) { + preg_match('/(' . preg_quote($selector, '/') . ')\{([^}]*)\}/', $engineCss, $ruleMatch); + } + + $assert('core/group' === ($result['blocks'][0]['blockName'] ?? ''), $name . ': source wrapper remains a core/group', (string) ($result['blocks'][0]['blockName'] ?? '(none)')); + $assert(str_contains($serialized, $className), $name . ': transformed markup retains the class whose grid declaration is being neutralized', $serialized); + $assert(str_contains($authorCss, '.' . $className . '{display:grid'), $name . ': author CSS retains the competing class-owned display:grid rule', $authorCss); + $assert('' !== $carrierClass, $name . ': transformed markup receives a geometry carrier class', $serialized); + $assert(str_contains($engineCss, $expectedRule), $name . ': engine-support carries the complete inline layout override', $engineCss); + $assert(1 !== preg_match('/be-inline-geometry-[a-f0-9-]+/', $authorCss), $name . ': generated carrier rule does not leak into author-css', $authorCss); + $assert(array( 0, 2, 0 ) === $specificity((string) ($ruleMatch[1] ?? '')), $name . ': carrier selector has specificity (0,2,0)', (string) ($ruleMatch[1] ?? '(missing)')); + $assert('' !== (string) ($ruleMatch[2] ?? '') && ! str_contains((string) $ruleMatch[2], '!important'), $name . ': inline layout carrier rule does not use !important', (string) ($ruleMatch[2] ?? '(missing)')); +} + +$gridResult = ( new HtmlTransformer() )->transform( + '
Rail
Body
', + array() +)->toArray(); +$gridSerialized = (string) ($gridResult['serialized_blocks'] ?? ''); +$gridCss = $cssFor($gridResult, 'engine-support'); +preg_match('/\b(be-inline-geometry-[a-f0-9-]+)\b/', $gridSerialized, $gridCarrierMatch); +$gridCarrier = (string) ($gridCarrierMatch[1] ?? ''); +$gridRule = '.' . $gridCarrier . '{display:grid !important;grid-template-columns:260px 1fr !important;align-items:center !important;justify-content:space-between !important;gap:32px !important}'; + +$assert(str_contains($gridSerialized, 'blocks-engine-css-owned-grid') && '' !== $gridCarrier, 'grid control: legitimate inline grid remains on the existing css-owned-grid carrier path', $gridSerialized); +$assert(str_contains($gridCss, $gridRule), 'grid control: existing grid display and companion declaration bytes remain unchanged', $gridCss); + +if ( $failures > 0 ) { + fwrite(STDERR, "Inline display carrier contract: {$failures} failed, {$passes} passed\n"); + exit(1); +} + +fwrite(STDOUT, "Inline display carrier contract passed: {$passes} assertions\n"); From bb82267e5c04e6fe93320dbcbe9ea307d075ff14 Mon Sep 17 00:00:00 2001 From: Matthew Batchelder Date: Sat, 15 Aug 2026 05:08:45 -0400 Subject: [PATCH 2/2] fix(php-transformer): preserve inline display overrides Carry display:block, display:inline-block, and display:flex only when matched static or conditional author CSS would reassert a different display mode. Flex-direction, flex-wrap, align-items, justify-content, and gap ride the same carrier for flex/inline-flex; grid-template-columns remains on the unchanged css-owned-grid path. Emit :root .be-inline-geometry-* at specificity (0,2,0) in source=engine-support before author CSS, with no !important, so a plain author class loses while authored !important retains its cascade priority. Existing sizing and forced grid declarations retain their current important rule. Gates: focused contract 34 assertions with 0 failures; composer test 275 parity fixtures with 0 failures; static-site raster parity remained 0.0000% for both solved fixtures. --- .../Style/StyleResolutionTrait.php | 118 ++++++++++++++++-- .../tests/unit/inline-display-carrier.php | 8 +- 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index db0e95a7..85ecb963 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -65,9 +65,24 @@ trait StyleResolutionTrait /** * @return list */ - private function inlineGeometryProperties(): array + private function inlineLayoutCarrierProperties(): array { return array( + 'display', + 'flex-direction', + 'flex-wrap', + 'align-items', + 'justify-content', + 'gap', + ); + } + + /** + * @return list + */ + private function inlineGeometryProperties(): array + { + return array_merge($this->inlineLayoutCarrierProperties(), array( 'width', 'height', 'min-width', @@ -79,7 +94,7 @@ private function inlineGeometryProperties(): array 'flex-basis', 'object-fit', 'object-position', - ); + )); } /** @@ -337,6 +352,17 @@ private function inlineGeometryClassName( : $this->cssDeclarations($this->attr($element, 'style')); $geometry = array(); $properties = $this->inlineGeometryProperties(); + if ( $this->inlineDisplayOverridesAuthorLayout($element, $declarations) ) { + $inlineDisplay = strtolower(trim((string) preg_replace('/\s*!\s*important\s*$/i', '', (string) ($declarations['display'] ?? '')))); + if ( ! in_array($inlineDisplay, array( 'flex', 'inline-flex' ), true) ) { + $properties = array_values(array_diff( + $properties, + array( 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap' ) + )); + } + } else { + $properties = array_values(array_diff($properties, $this->inlineLayoutCarrierProperties())); + } $inlineBackground = (string) ($declarations['background'] ?? $declarations['background-image'] ?? ''); if ( preg_match('/\burl\s*\(/i', $inlineBackground) && ( 0 < $this->directElementChildCount($element) || '' !== trim((string) $element->textContent) ) @@ -367,27 +393,95 @@ private function inlineGeometryClassName( return ''; } - // Emit carried declarations in source order: with per-declaration - // !important, last-write-wins is decided by rule order, and an - // alphabetical sort silently flips shorthand/longhand winners - // (grid vs grid-template-columns, gap vs column-gap). Values not - // present inline (forced/custom-property fallbacks) sort last. + // Emit carried declarations in source order. For declarations sharing + // a priority tier, last-write-wins is decided by rule order, and an + // alphabetical sort silently flips shorthand/longhand winners (grid vs + // grid-template-columns, gap vs column-gap). Values not present inline + // (forced/custom-property fallbacks) sort last. $sourceOrder = array_flip(array_keys($declarations)); uksort($geometry, static fn (string $a, string $b): int => (($sourceOrder[$a] ?? PHP_INT_MAX) <=> ($sourceOrder[$b] ?? PHP_INT_MAX)) ?: strcmp($a, $b)); - $declarations = array(); + $layoutDeclarations = array(); + $importantDeclarations = array(); + $forcedPropertyLookup = array_fill_keys($forcedProperties, true); + $inlineLayoutPropertyLookup = array_fill_keys($this->inlineLayoutCarrierProperties(), true); foreach ($geometry as $property => $value) { + if ( isset($inlineLayoutPropertyLookup[$property]) && ! isset($forcedPropertyLookup[$property]) ) { + // Preserve source inline layout over a later plain author class + // without preventing an authored !important declaration from + // retaining its normal cascade priority. + $layoutDeclarations[] = $property . ':' . $value; + continue; + } + // A converted inline declaration must continue to outrank authored // normal selectors, including ID selectors. Authored !important // rules retain their normal cascade priority through specificity. - $declarations[] = $property . ':' . $value . ' !important'; + $importantDeclarations[] = $property . ':' . $value . ' !important'; } - $rule = implode(';', $declarations); - $className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\n" . $rule); - $this->generatedGeometryRules[$className] = '.' . $className . '{' . $rule . '}'; + $signature = implode(';', array_merge($layoutDeclarations, $importantDeclarations)); + $className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\n" . $signature); + $rules = array(); + if ( array() !== $layoutDeclarations ) { + $rules[] = ':root .' . $className . '{' . implode(';', $layoutDeclarations) . '}'; + } + if ( array() !== $importantDeclarations ) { + $rules[] = '.' . $className . '{' . implode(';', $importantDeclarations) . '}'; + } + $this->generatedGeometryRules[$className] = implode("\n", $rules); return $className; } + /** + * An inline display needs a carrier only when materialized author CSS would + * otherwise reassert a different layout mode on the transformed element. + * Conditional variants count because the inline declaration owns every + * viewport in the source document. + * + * @param array $inlineDeclarations + */ + private function inlineDisplayOverridesAuthorLayout(DOMElement $element, array $inlineDeclarations): bool + { + $inlineDisplay = strtolower(trim((string) preg_replace( + '/\s*!\s*important\s*$/i', + '', + (string) ($inlineDeclarations['display'] ?? '') + ))); + if ( '' === $inlineDisplay ) { + return false; + } + + foreach ( $this->staticStyleRules as $rule ) { + if ( ! $this->matchesCssSelector($element, $rule['selector']) ) { + continue; + } + $authorDisplay = strtolower(trim((string) preg_replace( + '/\s*!\s*important\s*$/i', + '', + (string) ($rule['declarations']['display'] ?? '') + ))); + if ( '' !== $authorDisplay && $inlineDisplay !== $authorDisplay ) { + return true; + } + } + + foreach ( $this->conditionalStyleRules as $rule ) { + if ( ! $this->matchesCssSelector($element, $rule['selector']) ) { + continue; + } + $conditionalDisplay = strtolower(trim((string) preg_replace( + '/\s*!\s*important\s*$/i', + '', + (string) ($rule['declarations']['display'] ?? '') + ))); + if ( '' !== $conditionalDisplay && $inlineDisplay !== $conditionalDisplay ) { + return true; + } + } + + return false; + } + /** * A bare source serializes inside a generated *
. An authored percentage height on the diff --git a/php-transformer/tests/unit/inline-display-carrier.php b/php-transformer/tests/unit/inline-display-carrier.php index 21fce6be..7a3510fb 100644 --- a/php-transformer/tests/unit/inline-display-carrier.php +++ b/php-transformer/tests/unit/inline-display-carrier.php @@ -61,11 +61,17 @@ 'style' => 'display:flex;flex-direction:column;flex-wrap:wrap;align-items:center;justify-content:center;gap:2rem', 'declarations' => 'display:flex;flex-direction:column;flex-wrap:wrap;align-items:center;justify-content:center;gap:2rem', ), + 'mixed-specificity' => array( + 'class' => 'display-owner-specificity', + 'style' => 'display:block', + 'declarations' => 'display:block', + 'following_css' => 'div{display:block}', + ), ); foreach ( $cases as $name => $case ) { $className = $case['class']; - $html = '' + $html = '' . '

First

Second

'; $result = ( new HtmlTransformer() )->transform($html, array())->toArray(); $serialized = (string) ($result['serialized_blocks'] ?? '');