From 4b963c9eee0723dee426b405047c5ec2ce70cbad Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 21:10:20 -0400 Subject: [PATCH 1/2] Refine Figma DOM box classification --- .../scripts/figma-fixture-matrix-quality.php | 40 +++++++++++- .../src/Html/StaticHtmlEmitter.php | 11 ++++ .../tests/contract/FixtureMatrixContract.php | 63 +++++++++++++++++++ .../tests/contract/HtmlValidityContract.php | 17 ++--- .../tests/contract/TextLayoutContract.php | 8 +-- figma-transformer/tests/contract/run.php | 2 +- .../visual-parity/bin/dom-box-provider.mjs | 17 ++++- .../tools/visual-parity/tests/smoke.mjs | 5 +- 8 files changed, 147 insertions(+), 16 deletions(-) diff --git a/figma-transformer/scripts/figma-fixture-matrix-quality.php b/figma-transformer/scripts/figma-fixture-matrix-quality.php index 1d527a6b..7d4c42fd 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,55 @@ 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; + + // A one-axis line is expected; collapsed geometry must still agree with source evidence. + return $domAxis <= 1.0 && (null === $sourceAxis || abs($sourceAxis - $domAxis) > 0.5); +} + /** * @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..76580524 100644 --- a/figma-transformer/tests/contract/FixtureMatrixContract.php +++ b/figma-transformer/tests/contract/FixtureMatrixContract.php @@ -426,6 +426,53 @@ 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' => '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'); $invalidDomBoxQuality = matrix_analyze_dom_box_report(array( 'schema' => 'homeboy/static-artifact-dom-boxes/v1', 'entrypoints' => array( @@ -607,6 +654,22 @@ 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('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, '