diff --git a/figma-transformer/scripts/figma-fixture-matrix-quality.php b/figma-transformer/scripts/figma-fixture-matrix-quality.php index 1d527a6b..d6c2565f 100644 --- a/figma-transformer/scripts/figma-fixture-matrix-quality.php +++ b/figma-transformer/scripts/figma-fixture-matrix-quality.php @@ -392,7 +392,7 @@ function matrix_analyze_dom_box_entrypoint(array $entrypoint): array $findings[] = matrix_dom_box_finding('dom_viewport_width_leak', $node, array('width' => $width, 'right' => $right, 'viewport_width' => $viewportWidth)); } - if ( $width <= 1.0 || $height <= 1.0 ) { + if ( matrix_dom_box_is_unexpected_collapse($element, $width, $height) ) { $summary['dom_collapsed_box_count']++; $findings[] = matrix_dom_box_finding('dom_collapsed_box', $node, array('width' => $width, 'height' => $height)); } @@ -481,17 +481,62 @@ function matrix_dom_box_number(array $values, string $key, float $fallback): flo return isset($values[$key]) && is_numeric($values[$key]) ? (float) $values[$key] : $fallback; } +/** + * @param array $element + */ +function matrix_dom_box_is_unexpected_collapse(array $element, float $width, float $height): bool +{ + $collapsedWidth = $width <= 1.0; + $collapsedHeight = $height <= 1.0; + if ( ! $collapsedWidth && ! $collapsedHeight ) { + return false; + } + + $source = is_array($element['source'] ?? null) ? $element['source'] : array(); + $sourceType = isset($source['node_type']) && is_scalar($source['node_type']) ? strtoupper(trim((string) $source['node_type'])) : ''; + $isVisibleGeometry = in_array($sourceType, array('LINE', 'VECTOR'), true) + && true === ($element['visibility']['visible'] ?? null); + if ( ! $isVisibleGeometry || $collapsedWidth === $collapsedHeight ) { + return true; + } + + $orthogonalDimension = $collapsedWidth ? $height : $width; + if ( $orthogonalDimension <= 1.0 ) { + return true; + } + + $sourceDimensions = is_array($source['visual_dimensions'] ?? null) ? $source['visual_dimensions'] : array(); + $collapsedAxis = $collapsedWidth ? 'width' : 'height'; + $sourceAxis = isset($sourceDimensions[$collapsedAxis]) && is_numeric($sourceDimensions[$collapsedAxis]) + ? (float) $sourceDimensions[$collapsedAxis] + : null; + $domAxis = $collapsedWidth ? $width : $height; + if ( null === $sourceAxis || ! is_finite($sourceAxis) || $sourceAxis < 0.0 ) { + return true; + } + + if ( abs($sourceAxis - $domAxis) <= 0.5 ) { + return false; + } + + // A zero-sized source axis may round up to one visible DOM pixel. + return ! (0.0 === $sourceAxis && $domAxis > 0.0 && $domAxis <= 1.0); +} + /** * @param array $element * @return array */ function matrix_dom_box_node_summary(array $element): array { + $source = is_array($element['source'] ?? null) ? $element['source'] : array(); return array_filter(array( 'id' => isset($element['node_id']) && is_scalar($element['node_id']) ? (string) $element['node_id'] : null, 'name' => isset($element['node_name']) && is_scalar($element['node_name']) ? (string) $element['node_name'] : null, 'selector' => isset($element['selector']) && is_scalar($element['selector']) ? (string) $element['selector'] : null, 'tag' => isset($element['tag']) && is_scalar($element['tag']) ? (string) $element['tag'] : null, + 'source_node_type' => isset($source['node_type']) && is_scalar($source['node_type']) ? (string) $source['node_type'] : null, + 'source_visual_dimensions' => is_array($source['visual_dimensions'] ?? null) ? $source['visual_dimensions'] : null, ), static fn (mixed $value): bool => null !== $value && '' !== $value); } diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index 2f54d194..235733eb 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -1055,6 +1055,17 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, ar $layoutIntent = $this->layoutIntentClassifier()->layoutIntent($node, $parentNode); $elementClassName = null === $imageElement ? $className : $className . ' figma-image-asset'; $attributes = sprintf(' class="%1$s" data-figma-node-id="%2$s" data-figma-node-name="%3$s"', $elementClassName, $id, $attributeName); + $attributes .= ' data-source-node-type="' . $this->sanitizeAttribute($type) . '"'; + $sourceVisualBox = is_array($node['box'] ?? null) ? $node['box'] : array(); + foreach ( array('width', 'height') as $dimension ) { + if ( ! isset($sourceVisualBox[$dimension]) && isset($node[$dimension]) && is_numeric($node[$dimension]) ) { + $sourceVisualBox[$dimension] = $node[$dimension]; + } + } + if ( isset($sourceVisualBox['width'], $sourceVisualBox['height']) && is_numeric($sourceVisualBox['width']) && is_numeric($sourceVisualBox['height']) ) { + $attributes .= ' data-source-visual-width="' . $this->sanitizeAttribute((string) $sourceVisualBox['width']) . '"'; + $attributes .= ' data-source-visual-height="' . $this->sanitizeAttribute((string) $sourceVisualBox['height']) . '"'; + } $semanticRole = $this->semanticRoleMetadata($node, $tag, $type, $name); if ( null !== $semanticRole ) { $attributes .= ' data-figma-semantic-role="' . $this->sanitizeAttribute($semanticRole) . '"'; diff --git a/figma-transformer/tests/contract/FixtureMatrixContract.php b/figma-transformer/tests/contract/FixtureMatrixContract.php index dfa423f5..02b0f15a 100644 --- a/figma-transformer/tests/contract/FixtureMatrixContract.php +++ b/figma-transformer/tests/contract/FixtureMatrixContract.php @@ -426,6 +426,110 @@ function blocks_engine_figma_transformer_run_fixture_matrix_contract(callable $a ), ), ), '/tmp/dom-boxes.json'); + $collapseClassificationQuality = matrix_analyze_dom_box_report(array( + 'entrypoints' => array( + array( + 'page_path' => '/classification.html', + 'viewport' => array('width' => 1440, 'height' => 900), + 'dom_css_loaded' => true, + 'dom_capture_valid' => true, + 'elements' => array( + array( + 'node_id' => 'line:separator', + 'tag' => 'svg', + 'boundingClientRect' => array('left' => 0, 'right' => 240, 'top' => 0, 'bottom' => 1, 'width' => 240, 'height' => 1), + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'LINE', 'visual_dimensions' => array('width' => 240, 'height' => 1)), + ), + array( + 'node_id' => 'vector:tail', + 'tag' => 'svg', + 'boundingClientRect' => array('left' => 0, 'right' => 0.1, 'top' => 10, 'bottom' => 90, 'width' => 0.1, 'height' => 80), + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 0.1, 'height' => 80)), + ), + array( + 'node_id' => 'vector:source-zero-dom-one', + 'tag' => 'svg', + 'boundingClientRect' => array('left' => 0, 'right' => 943, 'top' => 90, 'bottom' => 91, 'width' => 943, 'height' => 1), + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943, 'height' => 0)), + ), + array( + 'node_id' => 'text:collapsed', + 'tag' => 'p', + 'boundingClientRect' => array('left' => 0, 'right' => 0, 'top' => 100, 'bottom' => 124, 'width' => 0, 'height' => 24), + 'visibility' => array('visible' => false), + 'source' => array('node_type' => 'TEXT', 'visual_dimensions' => array('width' => 180, 'height' => 24)), + ), + array( + 'node_id' => 'frame:collapsed', + 'tag' => 'section', + 'boundingClientRect' => array('left' => 0, 'right' => 320, 'top' => 130, 'bottom' => 130, 'width' => 320, 'height' => 0), + 'visibility' => array('visible' => false), + 'source' => array('node_type' => 'FRAME', 'visual_dimensions' => array('width' => 320, 'height' => 120)), + ), + array( + 'node_id' => 'vector:unexpected-collapse', + 'tag' => 'svg', + 'boundingClientRect' => array('left' => 0, 'right' => 1, 'top' => 140, 'bottom' => 200, 'width' => 1, 'height' => 60), + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 8, 'height' => 60)), + ), + ), + ), + ), + ), '/tmp/dom-box-collapse-classification.json'); + $visibleVectorSourceZeroDomOne = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943, 'height' => 0)), + ); + $invisibleVectorSourceZeroDomOne = $visibleVectorSourceZeroDomOne; + $invisibleVectorSourceZeroDomOne['visibility']['visible'] = false; + $bothAxesCollapsedVector = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 1, 'height' => 1)), + ); + $missingSourceAxisVector = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943)), + ); + $nonNumericSourceAxisVector = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943, 'height' => 'none')), + ); + $sourceAxisNotCollapsedVector = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943, 'height' => 2)), + ); + $sourceAxisOneDomZeroVector = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943, 'height' => 1)), + ); + $sourceAxisFractionalDomZeroVector = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943, 'height' => 0.75)), + ); + $sourceAxisWithinToleranceVector = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943, 'height' => 0.75)), + ); + $negativeSourceAxisVector = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943, 'height' => -0.25)), + ); + $nanSourceAxisVector = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943, 'height' => NAN)), + ); + $infiniteSourceAxisVector = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'VECTOR', 'visual_dimensions' => array('width' => 943, 'height' => INF)), + ); + $ordinaryContentWithCollapsedAxis = array( + 'visibility' => array('visible' => true), + 'source' => array('node_type' => 'TEXT', 'visual_dimensions' => array('width' => 943, 'height' => 0)), + ); $invalidDomBoxQuality = matrix_analyze_dom_box_report(array( 'schema' => 'homeboy/static-artifact-dom-boxes/v1', 'entrypoints' => array( @@ -607,6 +711,36 @@ function blocks_engine_figma_transformer_run_fixture_matrix_contract(callable $a $assert(1 === ($domBoxQuality['summary']['dom_viewport_width_leak_count'] ?? null), 'fixture-matrix-dom-box-quality-viewport-width-leak'); $assert(1 === ($domBoxQuality['summary']['dom_huge_vertical_spacing_count'] ?? null), 'fixture-matrix-dom-box-quality-huge-vertical-spacing'); $assert(1 === ($domBoxQuality['summary']['dom_collapsed_box_count'] ?? null), 'fixture-matrix-dom-box-quality-collapsed-box'); + $assert(3 === ($collapseClassificationQuality['summary']['dom_collapsed_box_count'] ?? null), 'fixture-matrix-dom-box-classification-warns-for-genuine-collapse'); + $collapseFindingIds = array_map( + static fn (array $finding): string => (string) ($finding['node']['id'] ?? ''), + $collapseClassificationQuality['pages'][0]['findings'] ?? array() + ); + $assert(! in_array('line:separator', $collapseFindingIds, true), 'fixture-matrix-dom-box-classification-allows-visible-one-pixel-line'); + $assert(! in_array('vector:tail', $collapseFindingIds, true), 'fixture-matrix-dom-box-classification-allows-source-faithful-subpixel-vector'); + $assert(! in_array('vector:source-zero-dom-one', $collapseFindingIds, true), 'fixture-matrix-dom-box-classification-allows-visible-vector-source-zero-dom-one-through-analyzer'); + $assert(! matrix_dom_box_is_unexpected_collapse($visibleVectorSourceZeroDomOne, 943, 1), 'fixture-matrix-dom-box-classification-allows-visible-vector-source-zero-dom-one'); + $assert(matrix_dom_box_is_unexpected_collapse($invisibleVectorSourceZeroDomOne, 943, 1), 'fixture-matrix-dom-box-classification-warns-for-invisible-vector'); + $assert(matrix_dom_box_is_unexpected_collapse($bothAxesCollapsedVector, 1, 1), 'fixture-matrix-dom-box-classification-warns-for-both-axis-collapse'); + $assert(matrix_dom_box_is_unexpected_collapse($missingSourceAxisVector, 943, 1), 'fixture-matrix-dom-box-classification-warns-for-missing-source-axis'); + $assert(matrix_dom_box_is_unexpected_collapse($nonNumericSourceAxisVector, 943, 1), 'fixture-matrix-dom-box-classification-warns-for-non-numeric-source-axis'); + $assert(matrix_dom_box_is_unexpected_collapse($nanSourceAxisVector, 943, 1), 'fixture-matrix-dom-box-classification-warns-for-nan-source-axis'); + $assert(matrix_dom_box_is_unexpected_collapse($infiniteSourceAxisVector, 943, 1), 'fixture-matrix-dom-box-classification-warns-for-infinite-source-axis'); + $assert(matrix_dom_box_is_unexpected_collapse($negativeSourceAxisVector, 943, 1), 'fixture-matrix-dom-box-classification-warns-for-negative-source-axis'); + $assert(matrix_dom_box_is_unexpected_collapse($sourceAxisNotCollapsedVector, 943, 1), 'fixture-matrix-dom-box-classification-warns-for-non-collapsed-source-axis'); + $assert(matrix_dom_box_is_unexpected_collapse($sourceAxisOneDomZeroVector, 943, 0), 'fixture-matrix-dom-box-classification-warns-for-source-one-dom-zero'); + $assert(matrix_dom_box_is_unexpected_collapse($sourceAxisFractionalDomZeroVector, 943, 0), 'fixture-matrix-dom-box-classification-warns-for-source-fractional-dom-zero'); + $assert(! matrix_dom_box_is_unexpected_collapse($sourceAxisWithinToleranceVector, 943, 1), 'fixture-matrix-dom-box-classification-allows-source-within-agreement-tolerance'); + $assert(matrix_dom_box_is_unexpected_collapse($ordinaryContentWithCollapsedAxis, 943, 1), 'fixture-matrix-dom-box-classification-warns-for-ordinary-content'); + $assert(in_array('text:collapsed', $collapseFindingIds, true), 'fixture-matrix-dom-box-classification-warns-for-collapsed-text'); + $assert(in_array('frame:collapsed', $collapseFindingIds, true), 'fixture-matrix-dom-box-classification-warns-for-collapsed-container'); + $assert(in_array('vector:unexpected-collapse', $collapseFindingIds, true), 'fixture-matrix-dom-box-classification-warns-for-source-mismatched-vector'); + $unexpectedVectorFinding = array_values(array_filter( + $collapseClassificationQuality['pages'][0]['findings'] ?? array(), + static fn (array $finding): bool => 'vector:unexpected-collapse' === ($finding['node']['id'] ?? null) + ))[0] ?? array(); + $assert('VECTOR' === ($unexpectedVectorFinding['node']['source_node_type'] ?? null), 'fixture-matrix-dom-box-finding-preserves-source-node-type'); + $assert(8 === ($unexpectedVectorFinding['node']['source_visual_dimensions']['width'] ?? null), 'fixture-matrix-dom-box-finding-preserves-source-visual-dimensions'); $assert(1 === ($domBoxQuality['summary']['dom_offscreen_box_count'] ?? null), 'fixture-matrix-dom-box-quality-offscreen-box'); $assert(2 === ($domBoxQuality['summary']['dom_missing_node_id_box_count'] ?? null), 'fixture-matrix-dom-box-quality-missing-node-id-boxes'); $assert(false === ($invalidDomBoxQuality['summary']['dom_css_loaded'] ?? null), 'fixture-matrix-invalid-dom-box-css-not-loaded'); diff --git a/figma-transformer/tests/contract/HtmlValidityContract.php b/figma-transformer/tests/contract/HtmlValidityContract.php index 257816d0..cb21a8f3 100644 --- a/figma-transformer/tests/contract/HtmlValidityContract.php +++ b/figma-transformer/tests/contract/HtmlValidityContract.php @@ -188,18 +188,19 @@ function blocks_engine_figma_transformer_run_html_validity_contract(callable $as $links = $result['source_report']['transform_diagnostics']['links'] ?? array(); $assert(str_contains($html, '
]* data-source-node-type="FRAME" data-source-visual-width="80" data-source-visual-height="32"/', $html), 'html-validity-node-preserves-source-dom-quality-evidence'); + $assert(1 === preg_match('/data-figma-node-id="validity:nav" data-figma-node-name="Navigation"[^>]* data-figma-semantic-role="nav"/', $html), 'html-validity-nav-carries-semantic-role-metadata'); + $assert(1 === preg_match('/data-figma-node-id="validity:services" data-figma-node-name="Services Section"[^>]* data-figma-semantic-role="services"/', $html), 'html-validity-services-section-carries-semantic-role-metadata'); + $assert(1 === preg_match('/data-figma-node-id="validity:pricing" data-figma-node-name="Pricing"[^>]* data-figma-semantic-role="pricing"/', $html), 'html-validity-pricing-section-carries-semantic-role-metadata'); + $assert(1 === preg_match('/data-figma-node-id="validity:contact-map" data-figma-node-name="Contact Map"[^>]* data-figma-semantic-role="map"/', $html), 'html-validity-map-section-carries-semantic-role-metadata'); + $assert(1 === preg_match('/data-figma-node-id="validity:final-cta" data-figma-node-name="Final CTA"[^>]* data-figma-semantic-role="cta"/', $html), 'html-validity-cta-section-carries-semantic-role-metadata'); $assert(str_contains($html, '
News About Contact'), 'html-validity-packed-route-text-preserves-spacing-with-inline-links'); + $assert(1 === preg_match('/class="figma-node-validity-packed-nav-main-nav-links" data-figma-node-id="validity:packed-nav" data-figma-node-name="Main Nav Links"[^>]*>News<\/a> About<\/a> Contact<\/a>/', $html), 'html-validity-packed-route-text-preserves-spacing-with-inline-links'); $assert(! str_contains($html, '
About'), 'html-validity-linked-list-item-anchor-inside-li'); - $assert(! str_contains($html, '