From 864703a73e31f1e13cfb24c538cb95b57c002f08 Mon Sep 17 00:00:00 2001 From: leader Date: Tue, 3 Nov 2026 23:23:11 +0900 Subject: [PATCH 1/2] feat: apply ChondroGen feedback to design-to-code delivery --- .../changepack_log_oQ3i0fB6K_-Y0MKSMDsCR.json | 7 + .../changepack_log_tbQ_0w_XUWH9C4iZn7YLs.json | 7 + README.md | 19 + .../examples/render_fixture.rs | 22 +- .../codegen/component/component_generation.rs | 9 +- .../codegen/component/component_variants.rs | 14 +- .../src/codegen/component/render_node.rs | 67 +- .../src/codegen/evidence.rs | 27 + .../devup-mcp-devup-ui/src/codegen/image.rs | 1 + .../src/codegen/image/image_projection.rs | 11 +- .../src/codegen/image/project_tree.rs | 6 +- .../src/codegen/image/transferred_minimum.rs | 69 + .../devup-mcp-devup-ui/src/codegen/layout.rs | 19 +- .../src/codegen/layout/border_origin.rs | 112 + .../src/codegen/layout/boxes.rs | 71 + .../src/codegen/layout/grid_template.rs | 153 +- .../src/codegen/layout/layout_properties.rs | 1853 ++-- .../src/codegen/layout/solid_ellipse.rs | 28 + crates/devup-mcp-devup-ui/src/codegen/mod.rs | 11 +- .../src/codegen/overlay_anchors.rs | 494 + .../src/codegen/property_derivations.rs | 36 +- .../src/codegen/responsive.rs | 10 + .../codegen/responsive/breakpoint_merge.rs | 149 +- .../src/codegen/responsive/child_alignment.rs | 1 + .../src/codegen/responsive/child_order.rs | 123 + .../src/codegen/responsive/gap_aliases.rs | 39 + .../src/codegen/responsive/impl.rs | 101 +- .../responsive/instance_text_branches.rs | 57 + .../src/codegen/responsive/layout_defaults.rs | 98 + .../responsive/length_token_projection.rs | 101 + .../responsive/literal_text_branches.rs | 50 + .../src/codegen/responsive/merge_content.rs | 227 +- .../src/codegen/responsive/padding_aliases.rs | 67 + .../src/codegen/responsive/paint_defaults.rs | 82 + .../src/codegen/responsive/size_aliases.rs | 35 + .../responsive/wrapper_reconciliation.rs | 405 + .../src/codegen/semantics.rs | 121 + .../devup-mcp-devup-ui/src/codegen/style.rs | 2 + .../src/codegen/style/image_fill_source.rs | 222 +- .../src/codegen/style/stroke_layout.rs | 131 + .../src/codegen/style/stroke_paint.rs | 221 + .../src/codegen/style/style_properties.rs | 2 + crates/devup-mcp-devup-ui/src/codegen/text.rs | 1826 ++-- .../src/codegen/text/bound_line_advance.rs | 23 + .../src/codegen/text/wrapping_policy.rs | 42 + .../src/codegen/text/wrapping_policy_tests.rs | 16 + .../devup-mcp-devup-ui/src/codegen/variant.rs | 5 + .../src/codegen/variant/drawn_when.rs | 25 +- .../src/codegen/variant/instance_booleans.rs | 47 + .../src/codegen/variant/scalar_properties.rs | 41 + .../codegen/variant/structural_branches.rs | 335 + .../src/codegen/variant/text_properties.rs | 101 +- .../src/codegen/variant/tree_signature.rs | 44 + .../src/codegen/variant/unnamed_state_axis.rs | 128 + .../src/codegen/variant/variant_projection.rs | 77 +- .../add_flattened_resource_entries.rs | 26 +- .../src/provenance/baked_into_assets.rs | 46 +- .../src/provenance/line_height_calculation.rs | 66 + .../src/provenance/resolution.rs | 2 + .../src/provenance/sizing.rs | 2 +- .../provenance/sizing/transferred_minimum.rs | 65 + .../provenance/sizing/types_and_helpers.rs | 53 +- .../provenance/source_map_and_contracts.rs | 61 +- .../src/provenance/wrapped_gaps.rs | 61 + .../src/theme/devup_json/theme_generation.rs | 1 + .../src/theme/devup_json/typography_values.rs | 11 + .../src/ui_validate/delivery_quality.rs | 174 + .../src/ui_validate/fixture_flow.rs | 665 ++ .../src/ui_validate/fixture_flow_tests.rs | 197 + .../src/ui_validate/types_and_helpers.rs | 3 + .../src/ui_validate/wiring.rs | 225 +- .../tests/absolute_border_origin.rs | 200 + .../tests/bound_line_rounding.rs | 157 + .../tests/captured_korean_wrapping.rs | 142 + .../tests/captured_wrapping.rs | 197 + .../tests/carriage_return_rendering.rs | 79 + .../tests/chondrogen_component_text_traces.rs | 31 +- .../tests/chondrogen_component_typings.rs | 53 +- .../tests/clipped_free_offset.rs | 91 + crates/devup-mcp-devup-ui/tests/codegen.rs | 9 +- .../tests/fixed_absolute_ellipses.rs | 111 + .../fixtures/about-grid-gap-aliases.json | 3706 +++++++ .../chondrogen_dropdown_anchor_nodes.json | 4387 ++++++++ .../chondrogen_footer_ascii_spacing.json | 246 + .../chondrogen_mobile_header_single_logo.json | 1912 ++++ .../fixtures/chondrogen_notice_tab_nodes.json | 1657 +++ .../chondrogen_records_padding_tokens.json | 631 ++ .../chondrogen_transit_carriage_returns.json | 436 + .../tests/fixtures/clipped-free-pagemenu.json | 1393 +++ .../dropdown-option-pure-state-axis.json | 1 + .../fixtures/header-padding-aliases.json | 6280 +++++++++++ .../fixtures/home-news-mixed-strokes.json | 3673 +++++++ .../fixtures/home-product-cta-defaults.json | 2447 +++++ .../list-filter-mixed-state-axis.json | 1 + .../tests/fixtures/manifest-trim-golden.json | 2 +- .../fixtures/news-detail-responsive-copy.json | 1284 +++ .../tests/fixtures/news-grid-gap-aliases.json | 9235 +++++++++++++++++ .../tests/fixtures/oa-media-size-aliases.json | 372 + .../fixtures/oa-overview-child-order.json | 180 + .../tests/fixtures/oa-pagemenu-paints.json | 3126 ++++++ .../fixtures/pagemenu-primitive-defaults.json | 1 + .../fixtures/product-card-mixed-strokes.json | 5713 ++++++++++ .../responsive-title-summary-wrappers.json | 337 + .../fixtures/transferred-image-minimum.json | 336 + .../tests/fixtures/vision-border-origin.json | 573 + .../tests/fixtures/vision-solid-ellipses.json | 729 ++ ...ision-timeline-width-specific-spacers.json | 7179 +++++++++++++ .../tests/fixtures/vision-wrapping-text.json | 248 + .../tests/folded_asset_size.rs | 53 +- .../tests/hard_break_whitespace.rs | 15 +- .../tests/image_representation.rs | 21 +- .../tests/image_transferred_minimum.rs | 143 + .../tests/instance_overrides.rs | 2 +- .../tests/integer_line_advance.rs | 78 +- .../tests/intrinsic_text_width.rs | 53 + .../tests/jsx_text_fidelity.rs | 50 +- .../tests/live_asset_layout.rs | 107 + .../tests/mixed_stroke_padding.rs | 130 + .../tests/overlay_anchors.rs | 216 + crates/devup-mcp-devup-ui/tests/provenance.rs | 11 +- .../tests/r8_absolute_scroll.rs | 56 +- .../tests/responsive_child_order.rs | 134 + .../tests/responsive_child_order_extras.rs | 127 + .../tests/responsive_contract.rs | 69 +- .../tests/responsive_instance_text.rs | 108 + .../tests/responsive_layout_defaults.rs | 240 + .../tests/responsive_length_tokens.rs | 151 + .../tests/responsive_literal_text.rs | 117 + .../tests/responsive_padding_aliases.rs | 109 + .../tests/responsive_paint_defaults.rs | 201 + .../tests/responsive_primitive_defaults.rs | 214 + .../tests/responsive_scalar_content.rs | 123 + .../tests/responsive_size_aliases.rs | 127 + .../responsive_wrapper_reconciliation.rs | 220 + crates/devup-mcp-devup-ui/tests/scalar_alt.rs | 120 + .../devup-mcp-devup-ui/tests/semantic_html.rs | 159 + ...wquw_151__wquw_151_proofread_devup_ui.snap | 55 +- ..._wquw_151_proofread_devup_ui_embedded.snap | 55 +- ...w_151__wquw_151_proofread_diagnostics.snap | 2250 +++- ...uw_151__wquw_151_proofread_source_map.snap | 571 +- ...151_frames__wquw_151_frame_3879_35503.snap | 4 + ...151_frames__wquw_151_frame_3879_35518.snap | 55 +- ...151_frames__wquw_151_frame_3879_35569.snap | 90 +- ...151_frames__wquw_151_frame_3879_35652.snap | 78 +- ...151_frames__wquw_151_frame_3879_35729.snap | 74 +- ...151_frames__wquw_151_frame_3879_35887.snap | 91 +- ...151_frames__wquw_151_frame_3879_35973.snap | 91 +- ...151_frames__wquw_151_frame_3879_36059.snap | 73 +- ...151_frames__wquw_151_frame_3879_36108.snap | 61 +- ...151_frames__wquw_151_frame_3879_36144.snap | 49 +- .../tests/strokes_included_layout.rs | 165 + .../tests/unnamed_state_axis.rs | 191 + .../tests/variant_structural_branches.rs | 149 + crates/devup-mcp-devup-ui/tests/wquw_151.rs | 46 +- .../tests/wquw_151_frames.rs | 13 + .../tests/wrapped_ascii_whitespace.rs | 140 + .../devup-mcp-figma/src/asset_render_info.rs | 17 +- crates/devup-mcp-figma/src/assets.rs | 3 + .../src/assets/asset_manifest.rs | 12 +- .../devup-mcp-figma/src/assets/live_layout.rs | 36 + .../src/assets/scope_diagnostics.rs | 20 + crates/devup-mcp-figma/src/lib.rs | 5 +- .../src/plugin_api_manifest.json | 6 +- crates/devup-mcp-figma/src/plugin_scripts.rs | 6 +- .../src/plugin_scripts_bundle.js | 12 +- crates/devup-mcp-figma/src/snapshot.rs | 4 + .../tests/asset_scope_diagnostics.rs | 25 + .../tests/feedback_scripts.test.mjs | 10 + .../tests/image_fill_render.rs | 9 +- .../tests/manifest_covers_readers.rs | 2 + crates/devup-mcp/src/server/asset_guard.rs | 88 +- crates/devup-mcp/src/server/audit_plan.rs | 4 +- .../server/audit_plan/planning_and_capture.rs | 30 +- .../src/server/audit_plan/prerequisites.rs | 152 +- .../src/server/audit_plan/static_params.rs | 48 +- .../devup-mcp/src/server/audit_plan_tests.rs | 99 +- .../src/server/behavior_contracts.rs | 206 + .../src/server/behavior_contracts/detect.rs | 376 + .../src/server/behavior_contracts/tests.rs | 368 + .../src/server/commands/opencode-devup.md | 9 +- crates/devup-mcp/src/server/compact.rs | 3 + .../src/server/development_contract.rs | 43 + crates/devup-mcp/src/server/export_record.rs | 114 +- .../server/export_record/generated_drift.rs | 29 +- crates/devup-mcp/src/server/guide.rs | 2 +- .../src/server/handlers/devup_ui_validate.rs | 9 +- crates/devup-mcp/src/server/interactions.rs | 31 +- .../src/server/interactions/state_capture.rs | 256 + .../mod_parts/exploration_and_responses.rs | 2 + .../server/mod_parts/state_and_services.rs | 4 + crates/devup-mcp/src/server/output.rs | 351 +- crates/devup-mcp/src/server/output_tests.rs | 254 + crates/devup-mcp/src/server/projection.rs | 1 + .../src/server/projection/asset_paths.rs | 260 +- .../projection/asset_reference_names.rs | 15 +- .../src/server/projection/canonical_assets.rs | 647 ++ .../projection/canonical_assets_tests.rs | 526 + .../server/projection/component_projection.rs | 23 +- .../projection/feedback_codegen_tests.rs | 50 +- .../server/projection/feedback_theme_tests.rs | 5 +- .../projection/finalize_export_delivery.rs | 106 +- .../server/projection/instance_overrides.rs | 11 - .../projection/overlay_anchor_delivery.rs | 72 + .../project_node_and_theme_outputs.rs | 14 + .../server/projection/project_operation.rs | 5 +- .../projection/project_responsive_output.rs | 29 +- .../projection/project_section_frames.rs | 9 + .../src/server/projection/regression_tests.rs | 12 +- .../server/projection/resource_delivery.rs | 55 +- .../src/server/projection/resource_outputs.rs | 7 + .../server/projection/responsive_groups.rs | 73 +- .../responsive_paint_regressions.rs | 84 + .../src/server/projection/safe_file_names.rs | 35 +- .../devup-mcp/src/server/projection/setup.rs | 4 + .../src/server/projection/svg_identity.rs | 482 + .../projection/truncated_delivery_tests.rs | 76 + .../w1_live_header_icon_contract.rs | 25 + .../src/server/projection/w1_regressions.rs | 92 +- ...variant_and_css_map_resources_recover_w.rs | 21 +- ...s_placement_contract_without_diagnostic.rs | 16 +- ..._wquw118_layout_contracts_are_preserved.rs | 160 +- ...is_warning_and_requested_failure_is_par.rs | 72 +- .../w1_three_frame_resource_delivery.rs | 130 + crates/devup-mcp/src/server/runtime_audit.rs | 1 + .../runtime_audit/capture_comparison.rs | 41 +- .../runtime_audit/inputs_and_capture.rs | 58 +- .../src/server/runtime_audit/plan_columns.rs | 1 + .../runtime_audit/screenshot_integrity.rs | 2 + .../server/runtime_audit/selection_tests.rs | 25 + .../src/server/runtime_audit/tests.rs | 32 +- .../src/server/runtime_audit/write_plan.rs | 63 +- crates/devup-mcp/src/server/screen_plan.rs | 1 + .../src/server/screen_plan/actions.rs | 8 +- .../server/screen_plan/responsive_contract.rs | 246 +- .../server/screen_plan/responsive_groups.rs | 156 + .../devup-mcp/src/server/screen_plan/state.rs | 17 + .../src/server/screen_plan/state_variants.rs | 35 +- .../src/server/screen_plan/status.rs | 3 +- .../devup-mcp/src/server/screen_plan_tests.rs | 1 + ...is_vinext_with_view_transitions_enabled.rs | 9 +- .../screen_plan_tests/responsive_contract.rs | 4 +- .../screen_plan_tests/responsive_groups.rs | 244 + crates/devup-mcp/src/server/skills/catalog.rs | 1 + .../server/skills/devfive-frontend/SKILL.md | 18 +- .../references/delivery-quality.md | 130 + .../skills/devup-implementation-plan/SKILL.md | 21 +- .../src/server/skills/devup/SKILL.md | 17 +- .../src/server/skills/fetch_tests.rs | 8 +- .../devup-mcp/src/server/skills/manifest.json | 17 +- .../server/stack_diff/openapi_client_layer.rs | 71 +- .../tests_generated_operation_aliases.rs | 128 + .../devup-mcp/src/server/stack_diff_parse.rs | 67 +- .../text/audit_plan-url_plan-a1031a26.md | 2 +- .../server/text/audit_plan-write-7f9aea99.md | 2 +- .../src/server/text/devup-figma-export.md | 4 +- .../src/server/text/devup-runtime-audit.md | 2 + .../src/server/text/devup-screen-plan.md | 2 +- .../src/server/text/devup-visual-compare.md | 4 +- .../src/server/text/guide-GUIDE-c682b0c5.md | 76 +- .../text/guide-INSTRUCTIONS-49bd7ec6.md | 1 + .../projection-annotate_node_ids-b03e6d56.md | 2 +- .../projection-project_operation-8a156710.md | 2 +- .../server/text/runtime_audit-page-height.md | 2 +- ...untime_audit-visual_comparison-8010a061.md | 2 +- crates/devup-mcp/src/server/tools.rs | 16 +- crates/devup-mcp/src/server/visual_compare.rs | 611 +- .../visual_compare_calibration_tests.rs | 42 + .../src/server/visual_compare_tests.rs | 553 + .../tests/composite_export/common.rs | 11 +- ...ict_tsx_export_rejects_lossy_projection.rs | 15 + .../devup-mcp/tests/downstream_integration.rs | 5 +- .../devup-mcp/tests/export_project_theme.rs | 10 +- crates/devup-mcp/tests/responsive_export.rs | 67 +- crates/devup-mcp/tests/runtime_audit_tool.rs | 4 +- .../ui_validate_response/section_coverage.rs | 13 + fixtures/devup-figma-plugin/README.md | 64 + fixtures/devup-figma-plugin/manifest.json | 130 +- .../upstream-codegen-003-c855738695.snap | 1 + .../upstream-codegen-004-8ec61c5bb5.snap | 1 + .../upstream-codegen-005-bcc6ca7dbf.snap | 1 + .../upstream-codegen-006-86c21dc8ce.snap | 2 +- .../upstream-codegen-007-c1c1afa5d7.snap | 2 +- .../upstream-codegen-008-8ea06a6c88.snap | 2 +- .../upstream-codegen-009-008aac1a2c.snap | 2 +- .../upstream-codegen-010-8454d22acd.snap | 2 +- .../upstream-codegen-011-72709aab82.snap | 2 +- .../upstream-codegen-016-3f1fb1569b.snap | 2 +- .../upstream-codegen-017-44cff97cec.snap | 2 +- .../upstream-codegen-018-bc5eb21a9e.snap | 2 +- .../upstream-codegen-020-a9d569be7a.snap | 2 +- .../upstream-codegen-028-0dcf04d5ca.snap | 1 + .../upstream-codegen-138-9cccfcb552.snap | 1 + .../upstream-codegen-140-e577df9ad5.snap | 1 + .../upstream-codegen-143-52b441f9f4.snap | 1 + .../upstream-codegen-156-bec8be9b99.snap | 1 + .../upstream-codegen-161-5b230821f2.snap | 1 + .../upstream-codegen-162-51e1254b4c.snap | 1 + .../upstream-codegen-163-2a9f9a056a.snap | 1 + .../upstream-codegen-164-33e4c6591c.snap | 2 + .../upstream-codegen-165-501ee1df12.snap | 4 +- .../upstream-codegen-166-cda14f0b08.snap | 3 + .../upstream-codegen-167-114a88771f.snap | 2 + .../upstream-codegen-191-2021d398f8.snap | 8 + .../upstream-codegen-197-026402bf2e.snap | 2 + .../upstream-codegen-198-a9ea7ba9c3.snap | 2 + .../upstream-codegen-199-829a5c949d.snap | 2 + .../upstream-codegen-200-3313d92719.snap | 2 + .../upstream-codegen-201-8207a4008e.snap | 2 + .../upstream-codegen-202-2786eda075.snap | 2 + .../upstream-codegen-203-8af03217a3.snap | 2 + .../upstream-codegen-204-2b2967c8c4.snap | 2 + .../upstream-codegen-205-cc3ea5202a.snap | 1 + .../upstream-codegen-206-b5c5dc97eb.snap | 2 + .../upstream-codegen-207-b0e134acf4.snap | 2 + .../upstream-codegen-209-0514b0990f.snap | 2 + .../upstream-codegen-210-1b24060767.snap | 2 + .../upstream-codegen-211-2e15c55701.snap | 1 + .../upstream-codegen-212-2a8f7b4a99.snap | 1 + .../upstream-codegen-213-aa53408d2f.snap | 5 + .../upstream-codegen-218-587666c709.snap | 1 + .../upstream-codegen-219-c6446b7b2e.snap | 1 + .../upstream-codegen-220-5980153f26.snap | 1 + .../upstream-codegen-222-f2d780c847.snap | 2 + .../upstream-codegen-223-4dc8092992.snap | 13 + .../upstream-codegen-234-01ad9a0db0.snap | 2 +- .../upstream-codegen-235-8a3992468b.snap | 2 +- .../upstream-codegen-238-1a8cf8c870.snap | 2 +- .../upstream-codegen-240-77d56a1585.snap | 2 +- .../upstream-codegen-242-5c198a8490.snap | 2 +- .../upstream-codegen-243-14366a47ff.snap | 1 + .../upstream-codegen-244-4ccea91f13.snap | 2 + .../upstream-codegen-245-537356bcf4.snap | 2 + .../upstream-codegen-246-295f39e09b.snap | 2 + .../upstream-codegen-247-39d6959685.snap | 2 + .../upstream-codegen-248-d1bc18e068.snap | 1 + .../upstream-codegen-249-6b8a42981d.snap | 2 + .../upstream-codegen-250-935ccdb80f.snap | 2 + .../upstream-codegen-251-50acddfb96.snap | 30 +- .../upstream-codegen-252-49e979239e.snap | 27 +- .../upstream-codegen-253-347a2e5fb2.snap | 11 +- .../upstream-codegen-254-9f340b6a10.snap | 1 + .../upstream-codegen-255-d01d8c7de6.snap | 3 +- .../feedback/button-state-component-set.json | 1 + .../feedback/cta-default-state-capture.json | 465 + harness/runtime-audit/README.md | 54 +- harness/runtime-audit/checks.mjs | 24 +- .../fixtures/transferred-minimum.png | Bin 0 -> 3234 bytes harness/runtime-audit/markup-checks.mjs | 61 + harness/runtime-audit/run.mjs | 54 +- harness/runtime-audit/selection-checks.mjs | 33 + harness/runtime-audit/test/browser.test.mjs | 17 +- harness/runtime-audit/test/checks.test.mjs | 23 + .../test/codegen-layout.test.mjs | 96 +- harness/runtime-audit/test/markup.test.mjs | 60 + harness/runtime-audit/test/selection.test.mjs | 68 + .../test/verify-calibration.test.mjs | 17 + harness/runtime-audit/verify-render.mjs | 45 +- plugin/dist/code.js | 2 +- plugin/dist/ui.html | 2 +- reports/chondrogen-feedback-coverage.md | 76 + 360 files changed, 79549 insertions(+), 4832 deletions(-) create mode 100644 .changepacks/changepack_log_oQ3i0fB6K_-Y0MKSMDsCR.json create mode 100644 .changepacks/changepack_log_tbQ_0w_XUWH9C4iZn7YLs.json create mode 100644 crates/devup-mcp-devup-ui/src/codegen/image/transferred_minimum.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/layout/border_origin.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/layout/boxes.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/layout/solid_ellipse.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/overlay_anchors.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive/child_order.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive/gap_aliases.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive/instance_text_branches.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive/layout_defaults.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive/length_token_projection.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive/literal_text_branches.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive/padding_aliases.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive/paint_defaults.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive/size_aliases.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive/wrapper_reconciliation.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/semantics.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/style/stroke_layout.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/style/stroke_paint.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/text/bound_line_advance.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy_tests.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/variant/instance_booleans.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/variant/structural_branches.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/variant/tree_signature.rs create mode 100644 crates/devup-mcp-devup-ui/src/codegen/variant/unnamed_state_axis.rs create mode 100644 crates/devup-mcp-devup-ui/src/provenance/line_height_calculation.rs create mode 100644 crates/devup-mcp-devup-ui/src/provenance/sizing/transferred_minimum.rs create mode 100644 crates/devup-mcp-devup-ui/src/provenance/wrapped_gaps.rs create mode 100644 crates/devup-mcp-devup-ui/src/ui_validate/delivery_quality.rs create mode 100644 crates/devup-mcp-devup-ui/src/ui_validate/fixture_flow.rs create mode 100644 crates/devup-mcp-devup-ui/src/ui_validate/fixture_flow_tests.rs create mode 100644 crates/devup-mcp-devup-ui/tests/absolute_border_origin.rs create mode 100644 crates/devup-mcp-devup-ui/tests/bound_line_rounding.rs create mode 100644 crates/devup-mcp-devup-ui/tests/captured_korean_wrapping.rs create mode 100644 crates/devup-mcp-devup-ui/tests/captured_wrapping.rs create mode 100644 crates/devup-mcp-devup-ui/tests/carriage_return_rendering.rs create mode 100644 crates/devup-mcp-devup-ui/tests/clipped_free_offset.rs create mode 100644 crates/devup-mcp-devup-ui/tests/fixed_absolute_ellipses.rs create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/about-grid-gap-aliases.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_dropdown_anchor_nodes.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_footer_ascii_spacing.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_mobile_header_single_logo.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_notice_tab_nodes.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_records_padding_tokens.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_transit_carriage_returns.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/clipped-free-pagemenu.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/dropdown-option-pure-state-axis.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/header-padding-aliases.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/home-news-mixed-strokes.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/home-product-cta-defaults.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/list-filter-mixed-state-axis.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/news-detail-responsive-copy.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/news-grid-gap-aliases.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/oa-media-size-aliases.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/oa-overview-child-order.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/oa-pagemenu-paints.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/pagemenu-primitive-defaults.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/product-card-mixed-strokes.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/responsive-title-summary-wrappers.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/transferred-image-minimum.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/vision-border-origin.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/vision-solid-ellipses.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/vision-timeline-width-specific-spacers.json create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/vision-wrapping-text.json create mode 100644 crates/devup-mcp-devup-ui/tests/image_transferred_minimum.rs create mode 100644 crates/devup-mcp-devup-ui/tests/intrinsic_text_width.rs create mode 100644 crates/devup-mcp-devup-ui/tests/live_asset_layout.rs create mode 100644 crates/devup-mcp-devup-ui/tests/mixed_stroke_padding.rs create mode 100644 crates/devup-mcp-devup-ui/tests/overlay_anchors.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_child_order.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_child_order_extras.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_instance_text.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_layout_defaults.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_length_tokens.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_literal_text.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_padding_aliases.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_paint_defaults.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_primitive_defaults.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_scalar_content.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_size_aliases.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_wrapper_reconciliation.rs create mode 100644 crates/devup-mcp-devup-ui/tests/scalar_alt.rs create mode 100644 crates/devup-mcp-devup-ui/tests/semantic_html.rs create mode 100644 crates/devup-mcp-devup-ui/tests/strokes_included_layout.rs create mode 100644 crates/devup-mcp-devup-ui/tests/unnamed_state_axis.rs create mode 100644 crates/devup-mcp-devup-ui/tests/variant_structural_branches.rs create mode 100644 crates/devup-mcp-devup-ui/tests/wrapped_ascii_whitespace.rs create mode 100644 crates/devup-mcp-figma/src/assets/live_layout.rs create mode 100644 crates/devup-mcp-figma/src/assets/scope_diagnostics.rs create mode 100644 crates/devup-mcp-figma/tests/asset_scope_diagnostics.rs create mode 100644 crates/devup-mcp/src/server/behavior_contracts.rs create mode 100644 crates/devup-mcp/src/server/behavior_contracts/detect.rs create mode 100644 crates/devup-mcp/src/server/behavior_contracts/tests.rs create mode 100644 crates/devup-mcp/src/server/development_contract.rs create mode 100644 crates/devup-mcp/src/server/interactions/state_capture.rs create mode 100644 crates/devup-mcp/src/server/output_tests.rs create mode 100644 crates/devup-mcp/src/server/projection/canonical_assets.rs create mode 100644 crates/devup-mcp/src/server/projection/canonical_assets_tests.rs create mode 100644 crates/devup-mcp/src/server/projection/overlay_anchor_delivery.rs create mode 100644 crates/devup-mcp/src/server/projection/responsive_paint_regressions.rs create mode 100644 crates/devup-mcp/src/server/projection/svg_identity.rs create mode 100644 crates/devup-mcp/src/server/projection/truncated_delivery_tests.rs create mode 100644 crates/devup-mcp/src/server/projection/w1_live_header_icon_contract.rs create mode 100644 crates/devup-mcp/src/server/projection/w1_three_frame_resource_delivery.rs create mode 100644 crates/devup-mcp/src/server/runtime_audit/selection_tests.rs create mode 100644 crates/devup-mcp/src/server/screen_plan/responsive_groups.rs create mode 100644 crates/devup-mcp/src/server/screen_plan_tests/responsive_groups.rs create mode 100644 crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md create mode 100644 crates/devup-mcp/src/server/stack_diff/tests_generated_operation_aliases.rs create mode 100644 crates/devup-mcp/src/server/visual_compare_calibration_tests.rs create mode 100644 crates/devup-mcp/src/server/visual_compare_tests.rs create mode 100644 fixtures/feedback/button-state-component-set.json create mode 100644 fixtures/feedback/cta-default-state-capture.json create mode 100644 harness/runtime-audit/fixtures/transferred-minimum.png create mode 100644 harness/runtime-audit/markup-checks.mjs create mode 100644 harness/runtime-audit/selection-checks.mjs create mode 100644 harness/runtime-audit/test/markup.test.mjs create mode 100644 harness/runtime-audit/test/selection.test.mjs create mode 100644 harness/runtime-audit/test/verify-calibration.test.mjs create mode 100644 reports/chondrogen-feedback-coverage.md diff --git a/.changepacks/changepack_log_oQ3i0fB6K_-Y0MKSMDsCR.json b/.changepacks/changepack_log_oQ3i0fB6K_-Y0MKSMDsCR.json new file mode 100644 index 00000000..2c412414 --- /dev/null +++ b/.changepacks/changepack_log_oQ3i0fB6K_-Y0MKSMDsCR.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Minor" + }, + "note": "Ship ChondroGen feedback across pure responsive TSX, Korean wrapping, semantic shared assets, interaction and data contracts, installed guidance, and browser state, viewport and API verification", + "date": "2026-11-03T14:07:22.957100700Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_tbQ_0w_XUWH9C4iZn7YLs.json b/.changepacks/changepack_log_tbQ_0w_XUWH9C4iZn7YLs.json new file mode 100644 index 00000000..6c29df63 --- /dev/null +++ b/.changepacks/changepack_log_tbQ_0w_XUWH9C4iZn7YLs.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Preserve Figma responsive layout, nested length tokens, per-mode text metrics, live asset boundaries, variants and dropdown anchors; diagnose scoped asset recovery; validate real API binding flow and viewport/zoom/DPR-calibrated runtime evidence without forcing screenshot dimensions", + "date": "2026-11-02T09:37:31.181235800Z" +} diff --git a/README.md b/README.md index c6caf0ed..9cdb6a4a 100644 --- a/README.md +++ b/README.md @@ -1134,6 +1134,25 @@ legacy 경로에서 실제 확인된 공식 metadata는 XML text content envelop 2026-09-01 실제 파일 검증에서는 13개 page 전체 검색으로 `[FR-026] 본연체` Section (`4217:7743`)을 찾고, 그 안의 360×740 화면 10개를 시각 순서대로 인덱싱해 `A : STORY-F-PROOFREAD` (`3879:35518`)를 정확한 대상으로 선택했습니다. Section 전체 fast envelope는 8 MiB 안전 상한을 넘어서므로 성공으로 오인하지 않고, 각 화면을 공식 read-only MCP로 개별 수집했습니다. 열 화면은 각각 15~210개 node를 가지며 모든 child, styled text segment, 변수 3~25개와 text style 2~13개의 참조 완전성을 실제 JSON fixture와 DevupUI TSX snapshot으로 검증합니다. 대표 proofread 화면은 공식 read-only MCP 1회, 3개 PNG envelope 청크에서 144개 node, 변수 20개와 text style 11개를 수집했고 폴백은 없었습니다. instance children, concrete boolean property, mixed typography, nested `[1. 이름]`, token binding과 개별 footer stroke도 Rust snapshot/live contract로 검증했습니다. 같은 파일의 전체 theme export는 legacy 공식 read-only 호출 89개를 통해 collection 1개, variable 49개, style 37개, mode 2개를 수집해 42,794자 `devup.json`을 생성했으며 diagnostics는 0개였습니다. 현재 full-theme fast collector는 같은 collection/variable/style 전체를 단일 read-only `use_figma` 호출로 수집하고, envelope 검증 실패 시에만 이 legacy 경로를 0부터 다시 시작하도록 contract test로 고정했습니다. +## Delivery quality contracts + +Tool use carries `developmentContract`; code exports also provide a bounded +`behaviorPlan` with motion, interaction, layout and data-binding candidates. +The binary embeds these rules in `devup://guide/usage` and installs the full +`devfive-frontend/references/delivery-quality.md` reference with the frontend +skill. Guidance therefore remains available without a project's custom rules. + +Generated TSX contains executable code without Figma identity attributes or +export metadata comments. Provenance and unapplied instance overrides remain +structured sidecars. Public asset exports use semantic names and a persistent +registry, preserving caller-selected safe paths and existing user content. + +Plans distinguish captured evidence from inferred behavior. A static export +does not prove working animation or live DB integration. Source validation and +browser audit support selected computed paint, keyboard/history navigation and +short-page footer checks. See the [22-item feedback coverage report](reports/chondrogen-feedback-coverage.md) +for implementation evidence and conservative limits. + ## Snapshot 의미와 현재 한계 Figma Remote MCP에서는 `JSON_REST_V1` export가 허용되지 않으므로 host object를 그대로 REST JSON으로 만들 수 없습니다. 대신 checked-in property manifest와 runtime prototype/enumerable 탐색을 함께 사용해 모든 발견한 data field의 key와 읽기 결과를 보존합니다. 함수, 순환 node object는 제외하거나 id로 바꾸고, binary asset은 bytes 대신 metadata로 나타내며, 타 plugin private data와 오류를 내는 getter는 읽을 수 없습니다. 단일 값이 byte budget을 넘으면 key를 없애지 않고 `{ "$truncated": ..., "byteLength": ... }`와 `DEVUP_FIELD_VALUE_TRUNCATED`를 남기며, `characters`, styled segment, resource binding처럼 UI 변환에 필요한 값은 우선 보존합니다. diff --git a/crates/devup-mcp-devup-ui/examples/render_fixture.rs b/crates/devup-mcp-devup-ui/examples/render_fixture.rs index ae6f30c6..496e22a0 100644 --- a/crates/devup-mcp-devup-ui/examples/render_fixture.rs +++ b/crates/devup-mcp-devup-ui/examples/render_fixture.rs @@ -8,14 +8,34 @@ use devup_mcp_figma::Snapshot; fn main() -> Result<(), Box> { let args: Vec = std::env::args().skip(1).collect(); - if args.len() != 2 { + if args.len() != 2 && !(args.len() == 3 && args[2] == "--responsive") { return Err("usage: render_fixture ".into()); } let snapshot: Snapshot = serde_json::from_slice(&std::fs::read(&args[0])?)?; let options = CodegenOptions { component_name: Some("RenderFixture".into()), + inline_instances: true, ..Default::default() }; + if args.len() == 3 { + let mut merged = devup_mcp_devup_ui::codegen::responsive::merge_confirmed_breakpoints( + &snapshot, &options, + )? + .ok_or("responsive fixture requires two frames")?; + devup_mcp_devup_ui::codegen::node_ids::annotate_merged(&mut merged); + let imports = merged + .components + .iter() + .cloned() + .collect::>() + .join(", "); + let source = format!( + "import {{ {imports} }} from '@devup-ui/react';\nexport function RenderFixture() {{ return ({}); }}\n", + merged.tsx + ); + std::fs::write(Path::new(&args[1]), source)?; + return Ok(()); + } let mut output = generate_component(&snapshot, &snapshot.roots[0], &options)?; annotate_node_ids(&mut output, &snapshot.nodes.keys().cloned().collect()); std::fs::write(Path::new(&args[1]), output.tsx)?; diff --git a/crates/devup-mcp-devup-ui/src/codegen/component/component_generation.rs b/crates/devup-mcp-devup-ui/src/codegen/component/component_generation.rs index a04c36a7..d666d0e2 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component/component_generation.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component/component_generation.rs @@ -516,7 +516,14 @@ pub fn generate_inlined_component_instance( .iter() .map(|(key, value)| format!(" {key}=\"{value}\"")) .collect::(); - output.tsx = format!("{{/* <{name}{usage} /> */}}\n{}", output.tsx); + output.diagnostics.push(Diagnostic { + code: "DEVUP_CODEGEN_COMPONENT_USAGE".into(), + node_id: Some(root.id.clone()), + message: "The inlined component's selected usage is preserved in metadata.".into(), + severity: Some(DiagnosticSeverity::Info), + details: Some(serde_json::json!({"componentUsage":format!("<{name}{usage} />")})), + ..Diagnostic::default() + }); finalize_codegen_output(output, &projected, options, &selected.id) } diff --git a/crates/devup-mcp-devup-ui/src/codegen/component/component_variants.rs b/crates/devup-mcp-devup-ui/src/codegen/component/component_variants.rs index 72939529..e0d85e83 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component/component_variants.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component/component_variants.rs @@ -224,10 +224,20 @@ fn finalize_codegen_output( output.tsx = tsx; output.source_map = source_map; for record in super::instance_overrides::instance_overrides(snapshot, root_id, options) { - output.tsx.push_str(&format!("\n// @devup-override {}\n", record.to_string() - .replace('\u{2028}', "\\u2028").replace('\u{2029}', "\\u2029"))); output.diagnostics.push(super::instance_overrides::diagnostic(record)); } + let overlay_anchors = super::overlay_anchors::overlay_anchor_metadata(snapshot, &[root_id.to_owned()], &output.tsx); + output.diagnostics.extend(super::overlay_anchors::diagnostics(snapshot, root_id, &output.tsx)); + for record in overlay_anchors { + output.diagnostics.push(Diagnostic { + code: "DEVUP_CODEGEN_OVERLAY_ANCHOR_RUNTIME_UNVERIFIED".into(), + node_id: record["overlayNodeId"].as_str().map(str::to_owned), + message: "The captured dropdown relationship is projected with CSS anchors; verify native control binding and dynamic/error states in the consumer browser. Unsupported browsers are not verified.".into(), + severity: Some(DiagnosticSeverity::Warning), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Approximated), + details: Some(record), ..Diagnostic::default() + }); + } super::image::attach_evidence(snapshot, options, &mut output); crate::provenance::account_for_sizing(snapshot, &mut output); validate_tsx(&output.tsx)?; diff --git a/crates/devup-mcp-devup-ui/src/codegen/component/render_node.rs b/crates/devup-mcp-devup-ui/src/codegen/component/render_node.rs index 6fd90c21..e5493888 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component/render_node.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component/render_node.rs @@ -15,9 +15,30 @@ fn render_node( )); } let view = node.typed_view(); + let concrete_visibility = if context.inline_instances { + view.value("componentPropertyReferences") + .and_then(serde_json::Value::as_object) + .and_then(|references| references.get("visible")) + .and_then(serde_json::Value::as_str) + .and_then(|property| { + context + .instance_boolean_properties + .iter() + .rev() + .find_map(|properties| properties.get(property).copied()) + }) + } else { + None + }; + if concrete_visibility == Some(true) && view.bool("visible") == Some(false) { + let mut resolved = node.clone(); + resolved.fields.insert("visible".into(), serde_json::Value::Bool(true)); + visiting.remove(&node.id); + return render_node(snapshot, &resolved, depth, context, visiting); + } // Hidden snapshot subtrees have no deliverable assets. Do not emit their // image/background/mask references, even under display:none. - if view.bool("visible") == Some(false) { + if view.bool("visible") == Some(false) && concrete_visibility != Some(true) { context.diagnostics.push(non_rendering_diagnostic( node, "visible-false", @@ -34,26 +55,11 @@ fn render_node( }, )); } - add_fallback_diagnostics(snapshot, node, context); - let concrete_visibility = if context.inline_instances { - view.value("componentPropertyReferences") - .and_then(serde_json::Value::as_object) - .and_then(|references| references.get("visible")) - .and_then(serde_json::Value::as_str) - .and_then(|property| { - context - .instance_boolean_properties - .iter() - .rev() - .find_map(|properties| properties.get(property).copied()) - }) - } else { - None - }; if concrete_visibility == Some(false) { visiting.remove(&node.id); return Ok(mark_node(&node.id, String::new())); } + add_fallback_diagnostics(snapshot, node, context); if view.node_type() == "INSTANCE" && !context.inline_instances && style::non_rendering_asset_reason(snapshot, node).is_none() @@ -239,6 +245,7 @@ fn render_node( context.width_policy.as_ref(), super::image::original_image(node, &context.asset_policies), ); + if context.inline_instances { super::overlay_anchors::apply_props(snapshot, node, &mut props); } context.diagnostics.extend(layout::suspicious_values(snapshot, node, &props)); // A frame with no auto-layout places its children itself, and this keeps // them resolvable. Once the gap around them is measurable it is emitted as @@ -301,6 +308,7 @@ fn render_node( &mut context.used_tokens, &mut props, ); + super::semantics::push_semantic_props(snapshot, node, component, &mut props); style::tokenize_lengths( snapshot, node, @@ -369,23 +377,10 @@ fn render_node( let children_result = if asset.is_some() { Ok(Vec::new()) } else { - for child in view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .filter(|child| child.typed_view().bool("visible") == Some(false)) - { - context.diagnostics.push(non_rendering_diagnostic( - child, - "visible-false", - "", - "omitted", - )); - } reading_grid.as_ref().map_or_else( || view.child_ids().filter_map(|id| snapshot.nodes.get(id)).collect::>(), |grid| grid.cards.clone(), ).into_iter() - .filter(|child| child.typed_view().bool("visible") != Some(false)) .map(|child| render_node(snapshot, child, depth + 1, context, visiting)) .collect::, _>>() }; @@ -596,6 +591,18 @@ pub(super) fn render_props(props: &[Prop], depth: usize) -> (String, bool) { return (String::new(), false); } let mut sorted = props.to_vec(); + if !sorted.iter().any(|(name, _)| name.starts_with('_') || name == "selectors") { + for (left, right, axis, all) in [("pl", "pr", "px", "p"), ("pt", "pb", "py", "p"), ("ml", "mr", "mx", "m"), ("mt", "mb", "my", "m")] { + if sorted.iter().any(|(name, _)| name == axis || name == all) { continue; } + let sides = sorted.iter().filter(|(name, _)| name == left || name == right).collect::>(); + if sides.len() != 2 { continue; } + let (PropValue::String(a), PropValue::String(b)) = (&sides[0].1, &sides[1].1); + if a != b { continue; } + let value = a.clone(); + sorted.retain(|(name, _)| name != left && name != right); + sorted.push((axis.into(), PropValue::String(value))); + } + } sorted.sort_by(|left, right| left.0.cmp(&right.0)); let rendered = sorted .into_iter() diff --git a/crates/devup-mcp-devup-ui/src/codegen/evidence.rs b/crates/devup-mcp-devup-ui/src/codegen/evidence.rs index ee4561f4..bd03d452 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/evidence.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/evidence.rs @@ -47,6 +47,32 @@ pub(super) fn image_layout_diagnostics( else { continue; }; + let view = node.typed_view(); + if view.bool("strokesIncludedInLayout").is_none() + && view + .value("inferredAutoLayout") + .and_then(|layout| layout.get("strokesIncludedInLayout")) + .and_then(Value::as_bool) + .is_none() + && view.string("strokeAlign").unwrap_or("INSIDE") == "INSIDE" + && style::stroke_layout_is_auto(&view) + && element + .attributes + .iter() + .any(|(attribute, _)| matches!(attribute.name.as_str(), "border" | "outline")) + { + diagnostics.push(Diagnostic { + code: "DEVUP_CODEGEN_STROKE_LAYOUT_UNVERIFIED".into(), + message: "The capture omits strokesIncludedInLayout; legacy inside-stroke rendering is retained but its source layout behavior is unverified.".into(), + node_id: Some(node.id.clone()), property: Some("strokesIncludedInLayout".into()), + severity: Some(DiagnosticSeverity::Warning), fidelity_impact: Some(FidelityImpact::Approximated), + details: Some(json!({"sourceFields":["strokesIncludedInLayout","inferredAutoLayout.strokesIncludedInLayout","strokeAlign","strokes"], + "verification":{"state":"unverified","reasonCode":"stroke-layout-flag-not-captured"}, + "renderingPolicy":"Legacy compatibility only; absence is not evidence that the source flag is false.", + "nextAction":"Recapture with a bridge whose manifest includes strokesIncludedInLayout; preserve explicit false and true.","output":"tsx"})), + ..Default::default() + }); + } if element.tag == "Grid" { for (columns, property, sizes, count) in [ ( @@ -577,6 +603,7 @@ fn geometry(node: &devup_mcp_figma::RawNode) -> Value { "paddingRight", "paddingBottom", "paddingLeft", + "strokesIncludedInLayout", "itemSpacing", "primaryAxisAlignItems", "counterAxisAlignItems", diff --git a/crates/devup-mcp-devup-ui/src/codegen/image.rs b/crates/devup-mcp-devup-ui/src/codegen/image.rs index bc2fef4a..3ffcb08b 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/image.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/image.rs @@ -5,3 +5,4 @@ // Source sections share this module to preserve privacy and test names. include!("image/image_projection.rs"); include!("image/project_tree.rs"); +include!("image/transferred_minimum.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/image/image_projection.rs b/crates/devup-mcp-devup-ui/src/codegen/image/image_projection.rs index 0c8bb75c..cd733104 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/image/image_projection.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/image/image_projection.rs @@ -649,11 +649,10 @@ pub(super) fn apply_image_props(props: &mut Vec, mapping: &Value) { string_prop(props, name, v); } } - for name in ["width", "height"] { - if let Some(v) = mapping["intrinsicAttributes"][name].as_f64() { - string_prop(props, name, number(v)); - } - } + // DevupUI consumes width/height as styling aliases, not native img + // attributes. Unitless source pixel sizes would override measured w/h + // (and are spacing-scaled). Keep intrinsic sizes in the asset CSS map; + // only captured display dimensions belong on this component. for name in ["srcSet", "sizes"] { if let Some(v) = mapping[name].as_str() { string_prop(props, name, v); @@ -976,4 +975,4 @@ fn background_urls(value: &str) -> Vec<&str> { .map(|(url, _)| url.trim().trim_matches(['\'', '"'])) }) .collect() -} \ No newline at end of file +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/image/project_tree.rs b/crates/devup-mcp-devup-ui/src/codegen/image/project_tree.rs index e24eb0ef..12519a69 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/image/project_tree.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/image/project_tree.rs @@ -241,12 +241,14 @@ pub(super) fn map_for( .and_then(|i| style::image_fill_path(snapshot, &node.id, i, per_node)) .or_else(|| style::asset_path(snapshot, &node.id, per_node)) .unwrap_or_default(); - asset_css_map( + let mut mapping = asset_css_map( &serde_json::to_value(node).expect("RawNode serializes"), &id, &default_path, &policy_for(node, &id, policies), - ) + ); + preserve_transferred_minimum(snapshot, node, &mut mapping); + mapping } pub(super) fn attach_evidence( diff --git a/crates/devup-mcp-devup-ui/src/codegen/image/transferred_minimum.rs b/crates/devup-mcp-devup-ui/src/codegen/image/transferred_minimum.rs new file mode 100644 index 00000000..c8ef912b --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/image/transferred_minimum.rs @@ -0,0 +1,69 @@ +/// A horizontal flex item with a definite captured cross size has a +/// transferred automatic minimum through its captured aspect ratio. The image +/// projection's generic minW=0 must not erase that sizing contract. +pub(crate) fn horizontal_transferred_image_minimum( + snapshot: &Snapshot, + node: &RawNode, +) -> Option { + let view = node.typed_view(); + if view.string("layoutSizingHorizontal") != Some("FILL") + || view.string("layoutSizingVertical") != Some("FIXED") + || view.string("layoutPositioning") == Some("ABSOLUTE") + || view.number("minWidth").is_some() + || view.number("maxWidth").is_some() + { + return None; + } + let parent = snapshot.nodes.get(view.string("parentId")?)?; + let parent_view = parent.typed_view(); + if parent_view.string("layoutMode") != Some("HORIZONTAL") + || parent_view.string("layoutWrap") == Some("WRAP") + { + return None; + } + let width = view.number("width").filter(|n| n.is_finite() && *n > 0.0)?; + let height = view + .number("height") + .filter(|n| n.is_finite() && *n > 0.0)?; + let target = view.value("targetAspectRatio"); + let target_ratio = target + .and_then(|v| Some(v["x"].as_f64()? / v["y"].as_f64()?)) + .filter(|ratio| ratio.is_finite() && *ratio > 0.0); + if target.is_some_and(|value| !value.is_null()) { + if target_ratio.is_none_or(|ratio| (height * ratio - width).abs() > 0.5) { + return None; + } + } else if view.bool("preserveRatio") != Some(true) { + return None; + } + // The renderer must actually project the source as a replaced image. + if !matches!( + style::asset_kind(snapshot, node), + Some(style::AssetKind::Png) + ) { + return None; + } + Some(width) +} + +fn preserve_transferred_minimum(snapshot: &Snapshot, node: &RawNode, mapping: &mut Value) { + let Some(width) = horizontal_transferred_image_minimum(snapshot, node) else { + return; + }; + if mapping["css"]["wrapper"]["aspectRatio"].as_str().is_none() + || !mapping["css"]["image"]["transform"].is_null() + { + return; + } + // Explicit auto is necessary when a responsive mobile branch emits zero. + mapping["css"]["wrapper"]["minW"] = json!("auto"); + mapping["minimumSizingEvidence"] = json!({ + "property":"minW", "value":"auto", "sourceNodeId":node.id, + "parentId":node.typed_view().string("parentId"), + "capturedTransferredMinimum":width, + "targetAspectRatio":node.typed_view().value("targetAspectRatio"), + "preserveRatio":node.typed_view().bool("preserveRatio"), + "reason":"horizontal main-axis FILL, definite FIXED cross-axis height and captured aspect ratio retain CSS transferred automatic minimum; no fixed width is introduced", + "scope":"replaced-image-horizontal-flow-only" + }); +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index 0589b13b..101e6d9c 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -1,8 +1,11 @@ -// Source sections share this module to preserve privacy and test names. -include!("layout/layout_properties.rs"); -include!("layout/grid_template.rs"); - -include!("layout/reading_order.rs"); - -include!("layout/fixed_rows.rs"); -include!("layout/suspicious_values.rs"); +// Source sections share this module to preserve privacy and test names. +include!("layout/layout_properties.rs"); +include!("layout/boxes.rs"); +include!("layout/solid_ellipse.rs"); +include!("layout/border_origin.rs"); +include!("layout/grid_template.rs"); + +include!("layout/reading_order.rs"); + +include!("layout/fixed_rows.rs"); +include!("layout/suspicious_values.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout/border_origin.rs b/crates/devup-mcp-devup-ui/src/codegen/layout/border_origin.rs new file mode 100644 index 00000000..1be21dd2 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/layout/border_origin.rs @@ -0,0 +1,112 @@ +fn border_origin_offsets( + snapshot: &Snapshot, + node: &RawNode, + parent: &RawNode, +) -> Option<(f64, f64, f64, f64)> { + let view = node.typed_view(); + let p = parent.typed_view(); + if view.string("layoutPositioning") != Some("ABSOLUTE") + || view.bool("visible") == Some(false) + || p.bool("visible") == Some(false) + || !matches!( + p.string("layoutMode"), + Some("HORIZONTAL" | "VERTICAL" | "GRID") + ) + || super::style::asset_kind(snapshot, parent).is_some() + || super::style::asset_kind(snapshot, node).is_some() + || [ + "constraints", + "relativeTransform", + "absoluteBoundingBox", + "layoutPositioning", + "rotation", + "visible", + ] + .iter() + .any(|field| view.field_error(field).is_some()) + || [ + "relativeTransform", + "absoluteBoundingBox", + "layoutMode", + "rotation", + "visible", + ] + .iter() + .any(|field| p.field_error(field).is_some()) + { + return None; + } + for v in [&view, &p] { + let transform = v.value("relativeTransform")?.as_array()?; + if transform.len() != 2 + || transform + .iter() + .any(|row| row.as_array().is_none_or(|row| row.len() != 3)) + || v.value("rotation") + .is_some_and(|value| value.as_f64() != Some(0.0)) + { + return None; + } + if transform[0][0].as_f64() != Some(1.0) + || transform[0][1].as_f64() != Some(0.0) + || transform[1][0].as_f64() != Some(0.0) + || transform[1][1].as_f64() != Some(1.0) + { + return None; + } + } + let constraints = view.value("constraints")?; + if constraints["horizontal"] != "MIN" || constraints["vertical"] != "MIN" { + return None; + } + let (source, parent_box) = ( + box4(view.value("absoluteBoundingBox"))?, + box4(p.value("absoluteBoundingBox"))?, + ); + let (x, y) = (source.x - parent_box.x, source.y - parent_box.y); + let (left, top) = super::style::emitted_leading_border(parent)?; + (x.is_finite() && y.is_finite()).then_some((x, y, left, top)) +} + +fn correct_absolute_border_origin( + snapshot: &Snapshot, + node: &RawNode, + parent: &RawNode, + props: &mut [Prop], +) { + let Some((x, y, left, top)) = border_origin_offsets(snapshot, node, parent) else { + return; + }; + for (name, source, border) in [("left", x, left), ("top", y, top)] { + if border > 0.0 + && let Some((_, PropValue::String(value))) = + props.iter_mut().find(|(key, _)| key == name) + && *value == px(source) + { + *value = px(source - border); + } + } +} + +pub(crate) fn absolute_border_origin_calculation( + snapshot: &Snapshot, + node_id: &str, + generated: &str, +) -> Option { + let node = snapshot.nodes.get(node_id)?; + let parent = snapshot.nodes.get(node.typed_view().string("parentId")?)?; + let (x, y, left, top) = border_origin_offsets(snapshot, node, parent)?; + for (name, source, border) in [("left", x, left), ("top", y, top)] { + if border > 0.0 && generated == format!("{name}=\"{}\"", px(source - border)) { + return Some(format!( + "CSS absolute border-origin conversion: captured MIN {name} offset {} minus emitted native parent {} leading border {} = {}; CSS containing-block border origin adds that edge back. Source parent {}; verification labels are unchanged.", + px(source), + parent.id, + px(border), + px(source - border), + parent.id + )); + } + } + None +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout/boxes.rs b/crates/devup-mcp-devup-ui/src/codegen/layout/boxes.rs new file mode 100644 index 00000000..cff30867 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/layout/boxes.rs @@ -0,0 +1,71 @@ +/// A box in a parent's coordinates: left, top, width, height. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Box4 { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +fn box4(value: Option<&Value>) -> Option { + let value = value?; + Some(Box4 { + x: value.get("x")?.as_f64()?, + y: value.get("y")?.as_f64()?, + w: value.get("width")?.as_f64()?, + h: value.get("height")?.as_f64()?, + }) +} + +/// What Figma exports for an asset node, in its parent's coordinates: the +/// node's render bounds, which frame its SVG and PNG exports - measured +/// against `exportAsync` on the official server, 2026-09-07: an instance of +/// 1373x98 whose vector sits inside it exports as 952x104, and a group of +/// 686x735 rotated four degrees exports as 759x585 with the rotation drawn +/// into the paths. `None` where the snapshot does not carry the bounds. +pub(crate) fn export_box(snapshot: &Snapshot, node: &RawNode) -> Option { + let view = node.typed_view(); + let render = box4(view.value("absoluteRenderBounds"))?; + let parent = view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id))?; + let parent_box = box4(parent.typed_view().value("absoluteBoundingBox"))?; + Some(Box4 { + x: render.x - parent_box.x, + y: render.y - parent_box.y, + w: render.w, + h: render.h, + }) +} + +/// Where an in-flow asset's export sits inside the box the layout gives it, +/// and how large it is: the render bounds against the bounding box. `None` +/// when they coincide, which is every plain icon, or when the snapshot does +/// not carry them. +pub(super) fn export_offset(node: &RawNode) -> Option { + let view = node.typed_view(); + let render = box4(view.value("absoluteRenderBounds"))?; + let bounds = box4(view.value("absoluteBoundingBox"))?; + let close = |left: f64, right: f64| (left - right).abs() < 0.5; + if close(render.x, bounds.x) + && close(render.y, bounds.y) + && close(render.w, bounds.w) + && close(render.h, bounds.h) + { + return None; + } + Some(Box4 { + x: render.x - bounds.x, + y: render.y - bounds.y, + w: render.w, + h: render.h, + }) +} + +/// The box the layout gives an asset that is not positioned: its bounding +/// box, which is its own box unless it is rotated, when it is the box the +/// rotation sweeps - the box Figma's own layout gives it. +pub(crate) fn layout_box(node: &RawNode) -> Option { + box4(node.typed_view().value("absoluteBoundingBox")) +} + diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout/grid_template.rs b/crates/devup-mcp-devup-ui/src/codegen/layout/grid_template.rs index c8e7676a..e723e0fe 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout/grid_template.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout/grid_template.rs @@ -1,5 +1,3 @@ - - /// Preserve source tracks, including content sizing. A flexible column in a /// non-HUG grid has a zero minimum so replaced content cannot enlarge its share /// through CSS's automatic min-content minimum. Rows and HUG grids retain their @@ -105,18 +103,51 @@ pub(super) fn grid_template(node: &RawNode, columns: bool) -> GridTemplate { /// Hidden and absolutely positioned children do not participate in distribution. pub(crate) fn centers_lone_space_between_child(snapshot: &Snapshot, node: &RawNode) -> bool { let view = node.typed_view(); - matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) - && view.string("primaryAxisAlignItems") == Some("SPACE_BETWEEN") - && view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .filter(|child| { - let child = child.typed_view(); - child.bool("visible") != Some(false) - && child.string("layoutPositioning") != Some("ABSOLUTE") - }) - .count() - == 1 + if !matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) + || view.string("primaryAxisAlignItems") != Some("SPACE_BETWEEN") + { + return false; + } + let children = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| { + child.typed_view().bool("visible") != Some(false) + && child.typed_view().string("layoutPositioning") != Some("ABSOLUTE") + }) + .collect::>(); + let [child] = children.as_slice() else { + return false; + }; + // A captured start-aligned lone child is stronger evidence than the + // legacy center fallback (e.g. a FILL mobile header with one fixed logo). + let (position, size, near, far) = if view.string("layoutMode") == Some("HORIZONTAL") { + ("x", "width", "paddingLeft", "paddingRight") + } else { + ("y", "height", "paddingTop", "paddingBottom") + }; + let measured = view + .value("absoluteBoundingBox") + .zip(child.typed_view().value("absoluteBoundingBox")) + .and_then(|(parent, child)| { + Some(( + parent.get(position)?.as_f64()?, + parent.get(size)?.as_f64()?, + child.get(position)?.as_f64()?, + child.get(size)?.as_f64()?, + )) + }); + if let Some((start, extent, child_start, child_extent)) = measured { + let expected = start + + view.number(near).unwrap_or(0.0) + + (extent + - view.number(near).unwrap_or(0.0) + - view.number(far).unwrap_or(0.0) + - child_extent) + / 2.0; + return (child_start - expected).abs() <= 0.5; + } + true } fn push_auto_layout(snapshot: &Snapshot, node: &RawNode, component: &str, props: &mut Vec) { @@ -146,6 +177,23 @@ fn push_auto_layout(snapshot: &Snapshot, node: &RawNode, component: &str, props: } return; } + // A captured wrapped auto-layout is still a flex layout. Its main-axis + // spacing and cross-axis spacing are independent; a uniform CSS gap would + // silently replace the captured row spacing with itemSpacing. + if view.string("layoutWrap") == Some("WRAP") { + string_prop(props, "flexWrap", "wrap"); + if view.string("counterAxisAlignContent") == Some("SPACE_BETWEEN") { + string_prop(props, "alignContent", "space-between"); + } + if let Some(cross_gap) = view.number("counterAxisSpacing") { + let cross_property = if mode == Some("HORIZONTAL") { + "rowGap" + } else { + "columnGap" + }; + string_prop(props, cross_property, px(cross_gap)); + } + } // Figma's two newer distributions came to the plugin after the pinned // corpus (9214391); they are the CSS keywords of the same name. let justify = match view.string("primaryAxisAlignItems") { @@ -190,8 +238,19 @@ fn push_auto_layout(snapshot: &Snapshot, node: &RawNode, component: &str, props: .get("itemSpacing") .and_then(Value::as_f64) .or_else(|| view.number("itemSpacing")); - if let Some(gap) = gap.filter(|gap| *gap != 0.0) { - string_prop(props, "gap", px(gap)); + let separate_cross_gap = view.string("layoutWrap") == Some("WRAP") + && view.number("counterAxisSpacing").is_some(); + if let Some(gap) = gap.filter(|gap| *gap != 0.0 || separate_cross_gap) { + let property = if separate_cross_gap { + if mode == Some("HORIZONTAL") { + "columnGap" + } else { + "rowGap" + } + } else { + "gap" + }; + string_prop(props, property, px(gap)); } } } @@ -472,7 +531,9 @@ fn push_padding(snapshot: &Snapshot, node: &RawNode, props: &mut Vec) { else { return; }; - // An inside stroke is painted over the padding: a card 20px in from its + // With strokesIncludedInLayout=true Figma adds the stroke around the + // padding, like CSS border-box: retain every captured padding value. + // Otherwise an inside stroke is painted over the padding: a card 20px in from its // edge with a 1px stroke inside is still 20px in, stroke and all, and // its height is the content plus 40. CSS's border is added around the // padding instead, so a hugging card came out 2px taller - the landing @@ -480,7 +541,12 @@ fn push_padding(snapshot: &Snapshot, node: &RawNode, props: &mut Vec) { // fixed axis the content sat 1px further in. Taking the stroke out of // the padding puts the content where Figma has it on every axis: it // starts `p` in and, hugging, the box is content plus `2p`. - if view.string("strokeAlign").unwrap_or("INSIDE") == "INSIDE" + if let Some([new_top, new_right, new_bottom, new_left]) = + super::style::mixed_excluded_stroke_padding(&view, [top, right, bottom, left]) + { + [top, right, bottom, left] = [new_top, new_right, new_bottom, new_left]; + } else if !super::style::strokes_included_in_layout(&view) + && view.string("strokeAlign").unwrap_or("INSIDE") == "INSIDE" && !super::style::inside_stroke_uses_outline(&view) && view.node_type() != "LINE" && let Some(weight) = view.number("strokeWeight").filter(|weight| *weight > 0.0) @@ -588,6 +654,35 @@ fn sits_behind_in_flow_siblings(snapshot: &Snapshot, parent: &RawNode, child_id: /// in the same place and lets it size the frame; and a frame whose single /// child is centred is written as a `Center` with the child in flow, see /// `centres_its_only_child`. +fn has_clipped_free_child_offset(snapshot: &Snapshot, parent: &RawNode) -> bool { + let view = parent.typed_view(); + if view.string("layoutMode") != Some("NONE") + || view.bool("clipsContent") != Some(true) + || view + .number("rotation") + .is_some_and(|rotation| rotation.abs() > 0.01) + { + return false; + } + let Some(child) = only_visible_child(snapshot, parent) else { + return false; + }; + if child + .typed_view() + .number("rotation") + .is_some_and(|rotation| rotation.abs() > 0.01) + { + return false; + } + let (Some(parent_box), Some(child_box)) = (layout_box(parent), layout_box(child)) else { + return false; + }; + // Inferred auto layout cannot reproduce a manually offset child before + // the clipped free frame's origin. Keep these measured coordinates through + // the ordinary absolute-placement path; no scrolling is inferred. + child_box.x < parent_box.x - 0.5 || child_box.y < parent_box.y - 0.5 +} + pub(crate) fn placed_by_a_free_layout( snapshot: &Snapshot, node: &RawNode, @@ -615,10 +710,11 @@ pub(crate) fn placed_by_a_free_layout( Some("AUTO" | "ABSOLUTE") ) && parent_view.number("width").is_some() && parent_view.number("height").is_some() - && parent_view + && (parent_view .value("inferredAutoLayout") .and_then(Value::as_object) .is_none() + || has_clipped_free_child_offset(snapshot, parent)) && !matches!( parent_view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL" | "GRID") @@ -696,6 +792,22 @@ fn push_absolute( string_prop(props, "left", "50%"); string_prop(props, "transform", "translateX(-50%)"); } + (None, "MIN") + if x < 0.0 + && parent.string("layoutSizingHorizontal") == Some("FILL") + && parent.number("width").is_some_and(|width| placed.w > width) + && has_clipped_free_child_offset(snapshot, parent_node) => + { + // Keep the captured negative view while the track overhangs, then + // release it to the origin when the fluid frame can show it all. + // Every length is the captured offset or child width; no scroll + // interaction or guessed breakpoint is introduced. + string_prop( + props, + "left", + format!("clamp({}, calc(100% - {}), 0px)", px(x), px(placed.w)), + ); + } (None, _) => string_prop(props, "left", px(x)), } match vertical { @@ -716,6 +828,9 @@ fn push_absolute( } _ => string_prop(props, "top", px(y)), } + if policy.is_none() && placed_by.is_none() { + correct_absolute_border_origin(snapshot, node, parent_node, props); + } anchor } diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs b/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs index 8648f190..08030b58 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs @@ -1,953 +1,900 @@ -use devup_mcp_figma::{RawNode, Snapshot, TypedNode}; -use serde_json::Value; - -use super::component::{Prop, PropValue, RootLayout}; -use super::width::{Anchor, WidthPolicy}; - -/// A box in a parent's coordinates: left, top, width, height. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct Box4 { - pub x: f64, - pub y: f64, - pub w: f64, - pub h: f64, -} - -fn box4(value: Option<&Value>) -> Option { - let value = value?; - Some(Box4 { - x: value.get("x")?.as_f64()?, - y: value.get("y")?.as_f64()?, - w: value.get("width")?.as_f64()?, - h: value.get("height")?.as_f64()?, - }) -} - -/// What Figma exports for an asset node, in its parent's coordinates: the -/// node's render bounds, which frame its SVG and PNG exports - measured -/// against `exportAsync` on the official server, 2026-09-07: an instance of -/// 1373x98 whose vector sits inside it exports as 952x104, and a group of -/// 686x735 rotated four degrees exports as 759x585 with the rotation drawn -/// into the paths. `None` where the snapshot does not carry the bounds. -pub(crate) fn export_box(snapshot: &Snapshot, node: &RawNode) -> Option { - let view = node.typed_view(); - let render = box4(view.value("absoluteRenderBounds"))?; - let parent = view - .string("parentId") - .and_then(|parent_id| snapshot.nodes.get(parent_id))?; - let parent_box = box4(parent.typed_view().value("absoluteBoundingBox"))?; - Some(Box4 { - x: render.x - parent_box.x, - y: render.y - parent_box.y, - w: render.w, - h: render.h, - }) -} - -/// Where an in-flow asset's export sits inside the box the layout gives it, -/// and how large it is: the render bounds against the bounding box. `None` -/// when they coincide, which is every plain icon, or when the snapshot does -/// not carry them. -pub(super) fn export_offset(node: &RawNode) -> Option { - let view = node.typed_view(); - let render = box4(view.value("absoluteRenderBounds"))?; - let bounds = box4(view.value("absoluteBoundingBox"))?; - let close = |left: f64, right: f64| (left - right).abs() < 0.5; - if close(render.x, bounds.x) - && close(render.y, bounds.y) - && close(render.w, bounds.w) - && close(render.h, bounds.h) - { - return None; - } - Some(Box4 { - x: render.x - bounds.x, - y: render.y - bounds.y, - w: render.w, - h: render.h, - }) -} - -/// The box the layout gives an asset that is not positioned: its bounding -/// box, which is its own box unless it is rotated, when it is the box the -/// rotation sweeps - the box Figma's own layout gives it. -pub(crate) fn layout_box(node: &RawNode) -> Option { - box4(node.typed_view().value("absoluteBoundingBox")) -} - -// Root placement, width policy and image representation are independent -// inputs to this shared layout property writer. -#[allow(clippy::too_many_arguments)] -pub(super) fn push_layout_props( - snapshot: &Snapshot, - node: &RawNode, - component: &str, - props: &mut Vec, - root_layout: RootLayout, - is_render_root: bool, - policy: Option<&WidthPolicy>, - original_source: bool, -) { - let view = node.typed_view(); - let parent = snapshot.nodes.values().find(|candidate| { - candidate - .typed_view() - .child_ids() - .any(|child| child == node.id) - }); - let is_root = snapshot.roots.iter().any(|root| root == &node.id); - // The parent of a collected root sits outside the collected subtree, so it - // cannot be looked up and the node's recorded parent type is the only - // account of it. Without that fallback a screen read as having no parent at - // all and its canvas width was emitted as a real constraint, pinning the - // result to a device size that does not exist. - let is_page_root = parent - .map(|parent| parent.typed_view().node_type()) - .or_else(|| view.string("parentType")) - .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE")); - let fixed_w = view.string("layoutSizingHorizontal") == Some("FIXED"); - let fixed_h = view.string("layoutSizingVertical") == Some("FIXED"); - let fill_w = view.string("layoutSizingHorizontal") == Some("FILL"); - let fill_h = view.string("layoutSizingVertical") == Some("FILL"); - let absolute = parent.is_some() && (view.string("layoutPositioning") == Some("ABSOLUTE") - || placed_by_a_free_layout(snapshot, node, parent, is_page_root)); - let embedded_root = is_render_root && root_layout == RootLayout::Embedded; - let containing_block = holds_positioned_children(snapshot, node); - let mut width = None; - let mut height = None; - let mut policy_sizing = None; - - if embedded_root { - // The selected frame is being inserted into an existing page layout. - // Preserve its visual/layout semantics, but do not constrain the host - // with Figma canvas geometry or root positioning. - } else if absolute { - let is_asset = !original_source && super::style::asset_kind(snapshot, node).is_some(); - // An exported asset is placed by what the export frames. The plugin - // places it by the node's own box and rotates it again, and the - // report section's rotated illustration landed 120px low, squeezed - // into 686x735 where its export is 759x585. - let export = is_asset.then(|| export_box(snapshot, node)).flatten(); - let anchor = push_absolute(snapshot, node, parent, props, export, policy); - // Figma paints children in order, so a pinned picture drawn before - // its siblings sits behind them. CSS paints a positioned element - // after every in-flow sibling whatever the order, so the landing - // page's hero picture came out over its headline and the join-us - // badges over their buttons. Sent behind with `zIndex="-1"` - inside - // the stacking context the parent opens for it, below - it sits - // where Figma has it: above the parent's own background, under the - // content. - // - // Only under a parent that opens that context, including page roots. - if let Some(parent) = parent - && holds_positioned_children(snapshot, parent) - && sits_behind_in_flow_siblings(snapshot, parent, &node.id) - { - string_prop(props, "zIndex", "-1"); - } - if let Some(export) = export { - width = Some(px(export.w)); - height = Some(px(export.h)); - } - // The plugin's `_getLayoutProps` for a positioned node, as one rule - // rather than a branch per kind of node. Its width is its own only - // while the parent is wider and it is an asset or an empty frame; - // spilling past the parent it is `100%`, and a frame with children in - // it has no width said at all, its children being what sizes it. Its - // height is `100%` for a shape, its own for an empty frame, and unsaid - // once it has children. So the about hero picture, 418px in a 320px - // column, is `boxSize="100%"`, and a hidden 1920px frame in a 992px - // one is `w="100%" h="667px"`. - // - // Preserve the existing placement policy here. Only child-folded mask - // HUG axes are repaired below; other unproven sizes remain diagnostics. - let own_width = view.number("width"); - let parent_width = parent.and_then(|parent| parent.typed_view().number("width")); - if export.is_some() { - // Sized above, by the export. - } else if component == "Text" { - width = own_width.map(px); - height = Some("100%".to_owned()); - } else if view.node_type() == "INSTANCE" && is_asset { - // The plugin puts a positioned instance in a wrapper `Box` that - // carries the position, and draws the instance from its main - // component, which sizes itself by its own layout. An instance - // that folds to an asset is one element here, so it takes that - // size: a 651px logo pinned in a 360px banner is - // `w="651px" h="46px"`, not `w="100%"`. An instance kept as a - // component reference is sized as the wrapper is, below. - if fixed_w { - width = own_width.map(px); - } - if fixed_h { - height = view.number("height").map(px); - } - } else { - let parent_wider = matches!( - (own_width, parent_width), - (Some(width), Some(parent_width)) if parent_width > width - ); - let has_children = view.child_ids().next().is_some(); - let holds_children = matches!( - view.node_type(), - "FRAME" - | "GROUP" - | "INSTANCE" - | "COMPONENT" - | "COMPONENT_SET" - | "BOOLEAN_OPERATION" - ); - let empty_frame = holds_children && !has_children; - width = if parent_wider { - (is_asset || empty_frame) - .then(|| own_width.map(px)) - .flatten() - } else { - Some("100%".to_owned()) - }; - let wider_than_parent = matches!( - (own_width, parent_width), - (Some(width), Some(parent_width)) if width >= parent_width - ); - // A shape with nothing in it is as big as itself. `100%` is - // only the same thing when it covers its parent - the popup's - // dim overlay - and a 220px circle pinned in an 1,102px group - // said `h="100%"` and no width at all, which is no circle. - // - // Only inside a group. The plugin's rule says `100%` and no - // width for a small shape pinned in a frame as well, and the - // pinned corpus holds four such shapes; that is as wrong there, - // but no rendered screen in the corpus shows it, so it keeps - // byte parity until one does. A group is a different case in - // any event: it draws nothing and lays nothing out, so a shape - // in it can only ever be its own size. - let own_height = view.number("height"); - let parent_height = parent.and_then(|parent| parent.typed_view().number("height")); - let parent_taller = matches!( - (own_height, parent_height), - (Some(height), Some(parent_height)) if parent_height > height - ); - let in_a_group = parent.is_some_and(|parent| parent.node_type == "GROUP"); - let leaf_shape = !has_children && !holds_children && !is_asset && in_a_group; - if leaf_shape && parent_wider { - width = own_width.map(px); - } - height = if has_children { - (is_asset && !wider_than_parent) - .then(|| own_height.map(px)) - .flatten() - } else if empty_frame || (leaf_shape && parent_taller) { - own_height.map(px) - } else { - Some("100%".to_owned()) - }; - } - // An absolutely positioned node is out of flow, so nothing constrains - // it from the outside and the branches above may leave it sizeless, - // expecting its children to define the box. That is wrong whenever - // Figma pinned the size and nothing else accounts for it — a folded - // asset has no children left to measure at all. Where the gap around - // the children became padding, though, that padding and the content - // already add back up to the frame, and restating the size only says - // it twice. - // - // Ordinary frames may derive size from their content or padding. - // Child-folded HUG masks receive a bounded size repair below. - if fixed_w - && fixed_h - && width.is_none() - && height.is_none() - && derived_padding(snapshot, node).is_none() - { - width = view.number("width").map(px); - height = view.number("height").map(px); - } - // The same fact on the height alone, for a frame that fills its - // parent's width and so was given one above. Its children size it in - // CSS where Figma pinned it: the notice header is 60 tall around a - // 24px row of logo and menu, and centring them in 24 rather than 60 - // put them 18px high of where Figma draws them. - // - // Child-folded HUG masks have a repair below. A FIXED container height - // is independent of the current child's HUG height and derived insets. - if fixed_h && height.is_none() && !is_asset && view.child_ids().next().is_some() { - // Derived insets describe the captured child geometry, not a height - // constraint. HUG content can change without changing this FIXED box. - height = view.number("height").map(px); - } - policy_sizing = anchor.zip(parent).map(|(anchor, parent)| { - let parent_view = parent.typed_view(); - ( - anchor, - super::width::placed_box(node, parent, export), - parent_view.number("width").unwrap_or(0.0), - parent_view.number("height").unwrap_or(0.0), - is_asset, - ) - }); - } else if is_page_root { - // Figma page roots define the component canvas; their editor dimensions - // are not emitted as runtime constraints: a root's width is the - // viewport's, and a root that lays its children out is as tall as - // they are, at any width. - // - // A root that lays nothing out has nothing in flow to give it a - // height — its children are placed absolutely, or its one child is - // centred in it. The plugin leaves it sizeless too, and the popup - // overlay, a 390×800 frame dimmed behind one centred card, comes out - // a box of no height whose dim is never drawn; the answer it wrote - // gave the height back as padding, `py="211.5px"` around a 377px - // card. The drawn height is the design, and it is kept, one value per - // width. The width is still the viewport's. This departs from the - // plugin on purpose. - if lays_nothing_out(node) - && view.child_ids().next().is_some() - && derived_padding(snapshot, node).is_none() - { - height = view.number("height").map(px); - } - } else if fixed_w || fixed_h { - if fixed_w { - width = view.number("width").map(px); - } - if fixed_h { - height = view.number("height").map(px); - } - // A rotated asset in flow takes the box its rotation sweeps, which - // is the box Figma's layout gives it and the box its export fills; - // its own width and height are the picture's before the turn. - if view - .number("rotation") - .is_some_and(|rotation| rotation.abs() > 0.01) - && super::style::asset_kind(snapshot, node).is_some() - && !original_source - && let Some(bounds) = layout_box(node) - { - if fixed_w { - width = Some(px(bounds.w)); - } - if fixed_h { - height = Some(px(bounds.h)); - } - } - if fill_w - && (view.value("maxWidth") != Some(&Value::Null) - || parent.is_some_and(|parent| child_shrinker(parent, "width"))) - { - width = Some("100%".to_owned()); - } - if fill_h - && (view.value("maxHeight") != Some(&Value::Null) - || parent.is_some_and(|parent| child_shrinker(parent, "height"))) - { - height = Some("100%".to_owned()); - } - } else if is_root { - if fill_w || view.string("layoutAlign") == Some("STRETCH") { - width = Some("100%".to_owned()); - } - if fill_h { - height = Some("100%".to_owned()); - } - } else { - if fill_w - && (view.value("maxWidth") != Some(&Value::Null) - || parent.is_some_and(|parent| child_shrinker(parent, "width"))) - { - width = Some("100%".to_owned()); - } else if fixed_w { - width = view.number("width").map(px); - } - if fill_h - && (view.value("maxHeight") != Some(&Value::Null) - || parent.is_some_and(|parent| child_shrinker(parent, "height"))) - { - height = Some("100%".to_owned()); - } else if fixed_h { - height = view.number("height").map(px); - } - if view.node_type() != "COMPONENT" - && view.number("width").is_none() - && view.number("height").is_none() - { - width = Some("100%".to_owned()); - height = Some("100%".to_owned()); - } - } - - // A canvas with positioned children is their coordinate system, not just - // a responsive page measurement. Preserve each FIXED axis even on a page - // or section root. Embedded mode explicitly delegates dimensions to host. - if containing_block && !embedded_root { - if fixed_w { - width = view.number("width").map(px); - } - if fixed_h { - height = view.number("height").map(px); - } - } - - // A FIXED column is also the height basis for in-flow FILL children. - // This relationship does not depend on positioned descendants (R6). - if !embedded_root && vertical_fill_container(snapshot, node) { - height = view.number("height").map(px); - } - let vertical_fill = !absolute - && !embedded_root - && fill_h - && parent.is_some_and(|p| p.typed_view().string("layoutMode") == Some("VERTICAL")); - if vertical_fill { - // Main-axis FILL means remaining space, not 100% of the whole parent. - height = None; - } - - if component == "Text" && fixed_w && fixed_h { - match view.string("textAutoResize") { - Some("WIDTH_AND_HEIGHT") => { - if view.number("width").is_some() || view.number("height").is_some() { - width = None; - height = None; - } - } - Some("HEIGHT") => { - if let Some(text_width) = view.number("width") { - width = Some(px(text_width)); - height = None; - } - } - Some("NONE" | "TRUNCATE") if !is_page_root => { - width = view.number("width").map(px).or(width); - height = view.number("height").map(px).or(height); - } - _ => {} - } - } - - // Only folding children into a CSS mask destroys HUG's intrinsic basis. - // FILL remains fluid; a vector's measured ratio supplies its lost HUG axis. - let mut restored_ratio = None; - if !embedded_root && let Some((w, h)) = folded_mask_dimensions(snapshot, node) { - let horizontal = view.string("layoutSizingHorizontal"); - let vertical = view.string("layoutSizingVertical"); - if horizontal == Some("FILL") && vertical == Some("HUG") { - width = Some("100%".into()); - height = None; - restored_ratio = Some(format!("{w} / {h}")); - } else if horizontal == Some("HUG") && vertical == Some("FILL") { - width = None; - height = Some("100%".into()); - restored_ratio = Some(format!("{w} / {h}")); - } else { - if horizontal == Some("HUG") { - width = Some(px(w)); - } - if vertical == Some("HUG") { - height = Some(px(h)); - } - } - } - - // Horizontal FILL is independent of the other axis. Parent alignment - // can disable implicit stretch; preserve the fluid allocation explicitly. - if !absolute - && !embedded_root - && fill_w - && width.is_none() - && parent.is_some_and(|p| p.typed_view().string("layoutMode") == Some("VERTICAL")) - && parent.is_some_and(|p| { - matches!( - p.typed_view().string("counterAxisAlignItems"), - Some("CENTER" | "MAX" | "BASELINE") - ) - }) - { - width = Some("100%".into()); - } - - // Cross-axis FILL must stretch even when the row centers other children. - // Percentage height cannot resolve against a HUG row's auto height. - if !absolute - && !embedded_root - && fill_h - && parent.is_some_and(|p| { - p.typed_view().string("layoutMode") == Some("HORIZONTAL") - && p.typed_view().string("layoutSizingVertical") == Some("HUG") - }) - { - height = None; - string_prop(props, "alignSelf", "stretch"); - } - - // Under a width policy the horizontal anchor decides the width, after - // every other rule: a stretch is its two edges, a scale its share of the - // parent, and any other anchor holds a childless shape at its own drawn - // size, which the plugin's `h="100%"` and no width does not - except on an - // axis where it spans its whole parent, which keeps following it as the - // default export already writes it. - if let Some((anchor, placed, parent_width, parent_height, is_asset)) = policy_sizing { - let shape = component != "Text" && !is_asset && view.child_ids().next().is_none(); - match anchor { - Anchor::Stretch => width = None, - Anchor::Scale => width = Some(super::width::percent(placed.w, parent_width)), - _ if shape && placed.w + 0.5 < parent_width => width = view.number("width").map(px), - _ => {} - } - if shape && placed.h + 0.5 < parent_height { - height = view.number("height").map(px); - } - } - // A root under a width policy takes its container's width; the drawn - // width stays the coordinate basis its positioned children were placed - // in, and the widthBehavior report says what that leaves unproven. - if policy.is_some() - && is_render_root - && !embedded_root - && width.as_deref().is_some_and(|width| width.ends_with("px")) - { - width = Some("100%".to_owned()); - } - // Whether the height was said outright, which decides below whether the - // node still needs to be told to take the space its parent leaves. - if original_source && absolute { - if fixed_w { - width = view.number("width").map(px); - } - if fixed_h { - height = view.number("height").map(px); - } - } - let wrote_height = height.is_some(); - let wrote_width = width.is_some(); - if let (Some(width), Some(height)) = (&width, &height) - && width == height - { - string_prop(props, "boxSize", width.clone()); - } else { - if let Some(width) = width { - string_prop(props, "w", width); - } - if let Some(height) = height { - string_prop(props, "h", height); - } - } - - // A positioned node has its size said outright — the plugin's absolute - // branch of `_getLayoutProps` never writes `aspectRatio` — so the ratio - // is only for a node in flow, where it stands in for a side that is not - // written. The about hero picture is `boxSize="100%"` with no ratio. - if let Some(ratio) = restored_ratio { - string_prop(props, "aspectRatio", ratio); - } else if !absolute - && let Some(aspect) = view.value("targetAspectRatio").and_then(Value::as_object) - && let (Some(x), Some(y)) = ( - aspect.get("x").and_then(Value::as_f64), - aspect.get("y").and_then(Value::as_f64), - ) - && y != 0.0 - { - string_prop( - props, - "aspectRatio", - format_number((x / y * 100.0).floor() / 100.0), - ); - } - for (field, prop) in [ - ("maxWidth", "maxW"), - ("maxHeight", "maxH"), - ("minWidth", "minW"), - ("minHeight", "minH"), - ] { - if let Some(value) = view.number(field) { - string_prop(props, prop, px(value)); - } - } - if view.string("parentId").is_some() - && let Some(parent) = parent - // A component set's grid is how Figma lays its variants out on the - // canvas, not how the component is built. A variant is drawn on its - // own wherever it is used, so carrying the cell it sat in would place - // every button at the coordinates of its row in the sheet. - && parent.typed_view().node_type() != "COMPONENT_SET" - && parent - .typed_view() - .value("inferredAutoLayout") - .and_then(Value::as_object) - .and_then(|layout| layout.get("layoutMode")) - .and_then(Value::as_str) - == Some("GRID") - { - let column = view.number("gridColumnAnchorIndex").unwrap_or(-1.0); - let row = view.number("gridRowAnchorIndex").unwrap_or(-1.0); - // How many tracks the child covers. The plugin writes `span 1` for - // every child, and a picture drawn across two columns came out in one, - // its neighbour pushed a row down and every `1fr` row stretched to - // the tallest picture: a 1086px grid rendered 2191px tall. The spans - // are Figma's own, absent from the snapshot when they are 1. - let column_span = view.number("gridColumnSpan").unwrap_or(1.0).max(1.0); - let row_span = view.number("gridRowSpan").unwrap_or(1.0).max(1.0); - let column_count = parent.typed_view().number("gridColumnCount").unwrap_or(0.0); - let current = column + row * column_count; - let natural = parent - .typed_view() - .child_ids() - .position(|child| child == node.id) - .map(|index| index as f64); - // A child at its natural cell needs no placement - unless it spans, - // which flow alone would not give it. - if column >= 0.0 - && row >= 0.0 - && (natural != Some(current) || column_span > 1.0 || row_span > 1.0) - { - string_prop( - props, - "gridColumn", - format!( - "{} / span {}", - format_number(column + 1.0), - format_number(column_span) - ), - ); - string_prop( - props, - "gridRow", - format!( - "{} / span {}", - format_number(row + 1.0), - format_number(row_span) - ), - ); - } - } - if fill_w - && parent - .is_some_and(|parent| parent.typed_view().string("layoutMode") == Some("HORIZONTAL")) - { - string_prop(props, "flex", "1"); - // `flex: 1` shares out the room left over, and Figma gives a filling - // child exactly that - here 123px - and lets anything wider inside it - // spill out. CSS will not shrink a flex item under its content - // (`min-width: auto`), so a 132px row inside a 123px share widened - // the share to 132 and took the 9px from the hugging sibling, whose - // `Devup-ui` then broke across two lines. `minW="0"` lets the share - // be the share, and the content spill as Figma draws it. Written - // only where the snapshot shows content wider than the box. A row - // can overflow collectively even when each child fits individually. - // Wrapping rows can distribute that content over multiple lines. - let child_overflows = view.number("width").is_some_and(|own| { - view.child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .any(|child| { - let child = child.typed_view(); - child.bool("visible") != Some(false) - && child.string("layoutPositioning") != Some("ABSOLUTE") - && child.number("width").is_some_and(|width| width > own + 0.5) - }) - }); - let row_overflows = view.string("layoutMode") == Some("HORIZONTAL") - && view.string("layoutWrap") != Some("WRAP") - && line_overflows(snapshot, node, "width", "paddingLeft", "paddingRight"); - if view.number("minWidth").is_none() && (child_overflows || row_overflows) { - string_prop(props, "minW", "0"); - } - } - // Main-axis FILL consumes remaining space, whether its own children are - // positioned or in flow. This keeps the completion screen's body ahead of - // the following bottom action without copying any canvas coordinates. - if vertical_fill { - string_prop(props, "flex", "1"); - if vertical_fill_needs_minimum_reset(snapshot, node) { - string_prop(props, "minH", "0"); - } - } - // A child Figma never shrinks, in a line that does not fit. Figma keeps a - // fixed size and lets the row spill past its parent, which clips it; CSS - // shrinks flex children to fit instead. The devup-ui landing page's - // comparison row is seven 240px cards in a 912px frame - 1,800px of - // content - and every one of them was squeezed to about 120px, their - // labels wrapped to two lines, and the row came out 58px taller than the - // design, carrying everything below it down with it. - // - // Only where the line actually overflows. Children that fit are not - // shrunk by CSS either, and saying so for every fixed child in the file - // would be noise. - if !absolute - && let Some(parent) = parent - && let Some(axis) = parent.typed_view().string("layoutMode") - && matches!(axis, "VERTICAL" | "HORIZONTAL") - { - let (sizing, size, near, far) = if axis == "HORIZONTAL" { - ( - "layoutSizingHorizontal", - "width", - "paddingLeft", - "paddingRight", - ) - } else { - ( - "layoutSizingVertical", - "height", - "paddingTop", - "paddingBottom", - ) - }; - if view.string(sizing) == Some("FIXED") && line_overflows(snapshot, parent, size, near, far) - { - string_prop(props, "flexShrink", "0"); - } - } - // A child that hugs across its parent's axis, in a parent that packs its - // children to the start, drawn narrower than the room it has, and drawn - // differently for being stretched into it. - // - // Figma's default counter-axis alignment is MIN, which it writes by - // leaving the field out - and leaving it out of the code too means CSS - // applies its own default, `align-items: stretch`, which is the - // opposite. The devup-ui landing page's `Get started` button, 247px wide - // in the 1360px column that holds it, was drawn 1360px wide. - // - // Most hugging children do not care. A line of left-aligned text in a - // box that paints nothing is the same picture at any width, and writing - // an alignment for every one of them - the notice desktop alone holds - // twenty - says nothing while burying the few that matter. So it is - // written where the wider box would show: where the node paints across - // it, or places its own content by it. - if !absolute - && let Some(parent) = parent - && let Some(axis) = parent.typed_view().string("layoutMode") - && matches!(axis, "VERTICAL" | "HORIZONTAL") - && matches!( - parent.typed_view().string("counterAxisAlignItems"), - None | Some("MIN") - ) - { - let across_is_horizontal = axis == "VERTICAL"; - let (sizing, size, near, far, wrote) = if across_is_horizontal { - ( - "layoutSizingHorizontal", - "width", - "paddingLeft", - "paddingRight", - wrote_width, - ) - } else { - ( - "layoutSizingVertical", - "height", - "paddingTop", - "paddingBottom", - wrote_height, - ) - }; - if view.string(sizing) == Some("HUG") - && !wrote - && let (Some(own), Some(room)) = - (view.number(size), inner_extent(parent, size, near, far)) - && own + 0.5 < room - && a_wider_box_would_show(&view, across_is_horizontal) - { - string_prop(props, "alignSelf", "flex-start"); - } - } - - push_auto_layout(snapshot, node, component, props); - push_padding(snapshot, node, props); - match view.string("overflowDirection") { - Some("VERTICAL_SCROLLING") => { - string_prop(props, "overflowY", "auto"); - if view.bool("clipsContent") == Some(true) { - string_prop(props, "overflowX", "hidden"); - } - } - Some("HORIZONTAL_SCROLLING") => { - string_prop(props, "overflowX", "auto"); - if view.bool("clipsContent") == Some(true) { - string_prop(props, "overflowY", "hidden"); - } - } - Some("HORIZONTAL_AND_VERTICAL_SCROLLING") => string_prop(props, "overflow", "auto"), - _ if view.bool("clipsContent") == Some(true) => string_prop(props, "overflow", "hidden"), - _ => {} - } - // An absolutely positioned child needs a positioned ancestor to resolve - // against — but a node folded into a single asset has no children left in - // the output, so there is nothing to anchor and the containing block would - // exist for no one. - // - // A node that is itself positioned is already that ancestor, and saying - // `relative` over its `absolute` would put it back in flow: the join-us - // group of circles, pinned at -277,-187, took 1,102px of the page. - if containing_block && (!absolute || embedded_root) { - string_prop(props, "pos", "relative"); - } - // Position (relative or absolute) establishes a containing block, but - // needs an explicit z-index to contain negative-z children above its own - // background. Keep an existing z-index that places this entire subtree. - if containing_block - && !props.iter().any(|(name, _)| name == "zIndex") - && view - .child_ids() - .any(|child| sits_behind_in_flow_siblings(snapshot, node, child)) - { - string_prop(props, "zIndex", "0"); - } - // An export is drawn with its rotation in it, so an asset whose bounds - // the snapshot carries is not rotated again; without the bounds it is - // placed as the plugin places it, rotation and all. - let exported = !original_source - && super::style::asset_kind(snapshot, node).is_some() - && view.value("absoluteRenderBounds").is_some(); - if let Some(rotation) = view.number("rotation") - && rotation.abs() > 0.01 - && !exported - { - string_prop( - props, - "transform", - format!("rotate({}deg)", format_number(-rotation)), - ); - if absolute { - string_prop(props, "transformOrigin", "top left"); - } - } - if let Some(grid) = reading_grid(snapshot, node) { reading_grid_props(&grid, props); } - reading_card_props(snapshot, node, props); - fixed_row_props(snapshot, node, props); - if let Some(width) = navigation_width(snapshot, node) { string_prop(props, "data-navigation-width", format_number(width)); } -} - -pub(crate) fn vertical_fill_container(snapshot: &Snapshot, node: &RawNode) -> bool { - let view = node.typed_view(); - view.string("layoutMode") == Some("VERTICAL") - && view.string("layoutSizingVertical") == Some("FIXED") - && view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .any(|child| { - let child = child.typed_view(); - child.bool("visible") != Some(false) - && child.string("layoutPositioning") != Some("ABSOLUTE") - && child.string("layoutSizingVertical") == Some("FILL") - }) -} - -/// CSS's automatic flex minimum must not enlarge a captured FILL allocation. -/// Preserve explicit source minimums; only reset the implicit content minimum -/// when collected in-flow content demonstrably overflows the allocated height. -pub(crate) fn vertical_fill_needs_minimum_reset(snapshot: &Snapshot, node: &RawNode) -> bool { - let view = node.typed_view(); - if view.number("minHeight").is_some() { - return false; - } - (view.string("layoutMode") == Some("VERTICAL") - && line_overflows(snapshot, node, "height", "paddingTop", "paddingBottom")) - || inner_extent(node, "height", "paddingTop", "paddingBottom").is_some_and(|room| { - view.child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .any(|child| { - let child = child.typed_view(); - child.bool("visible") != Some(false) - && child.string("layoutPositioning") != Some("ABSOLUTE") - && child - .number("height") - .is_some_and(|height| height > room + 0.5) - }) - }) -} - -/// Only children that can survive projection need a containing block. -pub(super) fn holds_positioned_children(snapshot: &Snapshot, node: &RawNode) -> bool { - super::style::asset_kind(snapshot, node).is_none() - && node.typed_view().child_ids().any(|id| { - snapshot.nodes.get(id).is_some_and(|child| { - let view = child.typed_view(); - view.bool("visible") != Some(false) - && (view.string("layoutPositioning") == Some("ABSOLUTE") - || placed_by_a_free_layout(snapshot, child, Some(node), false)) - }) - }) -} - -pub(super) fn absolute_layout_is_exact(snapshot: &Snapshot, node: &RawNode) -> bool { - let view = node.typed_view(); - if view.string("layoutPositioning") != Some("ABSOLUTE") { - return true; - } - let parent = snapshot.nodes.values().find(|candidate| { - candidate - .typed_view() - .child_ids() - .any(|child_id| child_id == node.id) - }); - let has_geometry = ["x", "y", "width", "height"] - .into_iter() - .all(|field| view.number(field).is_some_and(f64::is_finite)); - let constraints = view.value("constraints").and_then(Value::as_object); - let supported_constraint = |axis: &str| { - constraints - .and_then(|value| value.get(axis)) - .and_then(Value::as_str) - .is_none_or(|value| matches!(value, "MIN" | "MAX")) - }; - let no_rotation = view.number("rotation").is_none_or(|value| value == 0.0); - let exact_size = parent.is_some_and(|parent| { - // A node pinned on both axes now emits those exact dimensions even - // when it has children, because the absolute branch of - // `push_layout_props` restates them rather than letting the children - // define the box. Keep this in step with that branch: judging such a - // node approximated would report a loss the output no longer has. - if view.string("layoutSizingHorizontal") == Some("FIXED") - && view.string("layoutSizingVertical") == Some("FIXED") - && view.child_ids().next().is_some() - { - return true; - } - if view.node_type() != "FRAME" { - return false; - } - if view.child_ids().next().is_none() { - return true; - } - let inferred_auto_layout = view - .value("inferredAutoLayout") - .and_then(Value::as_object) - .is_some(); - let parent = parent.typed_view(); - let horizontal = view.string("layoutSizingHorizontal") == Some("HUG") - && inferred_auto_layout - || matches!( - (view.number("width"), parent.number("width")), - (Some(width), Some(parent_width)) if width == parent_width - ); - let vertical = view.string("layoutSizingVertical") == Some("HUG") && inferred_auto_layout; - horizontal && vertical - }); - parent.is_some() - && has_geometry - && supported_constraint("horizontal") - && supported_constraint("vertical") - && no_rotation - && exact_size -} - -fn child_shrinker(parent: &RawNode, dimension: &str) -> bool { - let inferred = parent - .typed_view() - .value("inferredAutoLayout") - .and_then(Value::as_object); - match dimension { - "width" => inferred.is_some_and(|layout| { - layout.get("layoutMode").and_then(Value::as_str) == Some("VERTICAL") - && layout.get("counterAxisAlignItems").and_then(Value::as_str) == Some("CENTER") - }), - "height" => inferred.is_some_and(|layout| { - layout.get("layoutMode").and_then(Value::as_str) == Some("HORIZONTAL") - && layout.get("counterAxisAlignItems").and_then(Value::as_str) == Some("CENTER") - }), - _ => false, - } -} - -pub(super) struct GridTemplate { - pub css: Option, - pub unverified: Vec<&'static str>, -} +use devup_mcp_figma::{RawNode, Snapshot, TypedNode}; +use serde_json::Value; + +use super::component::{Prop, PropValue, RootLayout}; +use super::width::{Anchor, WidthPolicy}; + +// Root placement, width policy and image representation are independent +// inputs to this shared layout property writer. +#[allow(clippy::too_many_arguments)] +pub(super) fn push_layout_props( + snapshot: &Snapshot, + node: &RawNode, + component: &str, + props: &mut Vec, + root_layout: RootLayout, + is_render_root: bool, + policy: Option<&WidthPolicy>, + original_source: bool, +) { + let view = node.typed_view(); + let parent = snapshot.nodes.values().find(|candidate| { + candidate + .typed_view() + .child_ids() + .any(|child| child == node.id) + }); + let is_root = snapshot.roots.iter().any(|root| root == &node.id); + // The parent of a collected root sits outside the collected subtree, so it + // cannot be looked up and the node's recorded parent type is the only + // account of it. Without that fallback a screen read as having no parent at + // all and its canvas width was emitted as a real constraint, pinning the + // result to a device size that does not exist. + let is_page_root = parent + .map(|parent| parent.typed_view().node_type()) + .or_else(|| view.string("parentType")) + .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE")); + let fixed_w = view.string("layoutSizingHorizontal") == Some("FIXED"); + let fixed_h = view.string("layoutSizingVertical") == Some("FIXED"); + let fill_w = view.string("layoutSizingHorizontal") == Some("FILL"); + let fill_h = view.string("layoutSizingVertical") == Some("FILL"); + let absolute = parent.is_some() + && (view.string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout(snapshot, node, parent, is_page_root)); + let embedded_root = is_render_root && root_layout == RootLayout::Embedded; + let containing_block = holds_positioned_children(snapshot, node); + let mut width = None; + let mut height = None; + let mut policy_sizing = None; + + if embedded_root { + // The selected frame is being inserted into an existing page layout. + // Preserve its visual/layout semantics, but do not constrain the host + // with Figma canvas geometry or root positioning. + } else if absolute { + let is_asset = !original_source && super::style::asset_kind(snapshot, node).is_some(); + // An exported asset is placed by what the export frames. The plugin + // places it by the node's own box and rotates it again, and the + // report section's rotated illustration landed 120px low, squeezed + // into 686x735 where its export is 759x585. + let export = is_asset.then(|| export_box(snapshot, node)).flatten(); + let anchor = push_absolute(snapshot, node, parent, props, export, policy); + // Figma paints children in order, so a pinned picture drawn before + // its siblings sits behind them. CSS paints a positioned element + // after every in-flow sibling whatever the order, so the landing + // page's hero picture came out over its headline and the join-us + // badges over their buttons. Sent behind with `zIndex="-1"` - inside + // the stacking context the parent opens for it, below - it sits + // where Figma has it: above the parent's own background, under the + // content. + // + // Only under a parent that opens that context, including page roots. + if let Some(parent) = parent + && holds_positioned_children(snapshot, parent) + && sits_behind_in_flow_siblings(snapshot, parent, &node.id) + { + string_prop(props, "zIndex", "-1"); + } + if let Some(export) = export { + width = Some(px(export.w)); + height = Some(px(export.h)); + } + // The plugin's `_getLayoutProps` for a positioned node, as one rule + // rather than a branch per kind of node. Its width is its own only + // while the parent is wider and it is an asset or an empty frame; + // spilling past the parent it is `100%`, and a frame with children in + // it has no width said at all, its children being what sizes it. Its + // height is `100%` for a shape, its own for an empty frame, and unsaid + // once it has children. So the about hero picture, 418px in a 320px + // column, is `boxSize="100%"`, and a hidden 1920px frame in a 992px + // one is `w="100%" h="667px"`. + // + // Preserve the existing placement policy here. Only child-folded mask + // HUG axes are repaired below; other unproven sizes remain diagnostics. + let own_width = view.number("width"); + let parent_width = parent.and_then(|parent| parent.typed_view().number("width")); + if export.is_some() { + // Sized above, by the export. + } else if let Some((w, h)) = fixed_native_ellipse_size(&view, component, is_asset) { + width = Some(px(w)); + height = Some(px(h)); + } else if component == "Text" { + width = own_width.map(px); + height = Some("100%".to_owned()); + } else if view.node_type() == "INSTANCE" && is_asset { + // The plugin puts a positioned instance in a wrapper `Box` that + // carries the position, and draws the instance from its main + // component, which sizes itself by its own layout. An instance + // that folds to an asset is one element here, so it takes that + // size: a 651px logo pinned in a 360px banner is + // `w="651px" h="46px"`, not `w="100%"`. An instance kept as a + // component reference is sized as the wrapper is, below. + if fixed_w { + width = own_width.map(px); + } + if fixed_h { + height = view.number("height").map(px); + } + } else { + let parent_wider = matches!( + (own_width, parent_width), + (Some(width), Some(parent_width)) if parent_width > width + ); + let has_children = view.child_ids().next().is_some(); + let holds_children = matches!( + view.node_type(), + "FRAME" + | "GROUP" + | "INSTANCE" + | "COMPONENT" + | "COMPONENT_SET" + | "BOOLEAN_OPERATION" + ); + let empty_frame = holds_children && !has_children; + width = if parent_wider { + (is_asset || empty_frame) + .then(|| own_width.map(px)) + .flatten() + } else { + Some("100%".to_owned()) + }; + let wider_than_parent = matches!( + (own_width, parent_width), + (Some(width), Some(parent_width)) if width >= parent_width + ); + // A shape with nothing in it is as big as itself. `100%` is + // only the same thing when it covers its parent - the popup's + // dim overlay - and a 220px circle pinned in an 1,102px group + // said `h="100%"` and no width at all, which is no circle. + // + // Only inside a group. The plugin's rule says `100%` and no + // width for a small shape pinned in a frame as well, and the + // pinned corpus holds four such shapes; that is as wrong there, + // but no rendered screen in the corpus shows it, so it keeps + // byte parity until one does. A group is a different case in + // any event: it draws nothing and lays nothing out, so a shape + // in it can only ever be its own size. + let own_height = view.number("height"); + let parent_height = parent.and_then(|parent| parent.typed_view().number("height")); + let parent_taller = matches!( + (own_height, parent_height), + (Some(height), Some(parent_height)) if parent_height > height + ); + let in_a_group = parent.is_some_and(|parent| parent.node_type == "GROUP"); + let leaf_shape = !has_children && !holds_children && !is_asset && in_a_group; + if leaf_shape && parent_wider { + width = own_width.map(px); + } + height = if has_children { + (is_asset && !wider_than_parent) + .then(|| own_height.map(px)) + .flatten() + } else if empty_frame || (leaf_shape && parent_taller) { + own_height.map(px) + } else { + Some("100%".to_owned()) + }; + } + // An absolutely positioned node is out of flow, so nothing constrains + // it from the outside and the branches above may leave it sizeless, + // expecting its children to define the box. That is wrong whenever + // Figma pinned the size and nothing else accounts for it — a folded + // asset has no children left to measure at all. Where the gap around + // the children became padding, though, that padding and the content + // already add back up to the frame, and restating the size only says + // it twice. + // + // Ordinary frames may derive size from their content or padding. + // Child-folded HUG masks receive a bounded size repair below. + if fixed_w + && fixed_h + && width.is_none() + && height.is_none() + && derived_padding(snapshot, node).is_none() + { + width = view.number("width").map(px); + height = view.number("height").map(px); + } + // The same fact on the height alone, for a frame that fills its + // parent's width and so was given one above. Its children size it in + // CSS where Figma pinned it: the notice header is 60 tall around a + // 24px row of logo and menu, and centring them in 24 rather than 60 + // put them 18px high of where Figma draws them. + // + // Child-folded HUG masks have a repair below. A FIXED container height + // is independent of the current child's HUG height and derived insets. + if fixed_h && height.is_none() && !is_asset && view.child_ids().next().is_some() { + // Derived insets describe the captured child geometry, not a height + // constraint. HUG content can change without changing this FIXED box. + height = view.number("height").map(px); + } + policy_sizing = anchor.zip(parent).map(|(anchor, parent)| { + let parent_view = parent.typed_view(); + ( + anchor, + super::width::placed_box(node, parent, export), + parent_view.number("width").unwrap_or(0.0), + parent_view.number("height").unwrap_or(0.0), + is_asset, + ) + }); + } else if is_page_root { + // Figma page roots define the component canvas; their editor dimensions + // are not emitted as runtime constraints: a root's width is the + // viewport's, and a root that lays its children out is as tall as + // they are, at any width. + // + // A root that lays nothing out has nothing in flow to give it a + // height — its children are placed absolutely, or its one child is + // centred in it. The plugin leaves it sizeless too, and the popup + // overlay, a 390×800 frame dimmed behind one centred card, comes out + // a box of no height whose dim is never drawn; the answer it wrote + // gave the height back as padding, `py="211.5px"` around a 377px + // card. The drawn height is the design, and it is kept, one value per + // width. The width is still the viewport's. This departs from the + // plugin on purpose. + if lays_nothing_out(node) + && view.child_ids().next().is_some() + && derived_padding(snapshot, node).is_none() + { + height = view.number("height").map(px); + } + } else if fixed_w || fixed_h { + if fixed_w { + width = view.number("width").map(px); + } + if fixed_h { + height = view.number("height").map(px); + } + // A rotated asset in flow takes the box its rotation sweeps, which + // is the box Figma's layout gives it and the box its export fills; + // its own width and height are the picture's before the turn. + if view + .number("rotation") + .is_some_and(|rotation| rotation.abs() > 0.01) + && super::style::asset_kind(snapshot, node).is_some() + && !original_source + && let Some(bounds) = layout_box(node) + { + if fixed_w { + width = Some(px(bounds.w)); + } + if fixed_h { + height = Some(px(bounds.h)); + } + } + if fill_w + && (view.value("maxWidth") != Some(&Value::Null) + || parent.is_some_and(|parent| child_shrinker(parent, "width"))) + { + width = Some("100%".to_owned()); + } + if fill_h + && (view.value("maxHeight") != Some(&Value::Null) + || parent.is_some_and(|parent| child_shrinker(parent, "height"))) + { + height = Some("100%".to_owned()); + } + } else if is_root { + if fill_w || view.string("layoutAlign") == Some("STRETCH") { + width = Some("100%".to_owned()); + } + if fill_h { + height = Some("100%".to_owned()); + } + } else { + if fill_w + && (view.value("maxWidth") != Some(&Value::Null) + || parent.is_some_and(|parent| child_shrinker(parent, "width"))) + { + width = Some("100%".to_owned()); + } else if fixed_w { + width = view.number("width").map(px); + } + if fill_h + && (view.value("maxHeight") != Some(&Value::Null) + || parent.is_some_and(|parent| child_shrinker(parent, "height"))) + { + height = Some("100%".to_owned()); + } else if fixed_h { + height = view.number("height").map(px); + } + if view.node_type() != "COMPONENT" + && view.number("width").is_none() + && view.number("height").is_none() + { + width = Some("100%".to_owned()); + height = Some("100%".to_owned()); + } + } + + // A canvas with positioned children is their coordinate system, not just + // a responsive page measurement. Preserve each FIXED axis even on a page + // or section root. Embedded mode explicitly delegates dimensions to host. + if containing_block && !embedded_root { + if fixed_w { + width = view.number("width").map(px); + } + if fixed_h { + height = view.number("height").map(px); + } + } + + // A FIXED column is also the height basis for in-flow FILL children. + // This relationship does not depend on positioned descendants (R6). + if !embedded_root && vertical_fill_container(snapshot, node) { + height = view.number("height").map(px); + } + let vertical_fill = !absolute + && !embedded_root + && fill_h + && parent.is_some_and(|p| p.typed_view().string("layoutMode") == Some("VERTICAL")); + if vertical_fill { + // Main-axis FILL means remaining space, not 100% of the whole parent. + height = None; + } + + if component == "Text" && fixed_w && fixed_h { + match view.string("textAutoResize") { + Some("WIDTH_AND_HEIGHT") => { + if view.number("width").is_some() || view.number("height").is_some() { + width = None; + height = None; + } + } + Some("HEIGHT") => { + if let Some(text_width) = view.number("width") { + width = Some(px(text_width)); + height = None; + } + } + Some("NONE" | "TRUNCATE") if !is_page_root => { + width = view.number("width").map(px).or(width); + height = view.number("height").map(px).or(height); + } + _ => {} + } + } + + // Only folding children into a CSS mask destroys HUG's intrinsic basis. + // FILL remains fluid; a vector's measured ratio supplies its lost HUG axis. + let mut restored_ratio = None; + if !embedded_root && let Some((w, h)) = folded_mask_dimensions(snapshot, node) { + let horizontal = view.string("layoutSizingHorizontal"); + let vertical = view.string("layoutSizingVertical"); + if horizontal == Some("FILL") && vertical == Some("HUG") { + width = Some("100%".into()); + height = None; + restored_ratio = Some(format!("{w} / {h}")); + } else if horizontal == Some("HUG") && vertical == Some("FILL") { + width = None; + height = Some("100%".into()); + restored_ratio = Some(format!("{w} / {h}")); + } else { + if horizontal == Some("HUG") { + width = Some(px(w)); + } + if vertical == Some("HUG") { + height = Some(px(h)); + } + } + } + + // Horizontal FILL is independent of the other axis. Parent alignment + // can disable implicit stretch; preserve the fluid allocation explicitly. + if !absolute + && !embedded_root + && fill_w + && width.is_none() + && parent.is_some_and(|p| p.typed_view().string("layoutMode") == Some("VERTICAL")) + && parent.is_some_and(|p| { + matches!( + p.typed_view().string("counterAxisAlignItems"), + Some("CENTER" | "MAX" | "BASELINE") + ) + }) + { + width = Some("100%".into()); + } + + // Cross-axis FILL must stretch even when the row centers other children. + // Percentage height cannot resolve against a HUG row's auto height. + if !absolute + && !embedded_root + && fill_h + && parent.is_some_and(|p| { + p.typed_view().string("layoutMode") == Some("HORIZONTAL") + && p.typed_view().string("layoutSizingVertical") == Some("HUG") + }) + { + height = None; + string_prop(props, "alignSelf", "stretch"); + } + + // Under a width policy the horizontal anchor decides the width, after + // every other rule: a stretch is its two edges, a scale its share of the + // parent, and any other anchor holds a childless shape at its own drawn + // size, which the plugin's `h="100%"` and no width does not - except on an + // axis where it spans its whole parent, which keeps following it as the + // default export already writes it. + if let Some((anchor, placed, parent_width, parent_height, is_asset)) = policy_sizing { + let shape = component != "Text" && !is_asset && view.child_ids().next().is_none(); + match anchor { + Anchor::Stretch => width = None, + Anchor::Scale => width = Some(super::width::percent(placed.w, parent_width)), + _ if shape && placed.w + 0.5 < parent_width => width = view.number("width").map(px), + _ => {} + } + if shape && placed.h + 0.5 < parent_height { + height = view.number("height").map(px); + } + } + // A root under a width policy takes its container's width; the drawn + // width stays the coordinate basis its positioned children were placed + // in, and the widthBehavior report says what that leaves unproven. + if policy.is_some() + && is_render_root + && !embedded_root + && width.as_deref().is_some_and(|width| width.ends_with("px")) + { + width = Some("100%".to_owned()); + } + // Whether the height was said outright, which decides below whether the + // node still needs to be told to take the space its parent leaves. + if original_source && absolute { + if fixed_w { + width = view.number("width").map(px); + } + if fixed_h { + height = view.number("height").map(px); + } + } + if absolute + && parent.is_some_and(|parent| has_clipped_free_child_offset(snapshot, parent)) + && (original_source || super::style::asset_kind(snapshot, node).is_none()) + { + // This free frame deliberately clips a manually offset layout child. + // Replacing its measured overhang with 100% changes that coordinate + // system and the captured visible state. Asset export bounds retain + // their separate sizing policy. + width = view.number("width").map(px).or(width); + } + let wrote_height = height.is_some(); + let wrote_width = width.is_some(); + if let (Some(width), Some(height)) = (&width, &height) + && width == height + { + string_prop(props, "boxSize", width.clone()); + } else { + if let Some(width) = width { + string_prop(props, "w", width); + } + if let Some(height) = height { + string_prop(props, "h", height); + } + } + + // A positioned node has its size said outright — the plugin's absolute + // branch of `_getLayoutProps` never writes `aspectRatio` — so the ratio + // is only for a node in flow, where it stands in for a side that is not + // written. The about hero picture is `boxSize="100%"` with no ratio. + if let Some(ratio) = restored_ratio { + string_prop(props, "aspectRatio", ratio); + } else if !absolute + && let Some(aspect) = view.value("targetAspectRatio").and_then(Value::as_object) + && let (Some(x), Some(y)) = ( + aspect.get("x").and_then(Value::as_f64), + aspect.get("y").and_then(Value::as_f64), + ) + && y != 0.0 + { + string_prop( + props, + "aspectRatio", + format_number((x / y * 100.0).floor() / 100.0), + ); + } + for (field, prop) in [ + ("maxWidth", "maxW"), + ("maxHeight", "maxH"), + ("minWidth", "minW"), + ("minHeight", "minH"), + ] { + if let Some(value) = view.number(field) { + string_prop(props, prop, px(value)); + } + } + if view.string("parentId").is_some() + && let Some(parent) = parent + // A component set's grid is how Figma lays its variants out on the + // canvas, not how the component is built. A variant is drawn on its + // own wherever it is used, so carrying the cell it sat in would place + // every button at the coordinates of its row in the sheet. + && parent.typed_view().node_type() != "COMPONENT_SET" + && parent + .typed_view() + .value("inferredAutoLayout") + .and_then(Value::as_object) + .and_then(|layout| layout.get("layoutMode")) + .and_then(Value::as_str) + == Some("GRID") + { + let column = view.number("gridColumnAnchorIndex").unwrap_or(-1.0); + let row = view.number("gridRowAnchorIndex").unwrap_or(-1.0); + // How many tracks the child covers. The plugin writes `span 1` for + // every child, and a picture drawn across two columns came out in one, + // its neighbour pushed a row down and every `1fr` row stretched to + // the tallest picture: a 1086px grid rendered 2191px tall. The spans + // are Figma's own, absent from the snapshot when they are 1. + let column_span = view.number("gridColumnSpan").unwrap_or(1.0).max(1.0); + let row_span = view.number("gridRowSpan").unwrap_or(1.0).max(1.0); + let column_count = parent.typed_view().number("gridColumnCount").unwrap_or(0.0); + let current = column + row * column_count; + let natural = parent + .typed_view() + .child_ids() + .position(|child| child == node.id) + .map(|index| index as f64); + // A child at its natural cell needs no placement - unless it spans, + // which flow alone would not give it. + if column >= 0.0 + && row >= 0.0 + && (natural != Some(current) || column_span > 1.0 || row_span > 1.0) + { + string_prop( + props, + "gridColumn", + format!( + "{} / span {}", + format_number(column + 1.0), + format_number(column_span) + ), + ); + string_prop( + props, + "gridRow", + format!( + "{} / span {}", + format_number(row + 1.0), + format_number(row_span) + ), + ); + } + } + if fill_w + && parent + .is_some_and(|parent| parent.typed_view().string("layoutMode") == Some("HORIZONTAL")) + { + string_prop(props, "flex", "1"); + // `flex: 1` shares out the room left over, and Figma gives a filling + // child exactly that - here 123px - and lets anything wider inside it + // spill out. CSS will not shrink a flex item under its content + // (`min-width: auto`), so a 132px row inside a 123px share widened + // the share to 132 and took the 9px from the hugging sibling, whose + // `Devup-ui` then broke across two lines. `minW="0"` lets the share + // be the share, and the content spill as Figma draws it. Written + // only where the snapshot shows content wider than the box. A row + // can overflow collectively even when each child fits individually. + // Wrapping rows can distribute that content over multiple lines. + let child_overflows = view.number("width").is_some_and(|own| { + view.child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .any(|child| { + let child = child.typed_view(); + child.bool("visible") != Some(false) + && child.string("layoutPositioning") != Some("ABSOLUTE") + && child.number("width").is_some_and(|width| width > own + 0.5) + }) + }); + let row_overflows = view.string("layoutMode") == Some("HORIZONTAL") + && view.string("layoutWrap") != Some("WRAP") + && line_overflows(snapshot, node, "width", "paddingLeft", "paddingRight"); + if view.number("minWidth").is_none() && (child_overflows || row_overflows) { + string_prop(props, "minW", "0"); + } + } + // Main-axis FILL consumes remaining space, whether its own children are + // positioned or in flow. This keeps the completion screen's body ahead of + // the following bottom action without copying any canvas coordinates. + if vertical_fill { + string_prop(props, "flex", "1"); + if vertical_fill_needs_minimum_reset(snapshot, node) { + string_prop(props, "minH", "0"); + } + } + // A child Figma never shrinks, in a line that does not fit. Figma keeps a + // fixed size and lets the row spill past its parent, which clips it; CSS + // shrinks flex children to fit instead. The devup-ui landing page's + // comparison row is seven 240px cards in a 912px frame - 1,800px of + // content - and every one of them was squeezed to about 120px, their + // labels wrapped to two lines, and the row came out 58px taller than the + // design, carrying everything below it down with it. + // + // Only where the line actually overflows. Children that fit are not + // shrunk by CSS either, and saying so for every fixed child in the file + // would be noise. + if !absolute + && let Some(parent) = parent + && let Some(axis) = parent.typed_view().string("layoutMode") + && matches!(axis, "VERTICAL" | "HORIZONTAL") + { + let (sizing, size, near, far) = if axis == "HORIZONTAL" { + ( + "layoutSizingHorizontal", + "width", + "paddingLeft", + "paddingRight", + ) + } else { + ( + "layoutSizingVertical", + "height", + "paddingTop", + "paddingBottom", + ) + }; + if view.string(sizing) == Some("FIXED") && line_overflows(snapshot, parent, size, near, far) + { + string_prop(props, "flexShrink", "0"); + } + } + // A child that hugs across its parent's axis, in a parent that packs its + // children to the start, drawn narrower than the room it has, and drawn + // differently for being stretched into it. + // + // Figma's default counter-axis alignment is MIN, which it writes by + // leaving the field out - and leaving it out of the code too means CSS + // applies its own default, `align-items: stretch`, which is the + // opposite. The devup-ui landing page's `Get started` button, 247px wide + // in the 1360px column that holds it, was drawn 1360px wide. + // + // Most hugging children do not care. A line of left-aligned text in a + // box that paints nothing is the same picture at any width, and writing + // an alignment for every one of them - the notice desktop alone holds + // twenty - says nothing while burying the few that matter. So it is + // written where the wider box would show: where the node paints across + // it, or places its own content by it. + if !absolute + && let Some(parent) = parent + && let Some(axis) = parent.typed_view().string("layoutMode") + && matches!(axis, "VERTICAL" | "HORIZONTAL") + && matches!( + parent.typed_view().string("counterAxisAlignItems"), + None | Some("MIN") + ) + { + let across_is_horizontal = axis == "VERTICAL"; + let (sizing, size, near, far, wrote) = if across_is_horizontal { + ( + "layoutSizingHorizontal", + "width", + "paddingLeft", + "paddingRight", + wrote_width, + ) + } else { + ( + "layoutSizingVertical", + "height", + "paddingTop", + "paddingBottom", + wrote_height, + ) + }; + if view.string(sizing) == Some("HUG") + && !wrote + && let (Some(own), Some(room)) = + (view.number(size), inner_extent(parent, size, near, far)) + && own + 0.5 < room + && a_wider_box_would_show(&view, across_is_horizontal) + { + string_prop(props, "alignSelf", "flex-start"); + } + } + + push_auto_layout(snapshot, node, component, props); + push_padding(snapshot, node, props); + match view.string("overflowDirection") { + Some("VERTICAL_SCROLLING") => { + string_prop(props, "overflowY", "auto"); + if view.bool("clipsContent") == Some(true) { + string_prop(props, "overflowX", "hidden"); + } + } + Some("HORIZONTAL_SCROLLING") => { + string_prop(props, "overflowX", "auto"); + if view.bool("clipsContent") == Some(true) { + string_prop(props, "overflowY", "hidden"); + } + } + Some("HORIZONTAL_AND_VERTICAL_SCROLLING") => string_prop(props, "overflow", "auto"), + _ if view.bool("clipsContent") == Some(true) => string_prop(props, "overflow", "hidden"), + _ => {} + } + // An absolutely positioned child needs a positioned ancestor to resolve + // against — but a node folded into a single asset has no children left in + // the output, so there is nothing to anchor and the containing block would + // exist for no one. + // + // A node that is itself positioned is already that ancestor, and saying + // `relative` over its `absolute` would put it back in flow: the join-us + // group of circles, pinned at -277,-187, took 1,102px of the page. + if containing_block && (!absolute || embedded_root) { + string_prop(props, "pos", "relative"); + } + // Position (relative or absolute) establishes a containing block, but + // needs an explicit z-index to contain negative-z children above its own + // background. Keep an existing z-index that places this entire subtree. + if containing_block + && !props.iter().any(|(name, _)| name == "zIndex") + && view + .child_ids() + .any(|child| sits_behind_in_flow_siblings(snapshot, node, child)) + { + string_prop(props, "zIndex", "0"); + } + // An export is drawn with its rotation in it, so an asset whose bounds + // the snapshot carries is not rotated again; without the bounds it is + // placed as the plugin places it, rotation and all. + let exported = !original_source + && super::style::asset_kind(snapshot, node).is_some() + && view.value("absoluteRenderBounds").is_some(); + if let Some(rotation) = view.number("rotation") + && rotation.abs() > 0.01 + && !exported + { + string_prop( + props, + "transform", + format!("rotate({}deg)", format_number(-rotation)), + ); + if absolute { + string_prop(props, "transformOrigin", "top left"); + } + } + if let Some(grid) = reading_grid(snapshot, node) { + reading_grid_props(&grid, props); + } + reading_card_props(snapshot, node, props); + fixed_row_props(snapshot, node, props); + if let Some(width) = navigation_width(snapshot, node) { + string_prop(props, "data-navigation-width", format_number(width)); + } +} + +pub(crate) fn vertical_fill_container(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + view.string("layoutMode") == Some("VERTICAL") + && view.string("layoutSizingVertical") == Some("FIXED") + && view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .any(|child| { + let child = child.typed_view(); + child.bool("visible") != Some(false) + && child.string("layoutPositioning") != Some("ABSOLUTE") + && child.string("layoutSizingVertical") == Some("FILL") + }) +} + +/// CSS's automatic flex minimum must not enlarge a captured FILL allocation. +/// Preserve explicit source minimums; only reset the implicit content minimum +/// when collected in-flow content demonstrably overflows the allocated height. +pub(crate) fn vertical_fill_needs_minimum_reset(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + if view.number("minHeight").is_some() { + return false; + } + (view.string("layoutMode") == Some("VERTICAL") + && line_overflows(snapshot, node, "height", "paddingTop", "paddingBottom")) + || inner_extent(node, "height", "paddingTop", "paddingBottom").is_some_and(|room| { + view.child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .any(|child| { + let child = child.typed_view(); + child.bool("visible") != Some(false) + && child.string("layoutPositioning") != Some("ABSOLUTE") + && child + .number("height") + .is_some_and(|height| height > room + 0.5) + }) + }) +} + +/// Only children that can survive projection need a containing block. +pub(super) fn holds_positioned_children(snapshot: &Snapshot, node: &RawNode) -> bool { + super::style::asset_kind(snapshot, node).is_none() + && node.typed_view().child_ids().any(|id| { + snapshot.nodes.get(id).is_some_and(|child| { + let view = child.typed_view(); + view.bool("visible") != Some(false) + && (view.string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout(snapshot, child, Some(node), false)) + }) + }) +} + +pub(super) fn absolute_layout_is_exact(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + if view.string("layoutPositioning") != Some("ABSOLUTE") { + return true; + } + let parent = snapshot.nodes.values().find(|candidate| { + candidate + .typed_view() + .child_ids() + .any(|child_id| child_id == node.id) + }); + let has_geometry = ["x", "y", "width", "height"] + .into_iter() + .all(|field| view.number(field).is_some_and(f64::is_finite)); + let constraints = view.value("constraints").and_then(Value::as_object); + let supported_constraint = |axis: &str| { + constraints + .and_then(|value| value.get(axis)) + .and_then(Value::as_str) + .is_none_or(|value| matches!(value, "MIN" | "MAX")) + }; + let no_rotation = view.number("rotation").is_none_or(|value| value == 0.0); + let exact_size = parent.is_some_and(|parent| { + // A node pinned on both axes now emits those exact dimensions even + // when it has children, because the absolute branch of + // `push_layout_props` restates them rather than letting the children + // define the box. Keep this in step with that branch: judging such a + // node approximated would report a loss the output no longer has. + if view.string("layoutSizingHorizontal") == Some("FIXED") + && view.string("layoutSizingVertical") == Some("FIXED") + && view.child_ids().next().is_some() + { + return true; + } + if view.node_type() != "FRAME" { + return false; + } + if view.child_ids().next().is_none() { + return true; + } + let inferred_auto_layout = view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .is_some(); + let parent = parent.typed_view(); + let horizontal = view.string("layoutSizingHorizontal") == Some("HUG") + && inferred_auto_layout + || matches!( + (view.number("width"), parent.number("width")), + (Some(width), Some(parent_width)) if width == parent_width + ); + let vertical = view.string("layoutSizingVertical") == Some("HUG") && inferred_auto_layout; + horizontal && vertical + }); + parent.is_some() + && has_geometry + && supported_constraint("horizontal") + && supported_constraint("vertical") + && no_rotation + && exact_size +} + +fn child_shrinker(parent: &RawNode, dimension: &str) -> bool { + let inferred = parent + .typed_view() + .value("inferredAutoLayout") + .and_then(Value::as_object); + match dimension { + "width" => inferred.is_some_and(|layout| { + layout.get("layoutMode").and_then(Value::as_str) == Some("VERTICAL") + && layout.get("counterAxisAlignItems").and_then(Value::as_str) == Some("CENTER") + }), + "height" => inferred.is_some_and(|layout| { + layout.get("layoutMode").and_then(Value::as_str) == Some("HORIZONTAL") + && layout.get("counterAxisAlignItems").and_then(Value::as_str) == Some("CENTER") + }), + _ => false, + } +} + +pub(super) struct GridTemplate { + pub css: Option, + pub unverified: Vec<&'static str>, +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout/solid_ellipse.rs b/crates/devup-mcp-devup-ui/src/codegen/layout/solid_ellipse.rs new file mode 100644 index 00000000..2cc6b663 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/layout/solid_ellipse.rs @@ -0,0 +1,28 @@ +/// A native solid ellipse has no intrinsic CSS content. Percentage height and +/// an omitted width therefore destroy its captured fixed axes under an auto- +/// layout frame. Preserve only this explicit, read-safe absolute shape case; +/// exported assets and unproven/fluid geometry retain their existing policy. +fn fixed_native_ellipse_size(view: &TypedNode<'_>, component: &str, is_asset: bool) -> Option<(f64, f64)> { + if is_asset || component != "Box" || view.node_type() != "ELLIPSE" + || view.string("layoutPositioning") != Some("ABSOLUTE") + || view.string("layoutSizingHorizontal") != Some("FIXED") + || view.string("layoutSizingVertical") != Some("FIXED") + || view.child_ids().next().is_some() + || ["width", "height", "layoutPositioning", "layoutSizingHorizontal", "layoutSizingVertical", "arcData", "fills"] + .iter().any(|field| view.field_error(field).is_some()) + { return None; } + let arc = view.value("arcData")?; + if arc.get("innerRadius")?.as_f64()? != 0.0 + || arc.get("startingAngle")?.as_f64()?.abs() > 0.00001 + || (arc.get("endingAngle")?.as_f64()? - std::f64::consts::TAU).abs() > 0.00001 + { return None; } + let paints = view.value("fills")?.as_array()?; + if !paints.iter().any(|paint| paint.get("type").and_then(Value::as_str) == Some("SOLID") + && paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) > 0.0 + && ["r", "g", "b"].iter().all(|channel| paint.get("color").and_then(|color| color.get(channel)) + .and_then(Value::as_f64).is_some_and(f64::is_finite))) + { return None; } + let (width, height) = (view.number("width")?, view.number("height")?); + (width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0).then_some((width, height)) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/mod.rs b/crates/devup-mcp-devup-ui/src/codegen/mod.rs index b307d216..6cd74b41 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/mod.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/mod.rs @@ -5,8 +5,12 @@ mod evidence; mod image; pub mod instance_overrides; mod layout; +pub(crate) use layout::absolute_border_origin_calculation; +mod overlay_anchors; +pub use overlay_anchors::overlay_anchor_metadata; pub mod node_ids; mod property_derivations; +mod semantics; pub(crate) use property_derivations::property_derivations; pub mod responsive; mod style; @@ -25,6 +29,7 @@ pub use component::{ generate_node, normalize_component_name, render_component_registration_snapshot, render_component_source, }; +pub(crate) use image::horizontal_transferred_image_minimum; pub use image::{ AssetPixelSize, AssetRepresentationPolicy, AssetVariant, asset_css_map, validate_asset_effects, }; @@ -36,5 +41,7 @@ pub(crate) use layout::{ pub(crate) use style::single_outside_stroke; pub(crate) use style::{AssetKind, asset_kind}; pub use style::{asset_path, image_fill_path}; -pub(crate) use text::escape_jsx_text; -pub(crate) use text::preserves_hard_break_spaces; +pub(crate) use text::{escape_jsx_text, render_segment_characters}; +pub(crate) use text::{ + preserves_hard_break_spaces, preserves_intrinsic_text_width, preserves_wrapped_ascii_spaces, +}; diff --git a/crates/devup-mcp-devup-ui/src/codegen/overlay_anchors.rs b/crates/devup-mcp-devup-ui/src/codegen/overlay_anchors.rs new file mode 100644 index 00000000..a670d544 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/overlay_anchors.rs @@ -0,0 +1,494 @@ +//! Captured dropdown relationships, without adding wrappers or inferring layout. +use std::collections::BTreeSet; + +use devup_mcp_figma::{Diagnostic, DiagnosticSeverity, FidelityImpact, RawNode, Snapshot}; +use serde_json::{Value, json}; + +use super::{ + component::{Prop, PropValue}, + layout::string_prop, +}; + +#[derive(Clone, Copy)] +struct Bounds { + x: f64, + y: f64, + width: f64, + height: f64, +} + +impl Bounds { + fn read(node: &RawNode) -> Option { + let value = node.typed_view().value("absoluteBoundingBox")?; + let b = Self { + x: value["x"].as_f64()?, + y: value["y"].as_f64()?, + width: value["width"].as_f64()?, + height: value["height"].as_f64()?, + }; + [b.x, b.y, b.width, b.height] + .iter() + .all(|v| v.is_finite()) + .then_some(b) + .filter(|b| b.width > 0.0 && b.height > 0.0) + } + fn value(self) -> Value { + json!({"x":self.x,"y":self.y,"width":self.width,"height":self.height}) + } +} + +struct Relation { + root: String, + control: String, + overlay: String, + parent: String, + name: String, + control_box: Bounds, + overlay_box: Bounds, + parent_box: Bounds, + gap: f64, +} + +fn ancestors<'a>(snapshot: &'a Snapshot, node: &'a RawNode) -> Vec<&'a RawNode> { + let mut nodes = vec![node]; + let mut seen = BTreeSet::from([node.id.as_str()]); + while let Some(parent) = nodes + .last() + .and_then(|n| n.typed_view().string("parentId")) + .and_then(|id| snapshot.nodes.get(id)) + { + if !seen.insert(parent.id.as_str()) { + break; + } + nodes.push(parent); + } + nodes +} + +fn visible(snapshot: &Snapshot, node: &RawNode) -> bool { + ancestors(snapshot, node).iter().all(|node| { + let view = node.typed_view(); + let override_value = view + .value("componentPropertyReferences") + .and_then(|v| v.get("visible")) + .and_then(Value::as_str) + .and_then(|key| { + ancestors(snapshot, node) + .into_iter() + .skip(1) + .find_map(|parent| { + parent + .typed_view() + .value("componentProperties")? + .get(key)? + .get("value")? + .as_bool() + }) + }); + override_value.unwrap_or(view.bool("visible") != Some(false)) + }) +} + +fn named(node: &RawNode, name: &str) -> bool { + [ + node.typed_view().name(), + node.typed_view().string("mainComponentName"), + ] + .into_iter() + .flatten() + .any(|value| value.eq_ignore_ascii_case(name)) +} + +fn dropdown_overlay(snapshot: &Snapshot, node: &RawNode) -> bool { + if node.typed_view().string("layoutPositioning") != Some("ABSOLUTE") || !visible(snapshot, node) + { + return false; + } + let children = node + .typed_view() + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| visible(snapshot, child)) + .collect::>(); + !children.is_empty() + && children.iter().all(|child| { + child.node_type == "INSTANCE" + && named(child, "DropdownOption") + && child.typed_view().string("mainComponentId").is_some() + }) +} + +fn caret(snapshot: &Snapshot, node: &RawNode, seen: &mut BTreeSet) -> bool { + if !seen.insert(node.id.clone()) || !visible(snapshot, node) { + return false; + } + let view = node.typed_view(); + let captured_down = named(node, "CaretDown") + || view.string("mainComponentName").is_some_and(|name| { + name.split('=') + .next_back() + .is_some_and(|v| v.eq_ignore_ascii_case("CaretDown")) + }) + || view + .value("variantProperties") + .and_then(Value::as_object) + .is_some_and(|values| { + values.values().any(|v| { + v.as_str() + .is_some_and(|v| v.eq_ignore_ascii_case("CaretDown")) + }) + }); + (node.node_type == "INSTANCE" && view.string("mainComponentId").is_some() && captured_down) + || view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .any(|child| caret(snapshot, child, seen)) +} + +fn dropdown_control(snapshot: &Snapshot, node: &RawNode) -> bool { + node.node_type == "INSTANCE" + && node.typed_view().string("mainComponentId").is_some() + && ["input", "select", "dropdown", "combobox"] + .iter() + .any(|name| named(node, name)) + && visible(snapshot, node) + && caret(snapshot, node, &mut BTreeSet::new()) +} + +fn transformed(snapshot: &Snapshot, node: &RawNode) -> bool { + ancestors(snapshot, node).iter().any(|node| { + let view = node.typed_view(); + view.number("rotation").is_some_and(|v| v.abs() > 0.001) + || view + .value("relativeTransform") + .and_then(Value::as_array) + .is_some_and(|rows| { + let coefficient = |row: usize, col: usize| rows.get(row)?.get(col)?.as_f64(); + [(0, 0, 1.0), (0, 1, 0.0), (1, 0, 0.0), (1, 1, 1.0)] + .iter() + .any(|(row, col, expected)| { + coefficient(*row, *col).is_none_or(|v| (v - expected).abs() > 0.001) + }) + }) + }) +} + +fn selected_root(snapshot: &Snapshot, node: &RawNode) -> Option { + ancestors(snapshot, node) + .iter() + .find(|node| snapshot.roots.contains(&node.id)) + .map(|node| node.id.clone()) +} + +fn issue(overlay: &RawNode, reason: &str, candidates: Vec) -> Diagnostic { + Diagnostic { code:"DEVUP_CODEGEN_OVERLAY_ANCHOR_UNRESOLVED".into(), node_id:Some(overlay.id.clone()), + severity:Some(DiagnosticSeverity::Warning),fidelity_impact:Some(FidelityImpact::Lossy), + message:"A captured dropdown overlay has no unique, untransformed control relationship; its original absolute placement is retained.".into(), + details:Some(json!({"classification":reason,"candidateControlNodeIds":candidates,"runtimeVerified":false, + "nextAction":"Capture an unambiguous dropdown control and options overlay in the same frame; do not infer an anchor from unrelated fields."})),..Default::default() } +} + +fn discover(snapshot: &Snapshot) -> (Vec, Vec) { + let controls = snapshot + .nodes + .values() + .filter(|node| dropdown_control(snapshot, node)) + .collect::>(); + let mut relations = Vec::new(); + let mut diagnostics = Vec::new(); + for overlay in snapshot + .nodes + .values() + .filter(|node| dropdown_overlay(snapshot, node)) + { + let Some((overlay_box, parent, parent_box, root)) = Bounds::read(overlay).and_then(|b| { + let p = snapshot + .nodes + .get(overlay.typed_view().string("parentId")?)?; + Some((b, p, Bounds::read(p)?, selected_root(snapshot, overlay)?)) + }) else { + diagnostics.push(issue(overlay, "missing-captured-geometry", vec![])); + continue; + }; + if ["horizontal", "vertical"].iter().any(|axis| { + overlay + .typed_view() + .value("constraints") + .and_then(|v| v.get(axis)) + .and_then(Value::as_str) + .is_some_and(|v| v != "MIN") + }) { + diagnostics.push(issue(overlay, "non-minimum-overlay-constraints", vec![])); + continue; + } + let matches = controls + .iter() + .filter_map(|control| { + if selected_root(snapshot, control).as_deref() != Some(root.as_str()) + || !ancestors(snapshot, control) + .iter() + .any(|n| n.id == parent.id) + { + return None; + } + let b = Bounds::read(control)?; + let gap = overlay_box.y - b.y - b.height; + // The tolerance only pairs recorded edges; emitted values always + // come from the complete captured boxes, never rounded guesses. + ((b.x - overlay_box.x).abs() <= 0.01 + && (b.width - overlay_box.width).abs() <= 0.01 + && gap >= 0.0 + && gap <= b.height) + .then_some((*control, b, gap)) + }) + .collect::>(); + let Some((control, control_box, gap)) = matches.first().copied() else { + diagnostics.push(issue(overlay, "no-captured-dropdown-control", vec![])); + continue; + }; + if matches.len() != 1 { + diagnostics.push(issue( + overlay, + "ambiguous-captured-dropdown-control", + matches.iter().map(|m| m.0.id.clone()).collect(), + )); + continue; + } + if transformed(snapshot, overlay) + || transformed(snapshot, control) + || ancestors(snapshot, control) + .iter() + .skip(1) + .any(|n| dropdown_control(snapshot, n)) + { + diagnostics.push(issue( + overlay, + "transformed-or-nested-dropdown-control", + vec![control.id.clone()], + )); + continue; + } + let identity = control + .id + .bytes() + .map(|b| format!("{b:02x}")) + .collect::(); + relations.push(Relation { + root, + control: control.id.clone(), + overlay: overlay.id.clone(), + parent: parent.id.clone(), + name: format!("--devup-dropdown-{identity}"), + control_box, + overlay_box, + parent_box, + gap, + }); + } + let duplicated = relations + .iter() + .filter(|r| { + relations + .iter() + .filter(|other| other.control == r.control) + .count() + > 1 + }) + .map(|r| r.control.clone()) + .collect::>(); + relations.retain(|r| { + if duplicated.contains(&r.control) { + diagnostics.push(issue( + &snapshot.nodes[&r.overlay], + "multiple-overlays-for-control", + vec![r.control.clone()], + )); + false + } else { + true + } + }); + (relations, diagnostics) +} + +impl Relation { + // devup-ui treats a raw leading `--` as a theme variable reference. + // CSS escapes retain the same dashed identifier without that conversion. + fn css_identifier(&self) -> String { + format!("\\2d \\2d {}", self.name.trim_start_matches("--")) + } + fn props(&self, node: &RawNode) -> Vec { + let mut props = Vec::new(); + if node.id == self.control { + string_prop(&mut props, "anchorName", self.css_identifier()); + } + if node.id == self.parent { + string_prop(&mut props, "anchorScope", "all"); + } + if node.id == self.overlay { + let top = self.overlay_box.y - self.parent_box.y; + string_prop(&mut props, "positionAnchor", self.css_identifier()); + string_prop( + &mut props, + "top", + format!( + "calc(anchor(bottom, {}) + {})", + super::layout::px(top - self.gap), + super::layout::px(self.gap) + ), + ); + string_prop( + &mut props, + "left", + format!( + "anchor(left, {})", + super::layout::px(self.overlay_box.x - self.parent_box.x) + ), + ); + string_prop( + &mut props, + "w", + format!( + "anchor-size(width, {})", + super::layout::px(self.overlay_box.width) + ), + ); + } + props + } + fn metadata(&self) -> Value { + json!({"schemaVersion":1,"kind":"captured-dropdown-control","rootNodeId":self.root,"controlNodeId":self.control, + "overlayNodeId":self.overlay,"scopeNodeId":self.parent,"anchorName":self.name,"cssIdentifier":self.css_identifier(),"gap":self.gap, + "controlBounds":self.control_box.value(),"overlayBounds":self.overlay_box.value(),"scopeBounds":self.parent_box.value(), + "capturedFallback":{"top":self.overlay_box.y-self.parent_box.y,"left":self.overlay_box.x-self.parent_box.x,"width":self.overlay_box.width}, + "runtimeVerified":false,"requiresCSSAnchorPositioning":true,"unsupportedBrowsersVerified":false, + "relationEvidence":"Visible captured dropdown input with mapped CaretDown and mapped DropdownOption children; same captured frame, matching left/width, unique nearby lower edge.", + "verification":"Source relationship only; verify generated CSS, the native control mapping, and runtime error/open combinations in the consumer browser."}) + } +} + +/// Called at the end of layout projection, replacing only captured placement +/// values. No DOM wrapper, control child, paint or flow property is changed. +pub(super) fn apply_props(snapshot: &Snapshot, node: &RawNode, props: &mut Vec) { + if !dropdown_control(snapshot, node) + && !dropdown_overlay(snapshot, node) + && !node + .typed_view() + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .any(|n| dropdown_overlay(snapshot, n)) + { + return; + } + for relation in discover(snapshot).0 { + for (name, value) in relation.props(node) { + props.retain(|(existing, _)| existing != &name); + props.push((name, value)); + } + } +} + +pub(super) fn derivation( + snapshot: &Snapshot, + node: &RawNode, + name: &str, + value: &str, +) -> Option { + if ![ + "anchorName", + "anchorScope", + "positionAnchor", + "top", + "left", + "w", + ] + .contains(&name) + || (matches!(name, "top" | "left" | "w") + && !value.contains("anchor(") + && !value.contains("anchor-size(")) + { + return None; + } + discover(snapshot).0.into_iter().find_map(|relation| { + relation.props(node).iter().any(|(p,PropValue::String(v))|p==name && v==value).then(||json!({ + "stage":"captured-overlay-anchor","generatedProperty":super::component::render_static_attribute(name,value), + "sourceFields":["absoluteBoundingBox","parentId","childrenIds","name","mainComponentId","mainComponentName","variantProperties","componentProperties","componentPropertyReferences","visible","rotation","relativeTransform"], + "originalValue":relation.metadata(),"calculation":"Project only the unique captured dropdown control/overlay relationship with CSS anchors and captured numeric fallbacks. Dynamic browser placement is not measured by this source derivation.","runtimeVerified":false})) + }) +} + +fn attribute_identifier(source: &str, expected: &str) -> bool { + let Some((_, value)) = source.split_once('=') else { + return false; + }; + let value = value.trim(); + if let Some(literal) = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + return literal == expected; + } + let Some(expression) = value + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}')) + else { + return false; + }; + let Ok(value) = serde_json::from_str::(expression) else { + return false; + }; + match value { + Value::String(value) => value == expected, + Value::Array(values) => values.iter().any(|value| value.as_str() == Some(expected)), + _ => false, + } +} + +/// Safe source relationships actually represented by native anchor props in +/// this delivered TSX. Callers may expose the same JSON beside the annotation. +pub fn overlay_anchor_metadata(snapshot: &Snapshot, roots: &[String], tsx: &str) -> Vec { + let attributes = crate::provenance::attributes::generated_attributes(tsx); + discover(snapshot) + .0 + .into_iter() + .filter(|r| roots.contains(&r.root)) + .filter(|r| { + ["anchorName", "positionAnchor"].iter().all(|name| { + attributes.iter().any(|a| { + a.name == *name && attribute_identifier(&a.source, &r.css_identifier()) + }) + }) + }) + .map(|r| { + let mut value = r.metadata(); + value["projected"] = json!(true); + value + }) + .collect() +} + +pub(super) fn diagnostics(snapshot: &Snapshot, root: &str, tsx: &str) -> Vec { + let (relations, mut issues) = discover(snapshot); + issues.retain(|d| { + d.node_id + .as_ref() + .and_then(|id| snapshot.nodes.get(id)) + .and_then(|node| selected_root(snapshot, node)) + .as_deref() + == Some(root) + }); + let metadata = overlay_anchor_metadata(snapshot, &[root.to_owned()], tsx); + for relation in relations.into_iter().filter(|r| r.root == root) { + if !metadata + .iter() + .any(|record| record["overlayNodeId"] == relation.overlay) + { + issues.push(issue( + &snapshot.nodes[&relation.overlay], + "native-control-not-expanded", + vec![relation.control], + )); + } + } + issues +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs b/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs index bb57f876..3e0fdf7a 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs @@ -21,7 +21,7 @@ pub(crate) fn property_derivations( let mut origins = BTreeMap::new(); let mut tokens = BTreeSet::new(); let mut style_derivations = style::StyleDerivations::default(); - for stage in ["layout", "style", "text", "animation"] { + for stage in ["layout", "style", "text", "semantic", "animation"] { let before = props.clone(); match stage { "layout" => layout::push_layout_props( @@ -61,6 +61,9 @@ pub(crate) fn property_derivations( &mut tokens, &mut props, ), + "semantic" => { + super::semantics::push_semantic_props(snapshot, node, component, &mut props) + } "animation" => animation::push_animation_props( snapshot, node, @@ -75,6 +78,9 @@ pub(crate) fn property_derivations( } } } + if options.inline_instances { + super::overlay_anchors::apply_props(snapshot, node, &mut props); + } // Written as render_node writes them, after the stages: a length bound to a // variable becomes its token in place, and the prop keeps the stage that // made it. @@ -92,9 +98,22 @@ pub(crate) fn property_derivations( ); let in_reading_grid = layout::in_reading_grid(snapshot, node); let in_wrapping_row = layout::in_wrapping_row(snapshot, node); - props.into_iter().filter_map(|(name, PropValue::String(value))| { + let display_gate = super::variant::display_gate_derivation(snapshot, node, component, &props); + let mut result = props.into_iter().filter_map(|(name, PropValue::String(value))| { + if let Some(derived) = super::overlay_anchors::derivation(snapshot, node, &name, &value) { + return Some(derived); + } let stage = origins.get(&name)?; let (fields, calculation): (Vec<&str>, &str) = match name.as_str() { + "p" | "px" | "py" | "pt" | "pr" | "pb" | "pl" if style::mixed_excluded_stroke_source_padding(&node.typed_view()).is_some() => + (vec!["inferredAutoLayout", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "strokesIncludedInLayout", "strokeAlign", "strokes", "strokeWeight", "strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight"], + "Captured inside per-edge SOLID strokes are explicitly excluded from layout. Each known edge is absorbed by the corresponding sufficient captured padding, so CSS border plus remaining padding preserves the original content inset and HUG extent without clamping or fixed heights."), + "alignContent" if node.typed_view().string("layoutWrap") == Some("WRAP") => + (vec!["layoutWrap", "counterAxisAlignContent"], "Captured wrapped tracks with counterAxisAlignContent SPACE_BETWEEN map directly to CSS alignContent=space-between."), + "flexWrap" if node.typed_view().string("layoutWrap") == Some("WRAP") => + (vec!["layoutWrap", "inferredAutoLayout"], "Captured WRAP auto-layout uses CSS flex wrapping; no grid tracks or extra layout wrapper are inferred."), + "rowGap" | "columnGap" if node.typed_view().string("layoutWrap") == Some("WRAP") => + (vec!["layoutWrap", "layoutMode", "inferredAutoLayout", "itemSpacing", "counterAxisSpacing"], "Captured counterAxisSpacing sets the cross-axis gap of the wrapped flex layout, independently of main-axis itemSpacing; row/column properties follow captured layoutMode."), "data-navigation-width" => (vec!["name", "layoutMode", "childrenIds", "itemSpacing", "width"], "navigation_width: measured visible child widths plus row gaps expose a header/nav/GNB content-fit requirement to export and validation."), "w" | "h" | "maxW" | "minH" | "flexWrap" | "flexShrink" if in_wrapping_row => @@ -106,7 +125,7 @@ pub(crate) fn property_derivations( "reading_grid: contiguous numbered cards from transparent vertical column wrappers sorted into DOM sequence; measured bounds define grid tracks and placement at wide widths, mobile-first arrays reset to one column and auto rows. Descendants are bounded by their card width."), "flexShrink" => (vec!["layoutSizingHorizontal", "layoutSizingVertical", "layoutGrow", "parentId", "width", "height"], "push_layout_props: preserve the fixed primary-axis size in the parent flex layout by disabling shrink."), - "wordBreak" => (vec!["styledTextSegments"], "push_text_props: Korean segment text uses keep-all word breaking."), + "wordBreak" | "overflowWrap" => (vec!["characters", "styledTextSegments", "width"], "Korean captured text uses keep-all word boundaries and break-word emergency wrapping for a token wider than its container; explicit local/theme policies are preserved."), "boxShadow" if style::asset_kind(snapshot, node).is_none() && style::single_outside_stroke(&node.typed_view()).is_some() => (vec!["strokes", "strokeAlign", "strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight", "effects"], include_str!("../text/property_derivations-property_derivations-f4103b4e.md")), @@ -130,9 +149,12 @@ pub(crate) fn property_derivations( "visibility" | "display" => (vec!["visible", "opacity", "absoluteRenderBounds", "fills", "textTruncation", "maxLines"], "Visibility/non-rendering or text truncation policy in the named stage; existing visibility classification is retained."), "WebkitTextStroke" | "paintOrder" => (vec!["strokes", "strokeWeight", "styledTextSegments"], "push_text_props composes text stroke width/color and stroke-before-fill paint order."), "WebkitBoxOrient" | "WebkitLineClamp" | "textOverflow" => (vec!["textTruncation", "maxLines", "styledTextSegments"], "push_text_props projects truncation and maximum line count into the emitted clamp properties."), + "whiteSpace" if text::preserves_intrinsic_text_width(&node.typed_view()) => (vec!["textAutoResize", "characters", "styledTextSegments", "maxLines"], "WIDTH_AND_HEIGHT text grows to its content width without soft wrapping; whiteSpace=pre preserves captured spaces and explicit breaks."), + "whiteSpace" if text::preserves_wrapped_ascii_spaces(&node.typed_view()) => (vec!["characters", "styledTextSegments", "textAutoResize", "width", "maxLines"], "Captured fixed-inline-width HEIGHT text uses pre-wrap to preserve repeated or edge ASCII spaces while retaining soft wrapping."), "whiteSpace" if text::preserves_hard_break_spaces(&node.typed_view()) => (vec!["characters", "styledTextSegments", "textAutoResize", "maxLines"], include_str!("../text/property_derivations-property_derivations-2ec1244f.md")), "textDecoration" => (vec!["textDecoration", "styledTextSegments"], "Text UNDERLINE/STRIKETHROUGH maps to underline/line-through."), "textTransform" => (vec!["textCase", "styledTextSegments"], "Text case projection follows push_text_props."), + "as" | "alt" if *stage == "semantic" => (vec!["name", "parentId"], "Explicit semantic layer/component names choose a non-visual HTML tag; descriptive asset names supply alt text and generic shape/icon names use empty alt. Variant roots use the named parent component set."), "as" | "my" => (vec!["styledTextSegments", "listOptions", "paragraphSpacing"], "Text list/paragraph projection uses the emitted semantic tag and spacing policy."), "animationName" | "animationDuration" | "animationTimingFunction" | "animationFillMode" | "animationDelay" | "animationIterationCount" => (vec!["reactions", "relativeTransform", "rotation", "childrenIds"], include_str!("../text/property_derivations-property_derivations-f0db063f.md")), @@ -157,6 +179,10 @@ pub(crate) fn property_derivations( } if *stage != "text" && let Some(parent) = parent { context.insert("parentNodeId".into(), json!(parent.id)); + if *stage == "semantic" { + context.insert("parentName".into(), json!(parent.typed_view().name())); + context.insert("parentType".into(), json!(parent.node_type)); + } if let Some(bounds) = parent.typed_view().value("absoluteBoundingBox") { context.insert("parentBounds".into(), bounds.clone()); } @@ -171,7 +197,9 @@ pub(crate) fn property_derivations( } if !context.is_empty() { evidence["context"] = Value::Object(context); } Some(evidence) - }).collect() + }).collect::>(); + result.extend(display_gate); + result } #[cfg(test)] diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs index 7d2dc09f..0cd679b6 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs @@ -24,7 +24,17 @@ // Source sections share this module to preserve privacy and test names. include!("responsive/breakpoint_merge.rs"); +include!("responsive/padding_aliases.rs"); +include!("responsive/size_aliases.rs"); +include!("responsive/gap_aliases.rs"); +include!("responsive/layout_defaults.rs"); +include!("responsive/paint_defaults.rs"); include!("responsive/child_alignment.rs"); +include!("responsive/wrapper_reconciliation.rs"); +include!("responsive/child_order.rs"); include!("responsive/width_label.rs"); +include!("responsive/instance_text_branches.rs"); +include!("responsive/literal_text_branches.rs"); +include!("responsive/length_token_projection.rs"); include!("responsive/merge_content.rs"); include!("responsive/impl.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/breakpoint_merge.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/breakpoint_merge.rs index bf85c234..e791e56e 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/responsive/breakpoint_merge.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/breakpoint_merge.rs @@ -1,4 +1,3 @@ - use std::collections::{BTreeMap, BTreeSet, VecDeque}; use devup_mcp_figma::{DevupError, RawNode, Snapshot}; @@ -7,8 +6,8 @@ use serde_json::{Value, json}; use super::{ component::{CodegenOptions, generate_component}, variant::{ - POSITION_PROPS, Tree, instance_variant_props, project_tree_keeping_instances, - referenced_component_name, + POSITION_PROPS, Tree, instance_variant_props, project_tree, project_tree_keeping_instances, + referenced_component_name, }, }; use crate::provenance::attributes::{ @@ -669,8 +668,15 @@ fn render_attribute(merged: &Merged) -> Option { let written = slots .iter() .map(|slot| { - slot.as_ref() - .map_or_else(|| " null".to_owned(), |value| format!(" \"{value}\"")) + slot.as_ref().map_or_else( + || " null".to_owned(), + |value| { + format!( + " {}", + serde_json::to_string(value).expect("string serialization") + ) + }, + ) }) .collect::>() .join(",\n"); @@ -679,71 +685,6 @@ fn render_attribute(merged: &Merged) -> Option { } } -/// What a layout element does by itself besides its `display`. -fn implied_layout(component: &str) -> &'static [(&'static str, &'static str)] { - match component { - "Flex" => &[("flexDir", "row")], - "VStack" => &[("flexDir", "column")], - "Center" => &[("alignItems", "center"), ("justifyContent", "center")], - _ => &[], - } -} - -/// The `display` a devup-ui element already carries by being itself. -fn implied_display(component: &str) -> Option<&'static str> { - match component { - "Flex" | "VStack" | "Center" => Some("flex"), - "Grid" => Some("grid"), - _ => None, - } -} - -/// What `display` an element goes back to when a wider width stops hiding it. -/// -/// `merge_slots` clears a dropped layout prop to `"initial"`, which is right -/// for the other forty and wrong for this one. `initial` is the value the CSS -/// specification gives a property, not the value the element has: for `w` that -/// is `auto` and for `p` it is `0`, which are what "unset" should mean, but for -/// `display` it is `inline` whatever the element is. An element's own display -/// comes from the user-agent stylesheet instead, so a `Box` shown only from -/// tablet up would come back inline rather than block. -/// -/// `revert` is the keyword that would mean what is wanted, and it cannot be -/// used either: it rolls the cascade back past the author origin, which is -/// where devup-ui puts `VStack`'s own `display: flex`. So the value is written -/// out, and `display` is never cleared to `initial`. -fn natural_display(component: &str) -> &'static str { - match component { - "Flex" | "VStack" | "Center" => "flex", - "Grid" => "grid", - // `Image` is an `img`, which is inline. Everything else devup-ui draws - // is a `div`, and `Text` is a `p`. - "Image" => "inline", - _ => "block", - } -} - -/// Put back the `display` the element name stands for, before any merging. -/// -/// The plugin keeps `display` in a node's props and picks the element from it; -/// this projection picks the element first and drops the value. That is the -/// same thing for one width, but not across several: a width where the node is -/// hidden would leave the others with nothing to go back to, and `merge_slots` -/// would clear them to `"initial"` — which for `display` is `inline`, not -/// `flex`. The node would stop being a flex container at every width it is -/// shown. Rendering drops the value again where the element implies it, so -/// nothing is written that was not written before. -fn restore_implied_display(tree: &mut Tree) { - if let Some(display) = implied_display(&tree.component) { - tree.props - .entry("display".to_owned()) - .or_insert_with(|| display.to_owned()); - } - for child in &mut tree.children { - restore_implied_display(child); - } -} - /// Fold one node's counterparts into a single node whose differing values have /// become arrays. fn merge_trees( @@ -751,6 +692,16 @@ fn merge_trees( precedence: &Precedence, context: &mut MergeContext<'_>, ) -> Option { + let normalized_main = normalize_source_main_alignment(by_slot, context.snapshot); + let by_slot = normalized_main.as_ref().unwrap_or(by_slot); + let normalized_gap = normalize_responsive_gap(by_slot, context.snapshot); + let by_slot = normalized_gap.as_ref().unwrap_or(by_slot); + let normalized_paint = normalize_source_paint_defaults(by_slot, context.snapshot); + let by_slot = normalized_paint.as_ref().unwrap_or(by_slot); + let normalized_padding = normalize_responsive_padding(by_slot); + let by_slot = normalized_padding.as_ref().unwrap_or(by_slot); + let normalized_size = normalize_responsive_size(by_slot); + let by_slot = normalized_size.as_ref().unwrap_or(by_slot); // The merged element is the first width's. Where another width draws it as // a different layout element, what each element does by itself has to be // written out, or the first one's would apply at every width: a `VStack` @@ -765,6 +716,10 @@ fn merge_trees( && components.iter().all(|name| LAYOUT_ELEMENTS.contains(name)) && by_slot.iter().flatten().all(|tree| !tree.is_component) { + let layout_names = components + .iter() + .flat_map(|component| implied_layout(component).iter().map(|(name, _)| *name)) + .collect::>(); restated = std::array::from_fn(|slot| { by_slot[slot].clone().map(|mut tree| { for (name, value) in implied_layout(&tree.component) { @@ -772,6 +727,21 @@ fn merge_trees( .entry((*name).to_owned()) .or_insert_with(|| (*value).to_owned()); } + // A missing class default is significant too: Flex starts and + // stretches, whereas Center centers both axes. `null` would + // retain the chosen JSX primitive's class, not restore the + // source primitive's CSS initial value. Restate only defaults + // that differ among these captured primitives, preserving all + // explicitly projected alignment and direction props. + for name in &layout_names { + if components.iter().any(|component| { + settled(name, None, component) != settled(name, None, &tree.component) + }) { + tree.props + .entry((*name).to_owned()) + .or_insert_with(|| "initial".to_owned()); + } + } tree.props .entry("display".to_owned()) .or_insert_with(|| natural_display(&tree.component).to_owned()); @@ -790,6 +760,36 @@ fn merge_trees( } let mut props = BTreeMap::new(); for key in keys { + if key == "alt" && !first.is_component { + let values = by_slot + .iter() + .flatten() + .filter(|tree| { + tree.props + .get("display") + .is_none_or(|value| value != "none") + }) + .filter_map(|tree| tree.props.get("alt")) + .collect::>(); + let selected = values.first().copied().or_else(|| first.props.get("alt")); + if values.len() > 1 { + context.notes.push(Unrepresented { node_id:first.node_id.clone(), + detail:format!("Visible widths have different alt text: {values:?}. HTML alt is scalar; the captured fallback is used until separate image branches preserve every alternative.") }); + context.diagnostics.push(devup_mcp_figma::Diagnostic { + code:"DEVUP_CODEGEN_RESPONSIVE_ALT_UNREPRESENTED".into(), + message:"Different visible alt text cannot be represented by a CSS responsive array.".into(), + node_id:Some(first.node_id.clone()),property:Some("alt".into()), + severity:Some(devup_mcp_figma::DiagnosticSeverity::Warning), + fidelity_impact:Some(devup_mcp_figma::FidelityImpact::Lossy), + details:Some(json!({"alternatives":values,"classification":"scalar-html-attribute","output":"responsiveTsx", + "nextAction":"Use separate image branches for these widths, preserving each captured alt."})),..Default::default() + }); + } + if let Some(value) = selected { + props.insert(key, super::semantics::scalar_html_value(value)); + } + continue; + } // A component's own props are not style props. devup-ui reads an array // only where it applies CSS, so `property1={[...]}` on `
` is // read as the literal array and the component sees nonsense. The widest @@ -826,6 +826,7 @@ fn merge_trees( .map_or(Drawn::Unset, |value| Drawn::Set(value)), }); let mut merged = merge_slots(&key, &widths); + project_nested_length_tokens(&key, &mut merged, &first.node_id, context); for (slot, width) in widths.iter().enumerate() { if let Drawn::Set(expected) = width && cleared_when_dropped(&key) @@ -882,6 +883,14 @@ fn merge_trees( } } + if !props.keys().any(|key| key.starts_with('_') || key == "selectors") { + for (left, right, axis, all) in [("pl", "pr", "px", "p"), ("pt", "pb", "py", "p"), ("ml", "mr", "mx", "m"), ("mt", "mb", "my", "m")] { + if props.contains_key(axis) || props.contains_key(all) { continue; } + if let Some(value) = props.get(left).cloned().filter(|value| props.get(right) == Some(value)) { + props.remove(left); props.remove(right); props.insert(axis.into(), value); + } + } + } let mut source_node_ids = BTreeSet::new(); for tree in by_slot.iter().flatten() { source_node_ids.extend(tree.source_node_ids.iter().cloned()); diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/child_alignment.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/child_alignment.rs index 7e610467..71a507d3 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/responsive/child_alignment.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/child_alignment.rs @@ -1,5 +1,6 @@ struct MergeContext<'a> { snapshot: &'a Snapshot, + length_tokens: &'a BTreeMap, notes: Vec, diagnostics: Vec, slot_widths: BySlot, diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/child_order.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/child_order.rs new file mode 100644 index 00000000..d418ce0b --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/child_order.rs @@ -0,0 +1,123 @@ +/// Keep the semantic correspondence chosen by align_children. CSS order is +/// derived only for complete, uniquely matched native flex sibling sets. +fn captured_child_orders( + parents: &BySlot, + maps: &ChildMaps<'_>, + context: &mut MergeContext<'_>, +) -> Option>> { + let drawn = maps.iter().flatten().collect::>(); + if drawn.len() < 2 + || !drawn + .iter() + .any(|left| drawn.iter().any(|right| shared_order_differs(left, right))) + { + return None; + } + for (slot, map) in maps + .iter() + .enumerate() + .filter_map(|(slot, map)| map.as_ref().map(|map| (slot, map))) + { + let parent = parents[slot].as_ref()?; + let node = context.snapshot.nodes.get(&parent.node_id)?; + if parent.is_component + || !matches!( + node.typed_view().string("layoutMode"), + Some("HORIZONTAL" | "VERTICAL") + ) + || node.field_errors.contains_key("childrenIds") + || node.field_errors.contains_key("layoutMode") + { + return None; + } + let source_ids = node.fields.get("childrenIds")?.as_array()?; + if source_ids.len() != map.len() + || source_ids.iter().zip(map).any(|(id, (_, children))| { + children.len() != 1 + || id.as_str() != Some(children[0].node_id.as_str()) + || children[0] + .props + .get("pos") + .is_some_and(|v| v == "absolute") + || children[0].props.contains_key("order") + }) + { + return None; + } + for (key, children) in map { + for other_map in &drawn { + if let Some((_, peers)) = other_map.iter().find(|(other, _)| other == key) { + let other = *peers.first()?; + if !(0..4).any(|pass| { + matches_at(context.snapshot, children[0], other, pass) + && map + .iter() + .filter(|(_, peers)| { + matches_at(context.snapshot, peers[0], other, pass) + }) + .count() + == 1 + && other_map + .iter() + .filter(|(_, peers)| { + matches_at(context.snapshot, children[0], peers[0], pass) + }) + .count() + == 1 + }) { + return None; + } + } else if other_map.iter().any(|(_, peers)| { + (0..4).any(|pass| matches_at(context.snapshot, children[0], peers[0], pass)) + }) { + // A width-specific layer is safe only when no possible + // counterpart was left unmatched at the other width. + return None; + } + } + } + } + context.diagnostics.push(devup_mcp_figma::Diagnostic { + code: "DEVUP_CODEGEN_RESPONSIVE_READING_ORDER_REVIEW".into(), + message: "Captured flex sibling order differs by viewport. CSS order preserves each visual order; DOM reading and keyboard order retain the canonical tree and require semantic review for interactive content.".into(), + node_id: parents.iter().flatten().next().map(|p| p.node_id.clone()), + details: Some(json!({"sourceField":"childrenIds","nodes":parents.iter().flatten().map(|p| &p.node_id).collect::>(),"output":"responsiveTsx","duplicatesDOM":false})), + severity: Some(devup_mcp_figma::DiagnosticSeverity::Warning), + ..devup_mcp_figma::Diagnostic::default() + }); + Some(std::array::from_fn(|slot| { + maps[slot].as_ref().map(|map| { + map.iter() + .enumerate() + .map(|(index, (key, _))| (key.clone(), index)) + .collect() + }) + })) +} + +fn captured_order_provenance(snapshot: &Snapshot, tree: &Tree) -> Vec { + tree.slots.iter().enumerate().filter_map(|(slot, source)| { + let (id, _) = source.as_ref()?; + let parent_id = snapshot.nodes.get(id)?.typed_view().string("parentId")?; + let ids = snapshot.nodes.get(parent_id)?.fields.get("childrenIds")?.as_array()?; + let index = ids.iter().position(|child| child.as_str() == Some(id))?; + Some(json!({"slot":slot,"sourceNodeId":parent_id,"sourceField":"childrenIds","childNodeId":id,"sourceIndex":index,"calculation":"CSS order equals captured direct flex sibling index; DOM reading order is unchanged"})) + }).collect() +} + +/// Width-specific siblings do not by themselves create an order conflict. +/// Compare only jointly captured peers; their positions remain exact indices +/// in each complete source list, including any width-specific spacer. +fn shared_order_differs(left: &[(String, Vec<&Tree>)], right: &[(String, Vec<&Tree>)]) -> bool { + let common_left = left + .iter() + .filter(|(key, _)| right.iter().any(|(other, _)| other == key)) + .map(|(key, _)| key) + .collect::>(); + let common_right = right + .iter() + .filter(|(key, _)| left.iter().any(|(other, _)| other == key)) + .map(|(key, _)| key) + .collect::>(); + common_left.len() > 1 && common_left != common_right +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/gap_aliases.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/gap_aliases.rs new file mode 100644 index 00000000..44663983 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/gap_aliases.rs @@ -0,0 +1,39 @@ +/// A mobile gap shorthand followed by axis-specific desktop gaps cannot leave +/// a gap=initial reset: CSS shorthand declaration order can erase one desktop +/// axis. Resolve each captured frame into the two independent CSS axes first. +fn normalize_responsive_gap(by_slot: &BySlot, snapshot: &Snapshot) -> Option> { + if by_slot.iter().flatten().count() < 2 + || by_slot.iter().flatten().any(|tree| tree.is_component) + || !by_slot.iter().flatten().any(|tree| tree.props.contains_key("gap")) + || !by_slot.iter().flatten().any(|tree| ["rowGap", "columnGap", "colGap"].iter().any(|name| tree.props.contains_key(*name))) + { + return None; + } + // Do not manufacture known axes for an unreadable width. The existing + // partial projection and its gaps remain available without this rewrite. + for tree in by_slot.iter().flatten() { + let node = snapshot.nodes.get(&tree.node_id)?; + let view = node.typed_view(); + for field in ["itemSpacing", "counterAxisSpacing", "inferredAutoLayout", "layoutMode", "layoutWrap"] { + if view.field_error(field).is_some() { return None; } + } + for field in ["itemSpacing", "counterAxisSpacing"] { + if view.value(field).is_some_and(|value| value.as_f64().is_none()) { return None; } + } + if !["gap", "rowGap", "columnGap", "colGap"].iter().any(|name| tree.props.contains_key(*name)) { + let spacing = view.number("itemSpacing").or_else(|| view.value("inferredAutoLayout") + .and_then(|layout| layout.get("itemSpacing")).and_then(Value::as_f64)); + if spacing != Some(0.0) || view.number("counterAxisSpacing") != Some(0.0) { return None; } + } + } + Some(std::array::from_fn(|slot| by_slot[slot].clone().map(|mut tree| { + let original = tree.props.clone(); + for name in ["gap", "rowGap", "columnGap", "colGap"] { tree.props.remove(name); } + for (axis, alias) in [("rowGap", "rowGap"), ("columnGap", "colGap")] { + let value = original.get(axis).or_else(|| original.get(alias)).or_else(|| original.get("gap")) + .cloned().unwrap_or_else(|| "initial".into()); + tree.props.insert(axis.into(), value); + } + tree + }))) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/impl.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/impl.rs index 31823813..8cbcf350 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/responsive/impl.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/impl.rs @@ -1,5 +1,3 @@ - - impl WidthCheck<'_> { fn run(&self, width: &[OwnedElement], ok: &mut [bool], gaps: &mut Vec) { let slot = self.slot; @@ -37,6 +35,15 @@ impl WidthCheck<'_> { continue; }; let gap = |reason: &str| json!({"reason":reason,"slot":slot,"widthNodeId":node,"element":index,"tag":tag}); + if let Some([parent, wrapper]) = contents_wrapper_source(node) { + // This box has no source element at this width. Its children + // retain their real identities and are verified below; do not + // claim the derived topology has been browser-tested. + fail(own, ok, json!({"reason":"derived-responsive-wrapper-requires-runtime-review", + "slot":slot,"element":index,"sourceParentNodeId":parent, + "capturedWrapperNodeId":wrapper,"runtimeVerified":false}), gaps); + continue; + } if !element.shown[slot] { if element.parent_shown[slot] && !element.in_text @@ -193,31 +200,113 @@ impl WidthCheck<'_> { let placement = |name: &str, value: &str| { absolute && (POSITION_PROPS.contains(&name) || (name == "w" && value == "100%")) }; - let theirs = theirs + let mut theirs = theirs .attributes .iter() .map(|(attribute, verified)| (attribute.name.as_str(), (written(attribute), *verified))) .filter(|(name, (value, _))| !placement_only || placement(name, value.as_str())) .collect::>(); + let own_uses_axis_gaps = own.iter().any(|index| matches!(self.attributes[*index].name.as_str(), "rowGap" | "columnGap")) + && !own.iter().any(|index| self.attributes[*index].name == "gap"); + if own_uses_axis_gaps && let Some(gap) = theirs.remove("gap") { + // Preserve the original shorthand's verification for each exactly + // equivalent axis; explicit source axis values take precedence. + for axis in ["rowGap", "columnGap"] { theirs.entry(axis).or_insert_with(|| gap.clone()); } + } + let own_uses_physical_size = own + .iter() + .any(|index| matches!(self.attributes[*index].name.as_str(), "w" | "h")) + && !own + .iter() + .any(|index| self.attributes[*index].name == "boxSize"); + if own_uses_physical_size && let Some(size) = theirs.remove("boxSize") { + // Width and height are exact aliases of the same captured scalar; + // carry its original provenance verification to both physical axes. + for axis in ["w", "h"] { + theirs.entry(axis).or_insert_with(|| size.clone()); + } + } + for (all, sides) in [("p", ["pl", "pr", "pt", "pb"]), ("m", ["ml", "mr", "mt", "mb"])] { + if !own.iter().any(|index| self.attributes[*index].name == all) + && let Some(value) = theirs.remove(all) { + for side in sides { theirs.entry(side).or_insert_with(|| value.clone()); } + } + } + for (left, right, axis) in [("pl", "pr", "px"), ("pt", "pb", "py"), ("ml", "mr", "mx"), ("mt", "mb", "my")] { + if !own.iter().any(|index| self.attributes[*index].name == axis) + && own.iter().any(|index| self.attributes[*index].name == left || self.attributes[*index].name == right) + && let Some(value) = theirs.remove(axis) { + theirs.entry(left).or_insert_with(|| value.clone()); + theirs.entry(right).or_insert(value); + } + } + let own_uses_physical_padding = own.iter().any(|index| { + matches!( + self.attributes[*index].name.as_str(), + "pl" | "pr" | "pt" | "pb" + ) + }) && !own + .iter() + .any(|index| matches!(self.attributes[*index].name.as_str(), "p" | "px" | "py")); + if own_uses_physical_padding + && ["p", "px", "py"] + .iter() + .any(|name| theirs.contains_key(name)) + { + let original = theirs.clone(); + for name in ["p", "px", "py", "pl", "pr", "pt", "pb"] { + theirs.remove(name); + } + for (side, axis) in [("pl", "px"), ("pr", "px"), ("pt", "py"), ("pb", "py")] { + if let Some(value) = original + .get(side) + .or_else(|| original.get(axis)) + .or_else(|| original.get("p")) + { + theirs.insert(side, value.clone()); + } + } + } + for (left, right, axis) in [("pl", "pr", "px"), ("pt", "pb", "py"), ("ml", "mr", "mx"), ("mt", "mb", "my")] { + if own.iter().any(|index| self.attributes[*index].name == axis) + && let Some(value) = theirs.get(left).cloned().filter(|value| theirs.get(right) == Some(value)) { + theirs.remove(left); theirs.remove(right); theirs.insert(axis, value); + } + } let mut names = BTreeSet::new(); for index in own { let attribute = &self.attributes[*index]; let name = attribute.name.as_str(); names.insert(name); + if name == "order" && captured_order_provenance(self.snapshot, self.placed[element].tree) + .iter().any(|source| source["slot"] == self.slot && slot_values(&attribute.source[name.len()..]) + .as_deref().and_then(|values| effective(values, self.css_slot)) + .and_then(|value| value.parse::().ok()) == source["sourceIndex"].as_u64()) + { + continue; + } if placement_only && name == "display" { continue; } let here = slot_values(&attribute.source[name.len()..]).map(|values| { - let value = effective(&values, slot); + let value = effective(&values, self.css_slot); let unset = value == Some("initial") && INHERITED.contains(&name) && !self.inherited(element, name); - settled(name, if unset { None } else { value }, tag) + resolved_length_value( + settled(name, if unset { None } else { value }, tag), + self.css_slot, + self.length_tokens, + ) }); let (there, verified) = theirs.get(name).map_or((None, true), |(value, verified)| { (Some(value.as_str()), *verified) }); - let there = settled(name, there, their_tag); + let there = resolved_length_value( + settled(name, there, their_tag), + self.css_slot, + self.length_tokens, + ); if here.as_ref() != Some(&there) || !verified { ok[*index] = false; gaps.push(json!({"reason":if verified {"value-differs-at-width"} else {"unverified-at-width"}, diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/instance_text_branches.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/instance_text_branches.rs new file mode 100644 index 00000000..53e708a6 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/instance_text_branches.rs @@ -0,0 +1,57 @@ +// Text is not a responsive CSS property. Preserve the nearest captured +// instance as a unit when its direct text changes: a consumer can then bind +// each tab/button's action to the label that is actually visible at that width. +// Splitting just its Text would leave one shared interactive parent/action. +fn instance_text_branches(children: BySlot, snapshot: &Snapshot) -> Vec> { + fn words(tree: &Tree, snapshot: &Snapshot) -> Option> { + if tree.is_component || snapshot.nodes.get(&tree.node_id)?.node_type != "INSTANCE" { + return None; + } + let content = tree + .children + .iter() + .filter_map(|child| { + (child.component == "Text" + && child + .props + .get("display") + .is_none_or(|value| value != "none")) + .then_some(child.content.as_ref()) + .flatten() + .map(|content| content.replace("
", "")) + }) + .collect::>(); + (!content.is_empty()).then_some(content) + } + + let signatures: BySlot> = std::array::from_fn(|slot| { + children[slot] + .as_ref() + .and_then(|child| words(child, snapshot)) + }); + if children + .iter() + .enumerate() + .any(|(slot, child)| child.is_some() && signatures[slot].is_none()) + { + return vec![children]; + } + let distinct = signatures + .iter() + .flatten() + .cloned() + .collect::>(); + if distinct.len() < 2 || distinct.iter().map(Vec::len).collect::>().len() != 1 { + return vec![children]; + } + distinct + .into_iter() + .map(|signature| { + std::array::from_fn(|slot| { + children[slot] + .clone() + .filter(|_| signatures[slot].as_ref() == Some(&signature)) + }) + }) + .collect() +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/layout_defaults.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/layout_defaults.rs new file mode 100644 index 00000000..ef8ac45c --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/layout_defaults.rs @@ -0,0 +1,98 @@ +/// What a layout element does by itself besides its `display`. +fn implied_layout(component: &str) -> &'static [(&'static str, &'static str)] { + match component { + "Flex" => &[("flexDir", "row")], + "VStack" => &[("flexDir", "column")], + "Center" => &[("alignItems", "center"), ("justifyContent", "center")], + _ => &[], + } +} + +/// The `display` a devup-ui element already carries by being itself. +fn implied_display(component: &str) -> Option<&'static str> { + match component { + "Flex" | "VStack" | "Center" => Some("flex"), + "Grid" => Some("grid"), + _ => None, + } +} + +/// What `display` an element goes back to when a wider width stops hiding it. +/// +/// `merge_slots` clears a dropped layout prop to `"initial"`, which is right +/// for the other forty and wrong for this one. `initial` is the value the CSS +/// specification gives a property, not the value the element has: for `w` that +/// is `auto` and for `p` it is `0`, which are what "unset" should mean, but for +/// `display` it is `inline` whatever the element is. An element's own display +/// comes from the user-agent stylesheet instead, so a `Box` shown only from +/// tablet up would come back inline rather than block. +/// +/// `revert` is the keyword that would mean what is wanted, and it cannot be +/// used either: it rolls the cascade back past the author origin, which is +/// where devup-ui puts `VStack`'s own `display: flex`. So the value is written +/// out, and `display` is never cleared to `initial`. +fn natural_display(component: &str) -> &'static str { + match component { + "Flex" | "VStack" | "Center" => "flex", + "Grid" => "grid", + // `Image` is an `img`, which is inline. Everything else devup-ui draws + // is a `div`, and `Text` is a `p`. + "Image" => "inline", + _ => "block", + } +} + +/// Put back the `display` the element name stands for, before any merging. +/// +/// The plugin keeps `display` in a node's props and picks the element from it; +/// this projection picks the element first and drops the value. That is the +/// same thing for one width, but not across several: a width where the node is +/// hidden would leave the others with nothing to go back to, and `merge_slots` +/// would clear them to `"initial"` — which for `display` is `inline`, not +/// `flex`. The node would stop being a flex container at every width it is +/// shown. Rendering drops the value again where the element implies it, so +/// nothing is written that was not written before. +fn restore_implied_display(tree: &mut Tree) { + if let Some(display) = implied_display(&tree.component) { + tree.props + .entry("display".to_owned()) + .or_insert_with(|| display.to_owned()); + } + for child in &mut tree.children { + restore_implied_display(child); + } +} + +/// An omitted Figma MIN default is not an absent breakpoint. Restore only a +/// read-safe captured MIN (direct or inferred when the direct default was +/// omitted), leaving unknown reads unverified instead of clearing every gap. +fn normalize_source_main_alignment(by_slot: &BySlot, snapshot: &Snapshot) -> Option> { + if !by_slot.iter().flatten().any(|tree| tree.props.contains_key("justifyContent")) { + return None; + } + let mut changed = false; + let normalized = std::array::from_fn(|slot| by_slot[slot].clone().map(|mut tree| { + if !tree.is_component && LAYOUT_ELEMENTS.contains(&tree.component.as_str()) + && !tree.props.contains_key("justifyContent") + && let Some(node) = snapshot.nodes.get(&tree.node_id) + { + let view = node.typed_view(); + let primary = match view.value("primaryAxisAlignItems") { + Some(value) => value.as_str(), + None if view.field_error("inferredAutoLayout").is_none() => view.value("inferredAutoLayout") + .and_then(|layout| layout.get("primaryAxisAlignItems")).and_then(Value::as_str), + None => None, + }; + if primary == Some("MIN") && view.field_error("primaryAxisAlignItems").is_none() + && view.field_error("layoutMode").is_none() + && matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) + { + // CSS initial is normal, whose flex-layout distribution starts. + tree.props.insert("justifyContent".into(), "initial".into()); + changed = true; + } + } + tree + })); + changed.then_some(normalized) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/length_token_projection.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/length_token_projection.rs new file mode 100644 index 00000000..499d272e --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/length_token_projection.rs @@ -0,0 +1,101 @@ +fn css_length_slot(width: f64) -> usize { + [480.0, 768.0, 992.0, 1280.0, 1600.0] + .iter() + .filter(|min| width >= **min) + .count() +} + +fn length_property(name: &str) -> bool { + matches!( + name, + "p" | "px" + | "py" + | "pt" + | "pr" + | "pb" + | "pl" + | "gap" + | "rowGap" + | "columnGap" + | "borderRadius" + | "fontSize" + ) +} + +fn known_length<'a>( + value: &str, + tokens: &'a BTreeMap, +) -> Option<&'a Value> { + let name = value + .strip_prefix('$') + .or_else(|| value.strip_prefix("var(--")?.strip_suffix(')'))?; + tokens + .values() + .find(|token| token.token == name) + .map(|token| &token.value) +} + +fn length_token_at(value: &Value, slot: usize) -> Option<&str> { + match value { + Value::Array(values) => values + .iter() + .take(slot + 1) + .rev() + .find(|value| !value.is_null())? + .as_str(), + value => value.as_str(), + } +} + +fn resolved_length_value( + value: Option, + slot: usize, + tokens: &BTreeMap, +) -> Option { + value.map(|value| { + known_length(&value, tokens) + .and_then(|token| length_token_at(token, slot)) + .map_or_else(|| value.clone(), str::to_owned) + }) +} + +// JSX token syntax is retained at every responsive slot. A compiler-specific +// extraction caveat is evidence to verify, not permission to emit raw CSS vars. +fn project_nested_length_tokens( + property: &str, + merged: &mut Merged, + node_id: &str, + context: &mut MergeContext<'_>, +) { + if !length_property(property) { return; } + let Merged::Array(slots) = merged else { return; }; + for value in slots.iter_mut().flatten() { + if let Some(name) = value.strip_prefix("var(--").and_then(|v| v.strip_suffix(')')) + && known_length(value, context.length_tokens).is_some() + { + *value = format!("${name}"); + } + } + let equivalent = slots.iter().flatten().filter(|value| value.starts_with('$')).find(|candidate| { + let Some(theme) = known_length(candidate, context.length_tokens) else { return false; }; + let last = theme.as_array().map_or(6, |values| values.len().max(6)); + (0..last).all(|slot| { + let Some(captured) = slots.iter().take(slot + 1).rev().flatten().next() else { return false; }; + resolved_length_value(Some(captured.clone()), slot, context.length_tokens).as_deref() == length_token_at(theme, slot) + }) + }).cloned(); + if let Some(token) = equivalent { + *merged = Merged::Same(Some(token)); + return; + } + if !slots.iter().flatten().any(|value| value.starts_with('$') && known_length(value, context.length_tokens).is_some_and(Value::is_array)) { return; } + context.diagnostics.push(devup_mcp_figma::Diagnostic { + code: "DEVUP_CODEGEN_RESPONSIVE_LENGTH_TOKEN_PROJECTED".into(), + message: "Responsive JSX keeps $token references. Verify computed styles at every theme breakpoint with the installed devup-ui compiler; source syntax alone does not verify CSS extraction.".into(), + node_id: Some(node_id.to_owned()), property: Some(property.to_owned()), + severity: Some(devup_mcp_figma::DiagnosticSeverity::Warning), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::None), + details: Some(json!({"output":"responsiveTsx","projectedSlots":slots,"bindingPreserved":true,"runtimeVerified":false,"scope":"Captured tokens are retained in JSX. Nested responsive extraction needs consumer-browser validation."})), + ..Default::default() + }); +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/literal_text_branches.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/literal_text_branches.rs new file mode 100644 index 00000000..6e2c5443 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/literal_text_branches.rs @@ -0,0 +1,50 @@ +/// Literal text is content, not a responsive CSS property. Keep each captured +/// copy on its own existing Text root when confidently aligned widths disagree. +/// Instance text branches run first so an interactive label keeps its hit area. +fn literal_text_branches(children: BySlot, snapshot: &Snapshot) -> Vec> { + let signatures: BySlot = std::array::from_fn(|slot| { + let tree = children[slot].as_ref()?; + let node = snapshot.nodes.get(&tree.node_id)?; + if tree.is_component + || tree.component != "Text" + || node.node_type != "TEXT" + || node.field_errors.contains_key("characters") + { + return None; + } + // Line-break-only differences already have a precise shared-text merge. + Some( + node.fields + .get("characters")? + .as_str()? + .replace("\r\n", "\n") + .replace('\r', " ") + .replace(['\n', '\u{2028}', '\u{2029}'], ""), + ) + }); + if children + .iter() + .enumerate() + .any(|(slot, tree)| tree.is_some() && signatures[slot].is_none()) + { + return vec![children]; + } + let distinct = signatures + .iter() + .flatten() + .cloned() + .collect::>(); + if distinct.len() < 2 { + return vec![children]; + } + distinct + .into_iter() + .map(|signature| { + std::array::from_fn(|slot| { + children[slot] + .clone() + .filter(|_| signatures[slot].as_ref() == Some(&signature)) + }) + }) + .collect() +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/merge_content.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/merge_content.rs index bfa8d9a8..1244c99e 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/responsive/merge_content.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/merge_content.rs @@ -1,5 +1,3 @@ - - /// What a text says across the widths. /// /// Words are not a style prop and have no array to go into, so the widths @@ -146,7 +144,10 @@ fn merge_children( precedence: &Precedence, context: &mut MergeContext<'_>, ) -> Vec { + let reconciled = reconcile_child_wrappers(by_slot, context); + let by_slot = reconciled.as_ref().unwrap_or(by_slot); let maps = align_children(by_slot, context); + let captured_orders = captured_child_orders(by_slot, &maps, context); let orders = maps .iter() .flatten() @@ -167,56 +168,97 @@ fn merge_children( .unwrap_or_default(); for index in 0..count { - let mut children: BySlot = std::array::from_fn(|slot| { - bucket(slot).and_then(|list| list.get(index).cloned().cloned()) + let children: BySlot = std::array::from_fn(|slot| { + let mut child = bucket(slot).and_then(|list| list.get(index).cloned().cloned())?; + if let Some(order) = captured_orders.as_ref().and_then(|orders| orders[slot].as_ref()).and_then(|orders| orders.get(&key)) { + child.props.insert("order".into(), order.to_string()); + } + Some(child) }); - // A layer hidden at every width that has it is drawn at none, and - // `tsx` leaves it out — with its asset references, which a hidden - // layer has none of to deliver. A copy hidden everywhere would - // say nothing but `display: none`. - if children.iter().flatten().all(|child| { - child - .props - .get("display") - .is_some_and(|value| value == "none") - }) { - continue; - } - if (0..SLOTS).any(|slot| by_slot[slot].is_some() && children[slot].is_none()) { - for child in children.iter_mut().flatten() { - if child.component == "Image" { - // A present source must restore visibility even when - // it is absent on both sides of this drawn slot. - child - .props - .entry("display".to_owned()) - .or_insert_with(|| natural_display("Image").to_owned()); + let snapshot = context.snapshot; + for mut children in instance_text_branches(children, snapshot) + .into_iter() + .flat_map(typography_branches) + .flat_map(|children| literal_text_branches(children, snapshot)) + { + // A layer hidden at every width that has it is drawn at none, and + // `tsx` leaves it out — with its asset references, which a hidden + // layer has none of to deliver. A copy hidden everywhere would + // say nothing but `display: none`. + if children.iter().flatten().all(|child| { + child + .props + .get("display") + .is_some_and(|value| value == "none") + }) { + continue; + } + if (0..SLOTS).any(|slot| by_slot[slot].is_some() && children[slot].is_none()) { + for child in children.iter_mut().flatten() { + if child.component == "Image" { + // A present source must restore visibility even when + // it is absent on both sides of this drawn slot. + child + .props + .entry("display".to_owned()) + .or_insert_with(|| natural_display("Image").to_owned()); + } } } - } - // A width that does not draw this child is given a copy of the - // first one that does — first in the Section's order — hidden. The - // copies then merge like anything else: the `display` array falls - // out of the ordinary prop merge rather than from a branch of its - // own, and so do the copy's other values, which is why the choice - // of width to copy shows in the output. - if let Some(shown) = - first_slot(&children, precedence).and_then(|slot| children[slot].clone()) - { - for slot in 0..SLOTS { - if by_slot[slot].is_some() && children[slot].is_none() { - let mut hidden = shown.clone(); - hidden.props.insert("display".to_owned(), "none".to_owned()); - children[slot] = Some(hidden); + // A width that does not draw this child is given a copy of the + // first one that does — first in the Section's order — hidden. The + // copies then merge like anything else: the `display` array falls + // out of the ordinary prop merge rather than from a branch of its + // own, and so do the copy's other values, which is why the choice + // of width to copy shows in the output. + if let Some(shown) = + first_slot(&children, precedence).and_then(|slot| children[slot].clone()) + { + for slot in 0..SLOTS { + if by_slot[slot].is_some() && children[slot].is_none() { + let mut hidden = shown.clone(); + hidden.props.insert("display".to_owned(), "none".to_owned()); + children[slot] = Some(hidden); + } } } + merged.extend(merge_trees(&children, precedence, context)); } - merged.extend(merge_trees(&children, precedence, context)); } } merged } +// typography is a scalar token, not a CSS property. Separate captured Text +// nodes keep each token's complete style without inventing responsive fonts. +fn typography_branches(children: BySlot) -> Vec> { + if children + .iter() + .flatten() + .any(|child| child.component != "Text" || child.is_component) + { + return vec![children]; + } + let tokens = children + .iter() + .flatten() + .map(|child| child.props.get("typography").cloned()) + .collect::>(); + if tokens.len() < 2 { + return vec![children]; + } + tokens + .into_iter() + .map(|token| { + std::array::from_fn(|slot| { + children[slot] + .clone() + .filter(|child| child.props.get("typography").cloned() == token) + }) + }) + .collect() +} + /// Write a merged tree as JSX, in the house style: props sorted, spread over /// lines once there are five, and childless elements closed on themselves. fn render_merged(tree: &Tree, depth: usize) -> String { @@ -262,10 +304,7 @@ fn render_merged(tree: &Tree, depth: usize) -> String { // A shape spelled out in place of the component it came from still says // which component that was, or a reader has nothing to go on when the // change belongs upstream. - let comment = match &tree.leading_comment { - Some(comment) => format!("{indent}{{/* {comment} */}}\n"), - None => String::new(), - }; + let comment = String::new(); let component = &tree.component; if children.is_empty() { if multiline { @@ -407,15 +446,28 @@ pub fn merge_confirmed_breakpoints( snapshot: &Snapshot, options: &CodegenOptions, ) -> Result, DevupError> { - let widths = snapshot.roots.iter().filter_map(|id| { - Some(("confirmed", snapshot.nodes.get(id)?.typed_view().number("width")?)) - }).collect::>(); + let widths = snapshot + .roots + .iter() + .filter_map(|id| { + Some(( + "confirmed", + snapshot.nodes.get(id)?.typed_view().number("width")?, + )) + }) + .collect::>(); if widths.len() != snapshot.roots.len() || !breakpoint_family_is_possible(&widths) { return Ok(None); } - let found = snapshot.roots.iter().enumerate().map(|(rank, id)| Breakpoint { - rank, node_id: id.clone(), - }).collect::>(); + let found = snapshot + .roots + .iter() + .enumerate() + .map(|(rank, id)| Breakpoint { + rank, + node_id: id.clone(), + }) + .collect::>(); merge_discovered_breakpoints(snapshot, options, &found) } @@ -448,7 +500,11 @@ fn merge_discovered_breakpoints( // the slot; reporting the clash is left to the caller, which knows the // names, rather than silently dropping a width here. if by_slot[slot].is_none() { - let mut tree = project_tree_keeping_instances(snapshot, node, options, true)?; + let mut tree = if options.inline_instances { + project_tree(snapshot, node, options, true)? + } else { + project_tree_keeping_instances(snapshot, node, options, true)? + }; restore_implied_display(&mut tree); by_slot[slot] = Some(tree); roots[slot] = Some(breakpoint.node_id.clone()); @@ -464,7 +520,13 @@ fn merge_discovered_breakpoints( } } slots.sort_unstable(); - let mut context = MergeContext { snapshot, notes: Vec::new(), diagnostics: Vec::new(), slot_widths }; + let mut context = MergeContext { + snapshot, + length_tokens: &options.length_tokens, + notes: Vec::new(), + diagnostics: Vec::new(), + slot_widths, + }; let Some(mut tree) = merge_trees(&by_slot, &precedence, &mut context) else { return Ok(None); }; @@ -476,11 +538,7 @@ fn merge_discovered_breakpoints( .get("w") .and_then(|value| slot_values(value)) .is_some_and(|values| { - values.len() > 1 - && values - .iter() - .flatten() - .all(|value| value.ends_with("px")) + values.len() > 1 && values.iter().flatten().all(|value| value.ends_with("px")) }); if root_fluid { tree.props.remove("w"); @@ -488,6 +546,11 @@ fn merge_discovered_breakpoints( let mut components = BTreeSet::new(); collect_components(&tree, &mut components); let tsx = render_merged(&tree, 2); + for root in roots.iter().flatten() { + context + .diagnostics + .extend(super::overlay_anchors::diagnostics(snapshot, root, &tsx)); + } let (contract, gaps) = verify_merge(snapshot, options, &roots, &tree, &tsx, root_fluid); let elements = merged_elements(&tree, &slot_widths); Ok(Some(MergedScreen { @@ -525,7 +588,7 @@ fn merged_elements(tree: &Tree, slot_widths: &BySlot) -> Vec continue; }; match placed.tree.slots.get(slot).cloned().flatten() { - Some((node, _)) if placed.shown[slot] => ids.push((*width, node)), + Some((node, _)) if placed.shown[slot] && contents_wrapper_source(&node).is_none() => ids.push((*width, node)), _ if !placed.shown[slot] => hidden_at.push(*width), // Shown with no node of its own at that width: nothing // in that frame to compare it with. @@ -607,25 +670,10 @@ fn slot_values(written: &str) -> Option>> { { return Some(vec![Some(value.to_owned())]); } - let mut rest = written - .strip_prefix("={[")? - .strip_suffix("]}")? - .trim_start(); - let mut values = Vec::new(); - while !rest.is_empty() { - if let Some(after) = rest.strip_prefix("null") { - values.push(None); - rest = after; - } else { - let after = rest.strip_prefix('"')?; - let end = after.find('"')?; - values.push(Some(after[..end].to_owned())); - rest = &after[end + 1..]; - } - rest = rest.trim_start(); - rest = rest.strip_prefix(',').unwrap_or(rest).trim_start(); - } - Some(values) + // Arrays are JS string/null literals serialized as JSON. Decode their + // escapes before comparing with scalar JSX values (whose backslashes are + // literal), including CSS escaped identifiers and quoted font names. + serde_json::from_str(written.strip_prefix("={")?.strip_suffix('}')?).ok() } /// What devup-ui applies at a slot: the last value at or below it. @@ -793,6 +841,13 @@ fn verify_merge( let check = WidthCheck { snapshot, slot, + css_slot: css_length_slot( + snapshot.nodes[root] + .typed_view() + .number("width") + .unwrap_or_default(), + ), + length_tokens: &options.length_tokens, placed: &placed, tags: &tags, attributes: &attributes, @@ -830,9 +885,17 @@ fn verify_merge( .iter() .zip(&ok) .map(|(attribute, verified)| { - json!({"elementIndex":attribute.element,"tag":attribute.tag, - "nodeId":aligned.then(|| placed[attribute.element].tree.node_id.as_str()), - "generatedProperty":attribute.source,"verified":verified}) + let mut entry = json!({"elementIndex":attribute.element,"tag":attribute.tag, + "nodeId":aligned.then(|| placed[attribute.element].tree.node_id.as_str()).filter(|id| contents_wrapper_source(id).is_none()), + "generatedProperty":attribute.source,"verified":verified}); + if attribute.name == "order" && aligned { + entry["derivedSource"] = json!(captured_order_provenance(snapshot, placed[attribute.element].tree)); + } + if aligned { + let wrapper_sources = derived_wrapper_provenance(placed[attribute.element].tree); + if !wrapper_sources.is_empty() { entry["derivedTopology"] = json!(wrapper_sources); } + } + entry }) .collect(); (contract, gaps) @@ -841,6 +904,8 @@ fn verify_merge( struct WidthCheck<'a> { snapshot: &'a Snapshot, slot: usize, + css_slot: usize, + length_tokens: &'a BTreeMap, placed: &'a [Placed<'a>], tags: &'a [String], attributes: &'a [GeneratedAttribute], diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/padding_aliases.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/padding_aliases.rs new file mode 100644 index 00000000..eb13f47f --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/padding_aliases.rs @@ -0,0 +1,67 @@ +/// Devup's responsive shorthand/longhand extraction can discard a narrower +/// side when a wider breakpoint uses an overlapping shorthand. The layout +/// writer emits scalar uniform padding props, so expand only families with +/// different overlapping aliases into their independent physical sides. +fn normalize_responsive_padding(by_slot: &BySlot) -> Option> { + if by_slot.iter().flatten().count() < 2 + || by_slot.iter().flatten().any(|tree| tree.is_component) + { + return None; + } + let aliases = [ + ("p", 15_u8), + ("px", 3), + ("py", 12), + ("pl", 1), + ("pr", 2), + ("pt", 4), + ("pb", 8), + ]; + let present = aliases + .iter() + .filter(|(name, _)| { + by_slot + .iter() + .flatten() + .any(|tree| tree.props.contains_key(*name)) + }) + .collect::>(); + if !present + .iter() + .enumerate() + .any(|(index, (left, left_sides))| { + present[index + 1..] + .iter() + .any(|(right, right_sides)| left != right && left_sides & right_sides != 0) + }) + { + return None; + } + let affected = present.iter().fold(0_u8, |sides, (_, mask)| sides | mask); + Some(std::array::from_fn(|slot| { + by_slot[slot].clone().map(|mut tree| { + let original = tree.props.clone(); + for (name, _) in aliases { + tree.props.remove(name); + } + for (side, axis, mask) in [ + ("pl", "px", 1_u8), + ("pr", "px", 2), + ("pt", "py", 4), + ("pb", "py", 8), + ] { + if affected & mask == 0 { + continue; + } + let value = original + .get(side) + .or_else(|| original.get(axis)) + .or_else(|| original.get("p")) + .cloned() + .unwrap_or_else(|| "initial".to_owned()); + tree.props.insert(side.to_owned(), value); + } + tree + }) + })) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/paint_defaults.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/paint_defaults.rs new file mode 100644 index 00000000..cc243d4b --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/paint_defaults.rs @@ -0,0 +1,82 @@ +/// Explicit empty paint differs from an uncollected/unreadable field. The fast +/// collector omits empty arrays after reading the complete manifest; native +/// layout nodes with captured dimensions can therefore have no paint field. +/// Component calls, missing nodes and failed/marked reads provide no such proof. +fn source_paint_is_empty(node: &RawNode, field: &str) -> bool { + if !matches!(node.node_type.as_str(), "FRAME" | "INSTANCE" | "COMPONENT" | "COMPONENT_SET") + || node.field_errors.contains_key(field) + || !["width", "height"].iter().all(|name| { + node.fields.get(*name).and_then(Value::as_f64).is_some() + }) + { + return false; + } + match node.fields.get(field) { + None => true, // Manifest-backed fast-snapshot empty-array omission. + Some(Value::Array(paints)) => paints.iter().all(|paint| { + paint.is_object() + && paint.get("visible").and_then(Value::as_bool) == Some(false) + }), + Some(_) => false, // Null, unsupported markers and malformed reads are unknown. + } +} + +fn paint_source_fields(prop: &str) -> &'static [&'static str] { + match prop { + "bg" | "bgImage" | "bgColor" | "background" | "backgroundImage" => &["fills"], + "border" | "borderTop" | "borderRight" | "borderBottom" | "borderLeft" + | "outline" | "outlineOffset" => &["strokes"], + // Outside strokes can also produce a shadow, so both sources must be empty. + "boxShadow" => &["effects", "strokes"], + "filter" | "backdropFilter" => &["effects"], + _ => &[], + } +} + +/// A mixed stroke still paints some edges. An explicitly captured zero on +/// another edge proves only that side is absent, not that all strokes are. +fn source_stroke_edge_is_zero(node: &RawNode, prop: &str) -> bool { + let field = match prop { + "borderTop" => "strokeTopWeight", + "borderRight" => "strokeRightWeight", + "borderBottom" => "strokeBottomWeight", + "borderLeft" => "strokeLeftWeight", + _ => return false, + }; + !node.field_errors.contains_key(field) + && !node.field_errors.contains_key("strokes") + && node.fields.get(field).and_then(Value::as_f64) == Some(0.0) + && node.fields.get("strokes").and_then(Value::as_array).is_some_and(|paints| { + paints.iter().all(|paint| paint.get("type").and_then(Value::as_str).is_some()) + }) +} + +fn normalize_source_paint_defaults( + by_slot: &BySlot, + snapshot: &Snapshot, +) -> Option> { + let keys = by_slot.iter().flatten().filter(|tree| !tree.is_component) + .flat_map(|tree| tree.props.keys()) + .filter(|key| !paint_source_fields(key).is_empty()) + .collect::>(); + let mut changed = false; + let normalized = std::array::from_fn(|slot| { + by_slot[slot].clone().map(|mut tree| { + if !tree.is_component && LAYOUT_ELEMENTS.contains(&tree.component.as_str()) + && let Some(node) = snapshot.nodes.get(&tree.node_id) + { + for key in &keys { + if !tree.props.contains_key(*key) + && (paint_source_fields(key).iter().all(|field| source_paint_is_empty(node, field)) + || (!tree.props.contains_key("border") && source_stroke_edge_is_zero(node, key))) + { + tree.props.insert((*key).clone(), "initial".into()); + changed = true; + } + } + } + tree + }) + }); + changed.then_some(normalized) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/size_aliases.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/size_aliases.rs new file mode 100644 index 00000000..fc3c3973 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/size_aliases.rs @@ -0,0 +1,35 @@ +/// Overlapping boxSize/w/h across frames must become independent physical +/// axes before breakpoint inheritance. A missing desktop h must not reset the +/// height supplied by desktop boxSize to CSS initial. +fn normalize_responsive_size(by_slot: &BySlot) -> Option> { + if by_slot.iter().flatten().count() < 2 + || by_slot.iter().flatten().any(|tree| tree.is_component) + || !by_slot + .iter() + .flatten() + .any(|tree| tree.props.contains_key("boxSize")) + || !by_slot + .iter() + .flatten() + .any(|tree| tree.props.contains_key("w") || tree.props.contains_key("h")) + { + return None; + } + Some(std::array::from_fn(|slot| { + by_slot[slot].clone().map(|mut tree| { + let original = tree.props.clone(); + for name in ["boxSize", "w", "h"] { + tree.props.remove(name); + } + for axis in ["w", "h"] { + let value = original + .get(axis) + .or_else(|| original.get("boxSize")) + .cloned() + .unwrap_or_else(|| "initial".into()); + tree.props.insert(axis.into(), value); + } + tree + }) + })) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/wrapper_reconciliation.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/wrapper_reconciliation.rs new file mode 100644 index 00000000..90191f75 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/wrapper_reconciliation.rs @@ -0,0 +1,405 @@ +// A structural wrapper at one width need not duplicate its children at another. +// Keep the real box and give the widths that had direct siblings a contents box: +// those children still participate in their original parent's formatting context. +// This is derived topology, so its browser verification remains explicit. +const CONTENTS_WRAPPER_PREFIX: &str = "__devup_responsive_contents__"; + +fn contents_wrapper_source(id: &str) -> Option<[String; 2]> { + serde_json::from_str(id.strip_prefix(CONTENTS_WRAPPER_PREFIX)?).ok() +} + +fn same_responsive_content(snapshot: &Snapshot, left: &Tree, right: &Tree) -> bool { + if !(0..4).any(|pass| matches_at(snapshot, left, right, pass)) + || left.content.as_deref().map(|s| s.replace("
", "")) + != right.content.as_deref().map(|s| s.replace("
", "")) + || left.is_component != right.is_component + || left.children.len() != right.children.len() + || left.visible_when != right.visible_when + || left.drawn_when != right.drawn_when + || [ + "as", + "role", + "href", + "type", + "name", + "id", + "aria-label", + "aria-labelledby", + "aria-describedby", + "htmlFor", + "value", + "defaultValue", + "checked", + "disabled", + "tabIndex", + ] + .iter() + .any(|key| left.props.get(*key) != right.props.get(*key)) + { + return false; + } + if left.is_component { + // A shared component name is not evidence that its text/business props + // agree. Placement can vary, but never guess an instance's content. + let content_props = |tree: &Tree| { + tree.props + .iter() + .filter(|(name, _)| !is_placement_prop(name)) + .map(|(name, value)| (name.clone(), value.clone())) + .collect::>() + }; + if content_props(left) != content_props(right) { + return false; + } + } + left.children + .iter() + .zip(&right.children) + .all(|(left, right)| same_responsive_content(snapshot, left, right)) +} + +fn structural_wrapper_reason(tree: &Tree, snapshot: &Snapshot) -> Option<&'static str> { + if tree.is_component + || tree.content.is_some() + || !LAYOUT_ELEMENTS.contains(&tree.component.as_str()) + || tree.visible_when.is_some() + || !tree.drawn_when.is_empty() + { + return Some("component, text, conditional or semantic wrapper"); + } + // Whitelisting box-layout properties avoids dropping background, padding, + // borders, clipping, semantics, selectors or inherited typography/colour. + const BOX_LAYOUT: &[&str] = &[ + "display", + "flexDir", + "flexWrap", + "justifyContent", + "alignItems", + "alignContent", + "alignSelf", + "gap", + "rowGap", + "columnGap", + "flex", + "flexGrow", + "flexShrink", + "flexBasis", + "w", + "h", + "minW", + "maxW", + "minH", + "maxH", + "boxSize", + "gridTemplateColumns", + "gridTemplateRows", + "gridAutoFlow", + "gridColumn", + "gridRow", + ]; + if tree + .props + .keys() + .any(|name| !BOX_LAYOUT.contains(&name.as_str())) + { + return Some("wrapper has paint, padding, clipping, semantics or inherited styles"); + } + if tree + .props + .get("display") + .is_some_and(|value| value == "none") + { + return Some("wrapper is hidden at a captured width"); + } + let Some(node) = snapshot.nodes.get(&tree.node_id) else { + return Some("wrapper source is not captured"); + }; + let view = node.typed_view(); + // The per-width projection can leave an unsupported or incompletely read + // source property unrepresented. Its absence from props is not permission + // to erase the corresponding source box. + const BOX_EFFECTS: &[&str] = &[ + "fills", + "strokes", + "effects", + "clipsContent", + "opacity", + "rotation", + "paddingLeft", + "paddingRight", + "paddingTop", + "paddingBottom", + "cornerRadius", + ]; + if BOX_EFFECTS + .iter() + .any(|field| node.field_errors.contains_key(*field)) + || ["fills", "strokes", "effects"].iter().any(|field| { + view.value(field) + .and_then(Value::as_array) + .is_some_and(|values| !values.is_empty()) + }) + || view.bool("clipsContent") == Some(true) + || view.number("opacity").is_some_and(|value| value != 1.0) + || [ + "rotation", + "paddingLeft", + "paddingRight", + "paddingTop", + "paddingBottom", + "cornerRadius", + ] + .iter() + .any(|field| view.number(field).is_some_and(|value| value != 0.0)) + || view.string("layoutPositioning") == Some("ABSOLUTE") + { + return Some("captured wrapper has paint, padding, clipping or box effects"); + } + if !matches!( + node.typed_view().string("layoutMode"), + Some("HORIZONTAL" | "VERTICAL") + ) || node.field_errors.contains_key("layoutMode") + || node.field_errors.contains_key("childrenIds") + { + return Some("wrapper layout or child source is incomplete"); + } + None +} + +fn matching_direct_children( + snapshot: &Snapshot, + wrapper: &Tree, + parent: &Tree, +) -> Option> { + let indices = wrapper + .children + .iter() + .map(|child| { + let matches = parent + .children + .iter() + .enumerate() + .filter(|(_, other)| same_responsive_content(snapshot, child, other)) + .map(|(index, _)| index) + .collect::>(); + (matches.len() == 1).then(|| matches[0]) + }) + .collect::>>()?; + let unique = indices.iter().copied().collect::>(); + (unique.len() == indices.len()).then_some(indices) +} + +fn compatible_outer_order( + snapshot: &Snapshot, + source: &Tree, + wrapper: &Tree, + target: &Tree, + indices: &[usize], +) -> bool { + let mut previous = None; + for child in &source.children { + let index = if child.node_id == wrapper.node_id { + Some(indices[0]) + } else { + let matches = target + .children + .iter() + .enumerate() + .filter(|(index, other)| { + !indices.contains(index) && same_responsive_content(snapshot, child, other) + }) + .map(|(index, _)| index) + .collect::>(); + if matches.len() > 1 { + return false; + } + matches.first().copied() + }; + if let Some(index) = index { + if previous.is_some_and(|previous| previous >= index) { + return false; + } + previous = Some(index); + } + } + true +} + +fn topology_review(context: &mut MergeContext<'_>, wrapper: &Tree, parent: &Tree, reason: &str) { + if context.diagnostics.iter().any(|d| { + d.code == "DEVUP_CODEGEN_RESPONSIVE_TOPOLOGY_UNREPRESENTED" + && d.node_id.as_deref() == Some(wrapper.node_id.as_str()) + && d.details + .as_ref() + .is_some_and(|details| details["otherParentNodeId"] == parent.node_id) + }) { + return; + } + context.notes.push(Unrepresented { + node_id: wrapper.node_id.clone(), + detail: format!("Identical content has different wrapper topology near {} and {}. Responsive branches remain because {reason}; resolve this structure before claiming one responsive content tree.", wrapper.node_id, parent.node_id), + }); + context.diagnostics.push(devup_mcp_figma::Diagnostic { + code: "DEVUP_CODEGEN_RESPONSIVE_TOPOLOGY_UNREPRESENTED".into(), + node_id: Some(wrapper.node_id.clone()), + message: "Common responsive content crosses a wrapper boundary that could not safely be reconciled.".into(), + severity: Some(devup_mcp_figma::DiagnosticSeverity::Warning), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Approximated), + details: Some(json!({"otherParentNodeId":parent.node_id,"reason":reason, + "output":"responsiveTsx","duplicatesContent":true, + "nextAction":"Review the shared wrapper and carry every width's layout, inherited style and semantics into one responsive tree; do not flatten a painted, padded or meaningful wrapper."})), + ..Default::default() + }); +} + +fn reconcile_child_wrappers( + by_slot: &BySlot, + context: &mut MergeContext<'_>, +) -> Option> { + // Bounds keep unusual generated documents from turning matching into an + // unbounded subtree search. Deep wrappers are considered at their own merge. + if by_slot + .iter() + .flatten() + .any(|parent| parent.children.len() > 128) + { + return None; + } + let templates = by_slot + .iter() + .enumerate() + .filter_map(|(slot, parent)| { + Some( + parent + .as_ref()? + .children + .iter() + .filter(|child| { + !child.is_component + && child.content.is_none() + && (2..=16).contains(&child.children.len()) + && LAYOUT_ELEMENTS.contains(&child.component.as_str()) + && contents_wrapper_source(&child.node_id).is_none() + }) + .cloned() + .map(move |child| (slot, child)), + ) + }) + .flatten() + .collect::>(); + let mut output = by_slot.clone(); + let mut changed = false; + for (source_slot, wrapper) in templates { + for (slot, captured_parent) in output.iter_mut().enumerate() { + if slot == source_slot { + continue; + } + let Some(parent) = captured_parent.as_ref() else { + continue; + }; + // An existing exact wrapper can be aligned normally. Never wrap a + // second copy around it just because a descendant happens to match. + if parent + .children + .iter() + .any(|other| same_responsive_content(context.snapshot, &wrapper, other)) + { + continue; + } + let Some(indices) = matching_direct_children(context.snapshot, &wrapper, parent) else { + let shared = wrapper.children.iter().any(|child| { + parent + .children + .iter() + .any(|other| same_responsive_content(context.snapshot, child, other)) + }); + if shared { + topology_review( + context, + &wrapper, + parent, + "child correspondence is partial or ambiguous", + ); + } + continue; + }; + let reason = structural_wrapper_reason(&wrapper, context.snapshot) + .or_else(|| { + (!compatible_outer_order(context.snapshot, by_slot[source_slot].as_ref().expect("captured source"), &wrapper, parent, &indices)) + .then_some("outer sibling order differs; CSS order on a contents wrapper would not order its children") + }) + .or_else(|| { + parent.props.keys().any(|name| name == "as" || name == "role" + || name == "selectors" || name.starts_with('_')) + .then_some("the parent has semantics or selectors whose child structure must be preserved") + }) + .or_else(|| { + (!indices.windows(2).all(|pair| pair[1] == pair[0] + 1)) + .then_some("common children are reordered or are not contiguous siblings") + }) + .or_else(|| { + parent + .children + .iter() + .filter(|child| { + indices + .iter() + .any(|index| parent.children[*index].node_id == child.node_id) + }) + .any(|child| { + child.props.contains_key("order") + || child + .props + .get("pos") + .is_some_and(|value| value == "absolute") + }) + .then_some("common children already have order or absolute positioning") + }); + if let Some(reason) = reason { + topology_review(context, &wrapper, parent, reason); + continue; + } + let start = indices[0]; + let end = start + indices.len(); + let mut contents = wrapper.clone(); + contents.node_id = format!( + "{CONTENTS_WRAPPER_PREFIX}{}", + json!([parent.node_id, wrapper.node_id]) + ); + contents.source_node_ids.clear(); + contents.props = BTreeMap::from([("display".into(), "contents".into())]); + contents.children = parent.children[start..end].to_vec(); + contents.slots.clear(); + contents.scalar_sources.clear(); + contents.leading_comment = None; + context.diagnostics.push(devup_mcp_figma::Diagnostic { + code: "DEVUP_CODEGEN_RESPONSIVE_WRAPPER_RECONCILED".into(), + node_id: Some(wrapper.node_id.clone()), + message: "Common responsive children share one tree; a derived display:contents wrapper preserves their direct-parent layout at this width.".into(), + severity: Some(devup_mcp_figma::DiagnosticSeverity::Info), + details: Some(json!({"slot":slot,"width":context.slot_widths[slot],"sourceParentNodeId":parent.node_id, + "sourceChildren":contents.children.iter().map(|child| &child.node_id).collect::>(), + "sourceWrapperNodeId":wrapper.node_id,"duplicatesContent":false,"runtimeVerified":false, + "nextAction":"Check layout, responsive ordering, focus and reading order in the browser at every captured width."})), + ..Default::default() + }); + captured_parent + .as_mut() + .expect("captured parent") + .children + .splice(start..end, [contents]); + changed = true; + } + } + changed.then_some(output) +} + +fn derived_wrapper_provenance(tree: &Tree) -> Vec { + tree.slots.iter().enumerate().filter_map(|(slot, source)| { + let (id, _) = source.as_ref()?; + let [parent, wrapper] = contents_wrapper_source(id)?; + Some(json!({"slot":slot,"sourceNodeId":parent,"sourceField":"childrenIds", + "capturedWrapperNodeId":wrapper,"calculation":"A derived display:contents wrapper groups the same contiguous children without adding a layout box at this width", + "runtimeVerified":false})) + }).collect() +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/semantics.rs b/crates/devup-mcp-devup-ui/src/codegen/semantics.rs new file mode 100644 index 00000000..ee961087 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/semantics.rs @@ -0,0 +1,121 @@ +//! Non-visual HTML semantics backed by explicit layer/component names. +//! +//! A canvas or a large font is not evidence of a main region or a heading. +//! Unnamed content still needs the consumer's accessibility review. + +use devup_mcp_figma::{RawNode, Snapshot}; + +use super::{component::Prop, layout::string_prop}; + +pub(super) fn scalar_html_value(value: &str) -> String { + format!( + "=\"{}\"", + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") + ) +} + +fn semantic_name<'a>(snapshot: &'a Snapshot, node: &'a RawNode) -> &'a str { + let view = node.typed_view(); + // Variant roots are commonly called `Viewport=desktop`, while the set + // holds the meaningful component name (Header, Footer, ...). + if node.node_type == "COMPONENT" + && let Some(parent) = view + .string("parentId") + .and_then(|id| snapshot.nodes.get(id)) + && parent.node_type == "COMPONENT_SET" + { + return parent.typed_view().name().unwrap_or_default(); + } + view.name().unwrap_or_default() +} + +fn key(name: &str) -> String { + name.split('/') + .next() + .unwrap_or_default() + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +pub(super) fn push_semantic_props( + snapshot: &Snapshot, + node: &RawNode, + component: &str, + props: &mut Vec, +) { + let name = semantic_name(snapshot, node); + if component == "Image" { + // Background paint layers already use alt="". For an ordinary asset, + // preserve the designer's descriptive layer name; + // generated shape/icon names would only add screen-reader noise. + let view = node.typed_view(); + let layer = view.name().unwrap_or_default().trim(); + let generic = [ + "rectangle", + "ellipse", + "vector", + "polygon", + "star", + "line", + "union", + "subtract", + "intersect", + "exclude", + "image", + "icon", + ] + .iter() + .any(|prefix| { + let lower = layer.to_ascii_lowercase(); + lower == *prefix + || lower.strip_prefix(prefix).is_some_and(|suffix| { + suffix.starts_with([' ', '-', '_', '/']) + && (*prefix == "icon" + || suffix.chars().all(|character| { + character.is_ascii_digit() + || matches!(character, ' ' | '-' | '_' | '/') + })) + }) + }); + if !props.iter().any(|(name, _)| name == "alt") { + string_prop(props, "alt", if generic { "" } else { layer }); + } + return; + } + if props.iter().any(|(name, _)| name == "as") { + return; + } + let tag = if component == "Text" { + match key(name).as_str() { + "h1" | "heading1" | "pageheading" | "pagetitle" => Some("h1"), + "h2" | "heading2" => Some("h2"), + "h3" | "heading3" => Some("h3"), + "h4" | "heading4" => Some("h4"), + "h5" | "heading5" => Some("h5"), + "h6" | "heading6" => Some("h6"), + _ => None, + } + } else if matches!( + node.node_type.as_str(), + "FRAME" | "COMPONENT" | "INSTANCE" | "GROUP" + ) { + match key(name).as_str() { + "header" | "siteheader" | "globalheader" => Some("header"), + "footer" | "sitefooter" | "globalfooter" => Some("footer"), + "nav" | "navigation" | "gnb" | "globalnavigation" | "primarynavigation" => Some("nav"), + "maincontent" | "pagecontent" => Some("main"), + _ => None, + } + } else { + None + }; + if let Some(tag) = tag { + string_prop(props, "as", tag); + } +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index ab58d29d..0edd1452 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -2,4 +2,6 @@ include!("style/style_properties.rs"); include!("style/asset_kind.rs"); include!("style/image_fill_source.rs"); +include!("style/stroke_layout.rs"); +include!("style/stroke_paint.rs"); include!("style/push_effects.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs b/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs index a4511bb4..4bce2fda 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs @@ -1,5 +1,3 @@ - - /// The file an image fill refers to. /// /// Every image fill once resolved to a single hard-coded `/icons/image.png`, @@ -94,16 +92,24 @@ pub fn image_fill_path( /// The file name an asset node gets, without folder or extension: readable /// kebab-case; distinct things sharing a name get -2, -3 in node-id order. pub(crate) fn asset_stem(snapshot: &Snapshot, node: &RawNode, per_node: bool) -> String { - let canonical = image_hash(node).and_then(|hash| { - snapshot.nodes.values().filter(|other| image_hash(other) == Some(hash)).min_by_key(|other| &other.id) - }).unwrap_or(node); + let canonical = image_hash(node) + .and_then(|hash| { + snapshot + .nodes + .values() + .filter(|other| image_hash(other) == Some(hash)) + .min_by_key(|other| &other.id) + }) + .unwrap_or(node); let base = readable_stem(snapshot, canonical); let own = asset_group_key(snapshot, node, per_node); let mut firsts = std::collections::BTreeMap::::new(); for other in snapshot.nodes.values() { let is_asset = asset_kind(snapshot, other).is_some() || image_hash(other).is_some(); let same_image = image_hash(node).is_some_and(|hash| image_hash(other) == Some(hash)); - if other.id != node.id && (!is_asset || (!same_image && readable_stem(snapshot, other) != base)) { + if other.id != node.id + && (!is_asset || (!same_image && readable_stem(snapshot, other) != base)) + { continue; } let first = firsts @@ -137,6 +143,9 @@ fn kebab_name(name: &str) -> String { } fn generic_role(role: &str) -> bool { + if role.len() >= 24 && role.chars().all(|c| c.is_ascii_hexdigit()) { + return true; + } let word = role.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-'); word.is_empty() || matches!( @@ -166,7 +175,11 @@ fn readable_stem(snapshot: &Snapshot, node: &RawNode) -> String { } current = parent.typed_view().string("parentId").map(str::to_owned); } - if role.is_empty() { "asset".to_owned() } else { role } + if role.is_empty() { + "asset".to_owned() + } else { + role + } } fn image_hash(node: &RawNode) -> Option<&str> { @@ -188,8 +201,15 @@ fn asset_group_key(snapshot: &Snapshot, node: &RawNode, per_node: bool) -> Strin if per_node || raster_instance { return format!("node:{}", node.id); } - if let Some(properties) = node.typed_view().value("variantProperties").and_then(Value::as_object) { - return format!("variant:{}", serde_json::to_string(properties).unwrap_or_default()); + if let Some(properties) = node + .typed_view() + .value("variantProperties") + .and_then(Value::as_object) + { + return format!( + "variant:{}", + serde_json::to_string(properties).unwrap_or_default() + ); } "plain".to_owned() } @@ -261,7 +281,10 @@ pub(super) fn paint_css( "PATTERN" => { let source_id = paint.get("sourceNodeId").and_then(Value::as_str)?; let source = snapshot.nodes.get(source_id); - let name = source.map_or_else(|| "pattern".to_owned(), |node| asset_stem(snapshot, node, per_node)); + let name = source.map_or_else( + || "pattern".to_owned(), + |node| asset_stem(snapshot, node, per_node), + ); // A raster belongs with the images and a vector with the icons, // which is the split every other asset reference follows. This one // sent a png to the icon folder. @@ -810,182 +833,3 @@ fn push_radius(view: &TypedNode<'_>, props: &mut Vec) { string_prop(props, "borderRadius", value); } } - -/// An inside stroke cannot consume layout space when the padding is too small -/// to absorb a CSS border. Paint it inward with an outline instead. -pub(super) fn inside_stroke_uses_outline(view: &TypedNode<'_>) -> bool { - view.string("strokeAlign").unwrap_or("INSIDE") == "INSIDE" - && matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) - && view.number("strokeWeight").is_some_and(|weight| { - weight > 0.0 - && ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] - .iter() - .any(|field| view.number(field).is_some_and(|padding| padding < weight)) - }) -} - -/// A single straight outside edge is a translated, zero-blur box shadow. -/// Unlike a CSS border it does not reduce a fixed content box or grow a HUG -/// box. Rounded corners, dashes and multi-edge joins need a different paint -/// construction, so retain their existing emission rather than approximate it. -pub(crate) fn single_outside_stroke(view: &TypedNode<'_>) -> Option<(usize, f64)> { - if view.string("strokeAlign") != Some("OUTSIDE") - || !matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) - || view.number("strokeWeight").is_some() - || [ - "cornerRadius", - "topLeftRadius", - "topRightRadius", - "bottomRightRadius", - "bottomLeftRadius", - ] - .iter() - .any(|field| view.number(field).is_some_and(|radius| radius != 0.0)) - || view - .value("dashPattern") - .and_then(Value::as_array) - .is_some_and(|dash| !dash.is_empty()) - || !view - .value("strokes") - .and_then(Value::as_array) - .is_some_and(|paints| { - // Match push_strokes' selected paint, not a later paint that - // it will never reach after rejecting an unpaintable first one. - paints - .iter() - .find(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("type").and_then(Value::as_str) == Some("SOLID") - }) - .is_some_and(|paint| color_from_paint(paint).is_some()) - }) - { - return None; - } - let mut edge = None; - for (index, field) in [ - "strokeTopWeight", - "strokeRightWeight", - "strokeBottomWeight", - "strokeLeftWeight", - ] - .iter() - .enumerate() - { - let weight = view.number(field)?; - if !weight.is_finite() || weight < 0.0 { - return None; - } - if weight > 0.0 { - if edge.is_some() { - return None; - } - edge = Some((index, weight)); - } - } - edge -} - -fn push_strokes( - view: &TypedNode<'_>, - outside_edge: Option<(usize, f64)>, - props: &mut Vec, - used_tokens: &mut BTreeSet, - variable_tokens: &std::collections::BTreeMap, -) { - let Some(paint) = view - .value("strokes") - .and_then(Value::as_array) - .and_then(|strokes| { - strokes.iter().find(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("type").and_then(Value::as_str) == Some("SOLID") - }) - }) - else { - return; - }; - let color = if let Some(token) = bound_paint_token(paint, variable_tokens) { - used_tokens.insert(token.clone()); - format!("${token}") - } else if let Some(color) = color_from_paint(paint) { - color - } else { - return; - }; - let style = if view - .value("dashPattern") - .and_then(Value::as_array) - .is_some_and(|value| !value.is_empty()) - { - "dashed" - } else { - "solid" - }; - let align = view.string("strokeAlign").unwrap_or("INSIDE"); - let explicit_weight = view.number("strokeWeight"); - if let Some((edge, weight)) = outside_edge { - let (x, y) = [(0.0, -weight), (weight, 0.0), (0.0, weight), (-weight, 0.0)][edge]; - string_prop( - props, - "boxShadow", - format!("{} {} 0 0 {color}", zero_or_px(x), zero_or_px(y)), - ); - return; - } - if explicit_weight.is_none() { - let sides = [ - ("strokeTopWeight", "borderTop"), - ("strokeRightWeight", "borderRight"), - ("strokeBottomWeight", "borderBottom"), - ("strokeLeftWeight", "borderLeft"), - ]; - if sides.iter().all(|(field, _)| view.number(field).is_some()) { - for (field, prop) in sides { - let weight = view.number(field).unwrap_or(0.0); - if weight != 0.0 { - string_prop(props, prop, format!("{style} {} {color}", px(weight))); - } - } - } - return; - } - let weight = explicit_weight.unwrap_or(0.0); - if view.node_type() == "LINE" { - string_prop(props, "outline", format!("{style} {} {color}", px(weight))); - let base = if view.string("layoutSizingHorizontal") == Some("FIXED") { - view.number("width") - .map(px) - .unwrap_or_else(|| "100%".to_owned()) - } else { - "100%".to_owned() - }; - string_prop( - props, - "maxW", - format!("calc({base} - {})", px(weight * 2.0)), - ); - if view - .number("rotation") - .is_none_or(|rotation| rotation.abs() <= 0.01) - { - string_prop( - props, - "transform", - format!("translate({}, {})", px(weight), px(-weight)), - ); - } - return; - } - if inside_stroke_uses_outline(view) { - string_prop(props, "outline", format!("{style} {} {color}", px(weight))); - string_prop(props, "outlineOffset", px(-weight)); - } else if align == "INSIDE" { - string_prop(props, "border", format!("{style} {} {color}", px(weight))); - } else { - string_prop(props, "outline", format!("{style} {} {color}", px(weight))); - if align == "CENTER" { - string_prop(props, "outlineOffset", px(-weight / 2.0)); - } - } -} diff --git a/crates/devup-mcp-devup-ui/src/codegen/style/stroke_layout.rs b/crates/devup-mcp-devup-ui/src/codegen/style/stroke_layout.rs new file mode 100644 index 00000000..0532331e --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/style/stroke_layout.rs @@ -0,0 +1,131 @@ +/// The captured stroke-layout contract; direct node state takes precedence +/// over inferred layout. Missing data keeps the established legacy behavior. +pub(super) fn stroke_layout_is_auto(view: &TypedNode<'_>) -> bool { + matches!( + view.string("layoutMode").or_else(|| view + .value("inferredAutoLayout")? + .get("layoutMode")? + .as_str()), + Some("HORIZONTAL" | "VERTICAL" | "GRID") + ) +} + +pub(super) fn strokes_included_in_layout(view: &TypedNode<'_>) -> bool { + // An explicit NONE is authoritative: a stale flag cannot add border + // layout space to manually positioned children, even if inference exists. + if !stroke_layout_is_auto(view) { + return false; + } + view.bool("strokesIncludedInLayout") + .or_else(|| { + view.value("inferredAutoLayout")? + .get("strokesIncludedInLayout")? + .as_bool() + }) + .unwrap_or(false) +} + +/// A stroke excluded from layout cannot consume space when padding is too +/// small to absorb a CSS border. Paint it inward with an outline instead. +pub(super) fn inside_stroke_uses_outline(view: &TypedNode<'_>) -> bool { + !strokes_included_in_layout(view) + && view.string("strokeAlign").unwrap_or("INSIDE") == "INSIDE" + && matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) + && view.number("strokeWeight").is_some_and(|weight| { + weight > 0.0 + && ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] + .iter() + .any(|field| view.number(field).is_some_and(|padding| padding < weight)) + }) +} + +/// Mixed per-edge inside strokes excluded from Figma layout use the same +/// border-box conversion as uniform strokes. This only applies when captured +/// padding can absorb every emitted SOLID border without clamping. +pub(super) fn mixed_excluded_stroke_padding( + view: &TypedNode<'_>, + padding: [f64; 4], +) -> Option<[f64; 4]> { + let explicit_flag = view.bool("strokesIncludedInLayout").or_else(|| { + view.value("inferredAutoLayout")? + .get("strokesIncludedInLayout")? + .as_bool() + }); + if explicit_flag != Some(false) + || !stroke_layout_is_auto(view) + || view.number("strokeWeight").is_some() + || view + .value("strokeAlign") + .is_some_and(|value| value.as_str() != Some("INSIDE")) + || view + .value("dashPattern") + .is_some_and(|value| !value.as_array().is_some_and(Vec::is_empty)) + { + return None; + } + let weight_fields = [ + "strokeTopWeight", + "strokeRightWeight", + "strokeBottomWeight", + "strokeLeftWeight", + ]; + let padding_fields = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]; + if [ + "strokesIncludedInLayout", + "inferredAutoLayout", + "strokeAlign", + "strokeWeight", + "strokes", + "dashPattern", + ] + .iter() + .chain(weight_fields.iter()) + .chain(padding_fields.iter()) + .any(|field| view.field_error(field).is_some()) + { + return None; + } + let paint = view + .value("strokes")? + .as_array()? + .iter() + .find(|paint| paint["visible"] != false && paint["type"] == "SOLID")?; + color_from_paint(paint)?; // Match the representable paint selected by push_strokes. + let mut result = padding; + let mut painted = false; + for index in 0..4 { + let weight = view.number(weight_fields[index])?; + let captured = view + .value("inferredAutoLayout") + .and_then(|v| v.get(padding_fields[index])) + .and_then(Value::as_f64) + .or_else(|| view.number(padding_fields[index]))?; + if !weight.is_finite() + || weight < 0.0 + || !captured.is_finite() + || (captured - padding[index]).abs() > 0.001 + || captured < weight + { + return None; + } + painted |= weight > 0.0; + result[index] -= weight; + } + painted.then_some(result) +} + +pub(super) fn mixed_excluded_stroke_source_padding(view: &TypedNode<'_>) -> Option<[f64; 4]> { + let get = |field| { + view.value("inferredAutoLayout") + .and_then(|v| v.get(field)) + .and_then(Value::as_f64) + .or_else(|| view.number(field)) + }; + let padding = [ + get("paddingTop")?, + get("paddingRight")?, + get("paddingBottom")?, + get("paddingLeft")?, + ]; + mixed_excluded_stroke_padding(view, padding) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/style/stroke_paint.rs b/crates/devup-mcp-devup-ui/src/codegen/style/stroke_paint.rs new file mode 100644 index 00000000..bd0affcf --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/style/stroke_paint.rs @@ -0,0 +1,221 @@ +/// A single straight outside edge is a translated, zero-blur box shadow. +/// Unlike a CSS border it does not reduce a fixed content box or grow a HUG +/// box. Rounded corners, dashes and multi-edge joins need a different paint +/// construction, so retain their existing emission rather than approximate it. +pub(crate) fn single_outside_stroke(view: &TypedNode<'_>) -> Option<(usize, f64)> { + if view.string("strokeAlign") != Some("OUTSIDE") + || !matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) + || view.number("strokeWeight").is_some() + || [ + "cornerRadius", + "topLeftRadius", + "topRightRadius", + "bottomRightRadius", + "bottomLeftRadius", + ] + .iter() + .any(|field| view.number(field).is_some_and(|radius| radius != 0.0)) + || view + .value("dashPattern") + .and_then(Value::as_array) + .is_some_and(|dash| !dash.is_empty()) + || !view + .value("strokes") + .and_then(Value::as_array) + .is_some_and(|paints| { + // Match push_strokes' selected paint, not a later paint that + // it will never reach after rejecting an unpaintable first one. + paints + .iter() + .find(|paint| { + paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("type").and_then(Value::as_str) == Some("SOLID") + }) + .is_some_and(|paint| color_from_paint(paint).is_some()) + }) + { + return None; + } + let mut edge = None; + for (index, field) in [ + "strokeTopWeight", + "strokeRightWeight", + "strokeBottomWeight", + "strokeLeftWeight", + ] + .iter() + .enumerate() + { + let weight = view.number(field)?; + if !weight.is_finite() || weight < 0.0 { + return None; + } + if weight > 0.0 { + if edge.is_some() { + return None; + } + edge = Some((index, weight)); + } + } + edge +} + +/// Replay the same stroke writer used by the parent element; outlines and +/// shadows are not native borders and must never shift absolute coordinates. +pub(super) fn emitted_leading_border(node: &RawNode) -> Option<(f64, f64)> { + let view = node.typed_view(); + if [ + "strokes", + "strokeAlign", + "strokeWeight", + "strokeTopWeight", + "strokeRightWeight", + "strokeBottomWeight", + "strokeLeftWeight", + "strokesIncludedInLayout", + ] + .iter() + .any(|field| view.field_error(field).is_some()) + || view + .value("strokeAlign") + .is_some_and(|value| value.as_str().is_none()) + { + return None; + } + let mut props = Vec::new(); + push_strokes( + &view, + single_outside_stroke(&view), + &mut props, + &mut BTreeSet::new(), + &BTreeMap::new(), + ); + let (mut left, mut top) = (0.0, 0.0); + let mut native = false; + for (name, PropValue::String(value)) in props { + if !matches!(name.as_str(), "border" | "borderLeft" | "borderTop") { + continue; + } + let weight = value + .split_whitespace() + .nth(1)? + .strip_suffix("px")? + .parse::() + .ok()?; + if !weight.is_finite() || weight < 0.0 { + return None; + } + if name == "border" || name == "borderLeft" { + left = weight; + } + if name == "border" || name == "borderTop" { + top = weight; + } + native = true; + } + (native && (left > 0.0 || top > 0.0)).then_some((left, top)) +} + +fn push_strokes( + view: &TypedNode<'_>, + outside_edge: Option<(usize, f64)>, + props: &mut Vec, + used_tokens: &mut BTreeSet, + variable_tokens: &std::collections::BTreeMap, +) { + let Some(paint) = view + .value("strokes") + .and_then(Value::as_array) + .and_then(|strokes| { + strokes.iter().find(|paint| { + paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("type").and_then(Value::as_str) == Some("SOLID") + }) + }) + else { + return; + }; + let color = if let Some(token) = bound_paint_token(paint, variable_tokens) { + used_tokens.insert(token.clone()); + format!("${token}") + } else if let Some(color) = color_from_paint(paint) { + color + } else { + return; + }; + let style = if view + .value("dashPattern") + .and_then(Value::as_array) + .is_some_and(|value| !value.is_empty()) + { + "dashed" + } else { + "solid" + }; + let align = view.string("strokeAlign").unwrap_or("INSIDE"); + let explicit_weight = view.number("strokeWeight"); + if let Some((edge, weight)) = outside_edge { + let (x, y) = [(0.0, -weight), (weight, 0.0), (0.0, weight), (-weight, 0.0)][edge]; + string_prop( + props, + "boxShadow", + format!("{} {} 0 0 {color}", zero_or_px(x), zero_or_px(y)), + ); + return; + } + if explicit_weight.is_none() { + let sides = [ + ("strokeTopWeight", "borderTop"), + ("strokeRightWeight", "borderRight"), + ("strokeBottomWeight", "borderBottom"), + ("strokeLeftWeight", "borderLeft"), + ]; + if sides.iter().all(|(field, _)| view.number(field).is_some()) { + for (field, prop) in sides { + let weight = view.number(field).unwrap_or(0.0); + if weight != 0.0 { + string_prop(props, prop, format!("{style} {} {color}", px(weight))); + } + } + } + return; + } + let weight = explicit_weight.unwrap_or(0.0); + if view.node_type() == "LINE" { + string_prop(props, "outline", format!("{style} {} {color}", px(weight))); + let base = if view.string("layoutSizingHorizontal") == Some("FIXED") { + view.number("width") + .map(px) + .unwrap_or_else(|| "100%".to_owned()) + } else { + "100%".to_owned() + }; + string_prop( + props, + "maxW", + format!("calc({base} - {})", px(weight * 2.0)), + ); + if view + .number("rotation") + .is_none_or(|rotation| rotation.abs() <= 0.01) + { + string_prop( + props, + "transform", + format!("translate({}, {})", px(weight), px(-weight)), + ); + } + return; + } + if inside_stroke_uses_outline(view) { + string_prop(props, "outline", format!("{style} {} {color}", px(weight))); + string_prop(props, "outlineOffset", px(-weight)); + } else if align == "INSIDE" { + string_prop(props, "border", format!("{style} {} {color}", px(weight))); + } else { + string_prop(props, "outline", format!("{style} {} {color}", px(weight))); + if align == "CENTER" { + string_prop(props, "outlineOffset", px(-weight / 2.0)); + } + } +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs b/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs index fe056a0f..7c1af2ef 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs @@ -49,6 +49,8 @@ fn asset_kind_nested(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Optio return leaf_asset_kind(snapshot, node, nested); } + if devup_mcp_figma::single_child_layout_must_remain_live(snapshot, node) { return None; } + if child_ids.len() == 1 { if ["paddingLeft", "paddingRight", "paddingTop", "paddingBottom"] .into_iter() diff --git a/crates/devup-mcp-devup-ui/src/codegen/text.rs b/crates/devup-mcp-devup-ui/src/codegen/text.rs index cc265e16..f552f08a 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/text.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/text.rs @@ -1,877 +1,949 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use devup_mcp_figma::TypedNode; -use serde_json::Value; - -use super::{ - component::{Prop, PropValue, render_static_attribute}, - layout::{format_number, px, string_prop}, - style::first_solid_color, -}; - -pub(super) fn push_text_props( - view: &TypedNode<'_>, - text_style_tokens: &BTreeMap, - typography_values: &BTreeMap, - variable_tokens: &BTreeMap, - used_tokens: &mut BTreeSet, - props: &mut Vec, -) { - if view.node_type() != "TEXT" { - return; - } - let segment = default_segment(view); - let typography = segment - .and_then(|value| value.get("textStyleId")) - .or_else(|| view.value("textStyleId")) - .and_then(Value::as_str) - .and_then(|id| text_style_tokens.get(id)); - let value = |field: &str| { - view.value(field) - .filter(|value| is_resolved_value(value)) - .or_else(|| segment.and_then(|value| value.get(field))) - }; - if let Some(color) = bound_segment_color(value("fills"), variable_tokens) - .or_else(|| first_solid_color(value("fills"))) - { - record_used_color(&color, used_tokens); - replace_prop(props, "color", color); - } - if let Some(typography) = typography { - string_prop(props, "typography", typography); - } else if let Some(family) = value("fontName") - .and_then(Value::as_object) - .and_then(|font| font.get("family")) - .and_then(Value::as_str) - { - string_prop(props, "fontFamily", family); - } - if typography.is_none() - && value("fontName") - .and_then(Value::as_object) - .and_then(|font| font.get("style")) - .and_then(Value::as_str) - .is_some_and(|style| style.contains("Italic")) - { - string_prop(props, "fontStyle", "italic"); - } - if let Some(font_size) = value("fontSize").and_then(Value::as_f64) { - string_prop(props, "fontSize", px(font_size)); - } - // A shared text style can have local weight overrides (including mixed - // regular/bold runs). The token name does not carry those resolved values. - if let Some(weight) = value("fontWeight").and_then(Value::as_f64) { - string_prop(props, "fontWeight", format_number(weight)); - } - if let Some(letter_spacing) = letter_spacing(value("letterSpacing")) { - string_prop(props, "letterSpacing", letter_spacing); - } - if let Some(line_height) = line_height( - value("lineHeight"), - line_height_font_size(value("fontSize"), value("boundVariables")), - ) { - string_prop(props, "lineHeight", line_height); - } - if typography.is_none() { - match value("textDecoration").and_then(Value::as_str) { - Some("UNDERLINE") => string_prop(props, "textDecoration", "underline"), - Some("STRIKETHROUGH") => string_prop(props, "textDecoration", "line-through"), - _ => {} - } - if let Some(case) = value("textCase").and_then(Value::as_str) - && case != "ORIGINAL" - { - string_prop(props, "textTransform", case.to_ascii_lowercase()); - } - } - if let Some(max_lines) = view.number("maxLines") { - if max_lines == 1.0 { - string_prop(props, "whiteSpace", "nowrap"); - } else if max_lines > 1.0 { - string_prop(props, "WebkitBoxOrient", "vertical"); - string_prop(props, "WebkitLineClamp", format_number(max_lines)); - string_prop(props, "display", "-webkit-box"); - } - } - if preserves_hard_break_spaces(view) { - string_prop(props, "whiteSpace", "pre-wrap"); - } - // Reads the designer's own truncation setting, which Figma always - // reports — provided it is collected. It was missing from the field - // manifest, so this saw nothing and every text claimed an ellipsis the - // design never asked for. - if view.string("textTruncation") != Some("DISABLED") - && view.string("layoutSizingHorizontal") != Some("HUG") - { - string_prop(props, "overflow", "hidden"); - string_prop(props, "textOverflow", "ellipsis"); - } - let horizontal_hug = view - .string("textAutoResize") - .is_some_and(|value| value.contains("WIDTH")); - let single_line = !view.string("characters").unwrap_or_default().contains('\n'); - if !(horizontal_hug && single_line) - && let Some(alignment) = view.string("textAlignHorizontal") - { - let value = match alignment { - "RIGHT" => Some("right"), - "CENTER" => Some("center"), - "JUSTIFIED" => Some("justify"), - _ => None, - }; - if let Some(value) = value { - string_prop(props, "textAlign", value); - } - } - let vertical_hug = view - .string("textAutoResize") - .is_some_and(|value| value.contains("HEIGHT")); - if !vertical_hug { - match view.string("textAlignVertical") { - Some("CENTER") => string_prop(props, "alignContent", "center"), - Some("BOTTOM") => string_prop(props, "alignContent", "end"), - _ => {} - } - } - if segments_contain_korean(view) { - string_prop(props, "wordBreak", "keep-all"); - } - if let Some(strokes) = view.value("strokes") - && let Some(color) = first_solid_color(Some(strokes)) - { - string_prop( - props, - "WebkitTextStroke", - format!("{} {color}", px(view.number("strokeWeight").unwrap_or(1.0))), - ); - string_prop(props, "paintOrder", "stroke fill"); - } - if let Some(value) = typography.and_then(|token| typography_values.get(token)) { - elide_typography_duplicates(props, value); - } - if let Some(list) = segment - .and_then(|value| value.get("listOptions")) - .and_then(Value::as_object) - .and_then(|value| value.get("type")) - .and_then(Value::as_str) - { - match list { - "UNORDERED" => string_prop(props, "as", "ul"), - "ORDERED" => string_prop(props, "as", "ol"), - _ => return, - } - string_prop(props, "my", "0px"); - string_prop(props, "pl", "1.5em"); - } -} - -fn elide_typography_duplicates(props: &mut Vec, typography: &Value) { - elide_metrics(props, typography, &BTreeMap::new()); -} - -/// The metrics a `typography` token already sets. -/// -/// A text with a text style applied reports the style's own metrics as they -/// resolve in the mode of the frame it is drawn in: a desktop frame says -/// `40px` for an `h2` that is `28px` on mobile. Figma detaches the style -/// when one of its metrics is edited, so a value the token defines at any -/// width is the token speaking, not an override. Writing it beside -/// `typography` pins every width to the drawn one and breaks the token's -/// responsive values; it is left out. A value the token defines at no width -/// is a real override and stays. A responsive value (a length token) stays -/// only where it differs from the token width by width. -fn elide_metrics( - props: &mut Vec, - typography: &Value, - lengths: &BTreeMap, -) { - const METRICS: [&str; 4] = ["fontSize", "fontWeight", "lineHeight", "letterSpacing"]; - let Some(slots) = typography_slots(typography) else { - return; - }; - props.retain(|(name, PropValue::String(value))| { - if !METRICS.contains(&name.as_str()) { - return true; - } - let expected = (0..SERIES) - .map(|slot| { - slots[slot] - .and_then(|slot_values| slot_values.get(name)) - .map(|value| series(&resolve_length(value, lengths))[slot].clone()) - .unwrap_or(Value::Null) - }) - .collect::>(); - if expected.iter().all(Value::is_null) { - return true; - } - let written = resolve_length(&Value::String(value.clone()), lengths); - let elided = if written.is_array() { - series(&written) - .iter() - .zip(&expected) - .all(|(written, expected)| same_metric(written, expected)) - } else { - expected - .iter() - .any(|expected| same_metric(&written, expected)) - }; - !elided - }); -} - -const SERIES: usize = 5; - -/// A token's slot objects, the last one carried forward over `null`. -fn typography_slots( - typography: &Value, -) -> Option<[Option<&serde_json::Map>; SERIES]> { - match typography { - Value::Object(value) => Some([Some(value); SERIES]), - Value::Array(slots) if slots.first().is_some_and(Value::is_object) => { - let mut carried = None; - let mut out = [None; SERIES]; - for (slot, entry) in out.iter_mut().enumerate() { - match slots.get(slot) { - Some(Value::Object(value)) => carried = Some(value), - Some(Value::Null) | None => {} - Some(_) => return None, - } - *entry = carried; - } - Some(out) - } - _ => None, - } -} - -/// A value as each slot reads it: a scalar everywhere, an array carried -/// forward over `null`. -fn series(value: &Value) -> Vec { - match value { - Value::Array(values) => { - let mut carried = Value::Null; - (0..SERIES) - .map(|slot| { - if let Some(value) = values.get(slot).filter(|value| !value.is_null()) { - carried = value.clone(); - } - carried.clone() - }) - .collect() - } - value => vec![value.clone(); SERIES], - } -} - -fn resolve_length( - value: &Value, - lengths: &BTreeMap, -) -> Value { - match value { - Value::String(name) if name.starts_with('$') => lengths - .values() - .find(|token| token.token == name[1..]) - .map_or_else(|| value.clone(), |token| token.value.clone()), - _ => value.clone(), - } -} - -fn same_metric(left: &Value, right: &Value) -> bool { - fn text(value: &Value) -> Option { - match value { - Value::String(value) => Some(value.clone()), - Value::Number(value) => Some(value.to_string()), - _ => None, - } - } - let (Some(left), Some(right)) = (text(left), text(right)) else { - return false; - }; - left == right || matches!((left.parse::(), right.parse::()), (Ok(l), Ok(r)) if l == r) -} - -pub(super) fn elide_tokenized_typography( - props: &mut Vec, - typography_values: &BTreeMap, - lengths: &BTreeMap, -) { - let Some(typography) = props.iter().find_map(|(name, PropValue::String(value))| { - (name == "typography") - .then(|| typography_values.get(value)) - .flatten() - }) else { - return; - }; - let typography = typography.clone(); - elide_metrics(props, &typography, lengths); -} - -fn is_resolved_value(value: &Value) -> bool { - !value.as_object().is_some_and(|object| { - ["$unsupported", "$undefined", "$truncated", "$error"] - .iter() - .any(|marker| object.contains_key(*marker)) - }) -} - -fn default_segment<'a>(view: &'a TypedNode<'a>) -> Option<&'a Value> { - let segments = view.value("styledTextSegments")?.as_array()?; - let mut selected = segments.first()?; - let mut longest = selected - .get("characters") - .and_then(Value::as_str) - .map(str::len) - .unwrap_or_default(); - for segment in segments.iter().skip(1) { - let length = segment - .get("characters") - .and_then(Value::as_str) - .map(str::len) - .unwrap_or_default(); - if length >= longest { - selected = segment; - longest = length; - } - } - Some(selected) -} - -fn letter_spacing(value: Option<&Value>) -> Option { - let value = value?; - if let Some(number) = value.as_f64() { - return Some(px(number)); - } - let object = value.as_object()?; - let number = object.get("value").and_then(Value::as_f64).unwrap_or(0.0); - match object.get("unit").and_then(Value::as_str) { - Some("PERCENT") => Some(format!("{}em", format_number(number.round() / 100.0))), - _ => Some(px(number)), - } -} - -/// 크기가 변수에 묶인 텍스트는 해석된 px 를 행간 계산에 쓰지 않는다. 그 값은 지금 -/// 모드에서만 참이라, 다른 모드에서 조용히 어긋난 행간을 남긴다. -fn line_height_font_size(size: Option<&Value>, bound: Option<&Value>) -> Option { - if bound.and_then(|value| value.get("fontSize")).is_some() { - return None; - } - size.and_then(Value::as_f64) -} - -fn line_height(value: Option<&Value>, font_size: Option) -> Option { - let value = value?; - if let Some(number) = value.as_f64() { - return Some(px(number)); - } - let object = value.as_object()?; - match object.get("unit").and_then(Value::as_str) { - Some("AUTO") => Some("normal".to_owned()), - // 퍼센트 행간은 적용되는 font-size 에 비례한다는 점에서 CSS 와 뜻이 같다. - // 크기가 변수에 묶여 있으면 `font_size` 가 비어 오고, 그때는 px 로 굳히지 - // 않고 비율을 남긴다 — 어느 모드에서도 맞는 유일한 표현이다. - Some("PERCENT") => { - object - .get("value") - .and_then(Value::as_f64) - .map(|number| match font_size { - Some(size) => px((size * number / 100.0).round()), - None => format!("{}%", format_number(number)), - }) - } - _ => object.get("value").and_then(Value::as_f64).map(px), - } -} - -/// Korean offers a browser no inter-word breaking opportunity it can infer, so -/// the default `word-break` splits a word wherever the line happens to end. -/// Figma's text engine breaks Korean the same way, which puts pixel fidelity -/// and correct Korean in direct opposition here: dropping this moves the render -/// closer to Figma's PNG and makes the generated screen worse. The plugin -/// settles it deliberately - `if (hasKorean) defaultProps.wordBreak = -/// 'keep-all'` in its text renderer - and so do we, because Figma's behaviour -/// is a limitation to compensate for rather than a specification to reproduce. -/// This was measured before being kept: removing it buys 0.21 percent on one -/// screen and costs 38 byte-parity goldens and every Korean line break. -fn segments_contain_korean(view: &TypedNode<'_>) -> bool { - view.value("styledTextSegments") - .and_then(Value::as_array) - .is_some_and(|segments| { - segments.iter().any(|segment| { - segment - .get("characters") - .and_then(Value::as_str) - .is_some_and(|text| { - text.chars().any(|character| { - matches!( - character as u32, - 0xAC00..=0xD7AF | 0x1100..=0x11FF | 0x3130..=0x318F - ) - }) - }) - }) - }) -} - -fn replace_prop(props: &mut Vec, name: &str, value: String) { - if let Some((_, existing)) = props.iter_mut().find(|(prop, _)| prop == name) { - *existing = PropValue::String(value); - } else { - string_prop(props, name, value); - } -} - -pub(super) fn render_text_children( - view: &TypedNode<'_>, - text_style_tokens: &BTreeMap, - typography_values: &BTreeMap, - variable_tokens: &BTreeMap, - used_tokens: &mut BTreeSet, - depth: usize, -) -> String { - let indent = " ".repeat(depth); - let Some(segments) = view - .value("styledTextSegments") - .and_then(Value::as_array) - .filter(|segments| !segments.is_empty()) - else { - return format!( - "{indent}{}", - escape_jsx_text(view.string("characters").unwrap_or_default()) - ); - }; - let default = default_segment(view).expect("non-empty styledTextSegments"); - let default_props = typography_props(default, text_style_tokens, variable_tokens, used_tokens); - let mut rendered: Vec = Vec::new(); - for segment in segments { - let value = segment - .get("characters") - .and_then(Value::as_str) - .unwrap_or_default(); - let list = segment - .get("listOptions") - .and_then(Value::as_object) - .and_then(|value| value.get("type")) - .and_then(Value::as_str) - .unwrap_or("NONE"); - if list != "NONE" { - let lines = value - .replace("\r\n", "\n") - .replace(['\r', '\u{2028}', '\u{2029}'], "\n"); - let items = lines.split_terminator('\n').collect::>(); - rendered.extend(items.iter().enumerate().map(|(index, line)| { - // A trailing separator is a break inside the last item, not - // another item (which would introduce an extra list marker). - let trailing_break = if index + 1 == items.len() && lines.ends_with('\n') { - "
" - } else { - "" - }; - format!( - "{indent}
  • \n{}{}{trailing_break}\n{indent}
  • ", - " ".repeat(depth + 1), - escape_jsx_text(line) - ) - })); - continue; - } - - let mut segment_props = - typography_props(segment, text_style_tokens, variable_tokens, used_tokens); - segment_props.retain(|(name, value)| { - !default_props - .iter() - .any(|(default_name, default_value)| default_name == name && default_value == value) - }); - // A different run style establishes its own metrics. Equal metrics - // can be omitted there; same-style runs keep local overrides relative - // to the parent, even when a run happens to equal the token again. - if segment_props.iter().any(|(name, _)| name == "typography") - && let Some(value) = segment - .get("textStyleId") - .and_then(Value::as_str) - .and_then(|id| text_style_tokens.get(id)) - .and_then(|token| typography_values.get(token)) - { - elide_typography_duplicates(&mut segment_props, value); - } - let content = escape_jsx_text(value); - if segment_props.is_empty() { - if content.is_empty() { - continue; - } - // Two bare children are one JSXText token. A formatting newline - // between nonempty lines becomes a space, even inside a word. - // Join only that hazardous boundary; element/expression boundaries - // already prevent JSX from inserting a space and stay unchanged. - if !content.starts_with(['<', '{']) - && let Some(previous) = rendered.last_mut() - && !previous.ends_with(['>', '}']) - { - previous.push_str(&content); - } else { - rendered.push(format!("{indent}{content}")); - } - } else { - segment_props.sort_by(|left, right| left.0.cmp(&right.0)); - let props = segment_props - .into_iter() - .map(|(name, value)| match value { - PropValue::String(value) => render_static_attribute(&name, &value), - }) - .collect::>() - .join(" "); - rendered.push(format!( - "{indent}\n{}{}\n{indent}", - " ".repeat(depth + 1), - content - )); - } - } - rendered.join("\n") -} - -fn typography_props( - segment: &Value, - text_style_tokens: &BTreeMap, - variable_tokens: &BTreeMap, - used_tokens: &mut BTreeSet, -) -> Vec { - let mut props = Vec::new(); - if let Some(color) = bound_segment_color(segment.get("fills"), variable_tokens) - .or_else(|| first_solid_color(segment.get("fills"))) - { - record_used_color(&color, used_tokens); - string_prop(&mut props, "color", color); - } - let typography = segment - .get("textStyleId") - .and_then(Value::as_str) - .and_then(|id| text_style_tokens.get(id)); - if let Some(typography) = typography { - string_prop(&mut props, "typography", typography); - } else if let Some(family) = segment - .get("fontName") - .and_then(Value::as_object) - .and_then(|font| font.get("family")) - .and_then(Value::as_str) - { - string_prop(&mut props, "fontFamily", family); - } - if typography.is_none() - && segment - .get("fontName") - .and_then(Value::as_object) - .and_then(|font| font.get("style")) - .and_then(Value::as_str) - .is_some_and(|style| style.contains("Italic")) - { - string_prop(&mut props, "fontStyle", "italic"); - } - if let Some(value) = segment.get("fontSize").and_then(Value::as_f64) { - string_prop(&mut props, "fontSize", px(value)); - } - if let Some(value) = segment.get("fontWeight").and_then(Value::as_f64) { - string_prop(&mut props, "fontWeight", format_number(value)); - } - if let Some(value) = letter_spacing(segment.get("letterSpacing")) { - string_prop(&mut props, "letterSpacing", value); - } - if let Some(value) = line_height( - segment.get("lineHeight"), - line_height_font_size(segment.get("fontSize"), segment.get("boundVariables")), - ) { - string_prop(&mut props, "lineHeight", value); - } - if typography.is_none() { - match segment.get("textDecoration").and_then(Value::as_str) { - Some("UNDERLINE") => string_prop(&mut props, "textDecoration", "underline"), - Some("STRIKETHROUGH") => string_prop(&mut props, "textDecoration", "line-through"), - _ => {} - } - if let Some(case) = segment.get("textCase").and_then(Value::as_str) - && case != "ORIGINAL" - { - string_prop(&mut props, "textTransform", case.to_ascii_lowercase()); - } - } - props -} - -fn record_used_color(color: &str, used_tokens: &mut BTreeSet) { - if let Some(token) = color.strip_prefix('$') { - used_tokens.insert(token.to_owned()); - } -} - -/// Token names carry no metrics. Resolved overrides must travel as a pair; -/// otherwise a pixel advance from the token may belong to another font size. -pub(super) fn validate_line_metrics( - view: &TypedNode<'_>, - tokens: &BTreeMap, -) -> Result<(), devup_mcp_figma::DevupError> { - if view.node_type() != "TEXT" { - return Ok(()); - } - let validate = |size: Option<&Value>, - height: Option<&Value>, - bound: Option<&Value>, - styled: bool| { - let percent = height.and_then(|h| h.get("unit")).and_then(Value::as_str) == Some("PERCENT"); - let ratio = height.and_then(|h| h.get("value")).and_then(Value::as_f64); - let invalid = (percent && ratio.is_none()) - || (styled - && size.is_some() - && line_height(height, line_height_font_size(size, bound)).is_none()); - if invalid { - Err(devup_mcp_figma::DevupError::new( - devup_mcp_figma::ErrorCode::DevupCodegenFailed, - format!( - "Text node '{}' cannot represent a size-dependent line advance without resolved fontSize and lineHeight; variable sizes require mode-aware metrics.", - view.id() - ), - false, - )) - } else { - Ok(()) - } - }; - let segment = default_segment(view); - let value = |field: &str| { - view.value(field) - .filter(|v| is_resolved_value(v)) - .or_else(|| segment.and_then(|s| s.get(field))) - }; - let styled = segment - .and_then(|s| s.get("textStyleId")) - .and_then(Value::as_str) - .is_some_and(|id| tokens.contains_key(id)); - validate( - value("fontSize"), - value("lineHeight"), - value("boundVariables"), - styled, - )?; - for segment in view - .value("styledTextSegments") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let styled = segment - .get("textStyleId") - .and_then(Value::as_str) - .is_some_and(|id| tokens.contains_key(id)); - validate( - segment.get("fontSize"), - segment.get("lineHeight"), - segment.get("boundVariables"), - styled, - )?; - } - Ok(()) -} - -fn bound_segment_color( - fills: Option<&Value>, - variable_tokens: &BTreeMap, -) -> Option { - let id = fills?.as_array()?.iter().find_map(|paint| { - paint - .get("boundVariables")? - .get("color")? - .get("id")? - .as_str() - })?; - variable_tokens.get(id).map(|token| format!("${token}")) -} - -/// Whether a character is whitespace to a JavaScript regex's `\s`. -/// -/// Wider than ASCII: it takes in the no-break space, the Unicode spaces and -/// the line and paragraph separators. The last two matter — Figma writes a -/// soft return as U+2028, and a run of them at the edge of a segment is -/// whitespace to the plugin. -fn is_js_whitespace(character: char) -> bool { - matches!( - character, - '\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' - ..='\u{200a}' - | '\u{2028}' - | '\u{2029}' - | '\u{202f}' - | '\u{205f}' - | '\u{3000}' - | '\u{feff}' - ) -} - -/// Preserve ordinary text in the plugin's JSX spelling. Edge spaces use -/// string expressions because source indentation would trim them; other edge -/// whitespace retains its actual characters instead of becoming ASCII spaces. -/// Design line separators paint as explicit breaks (CRLF is one separator). -/// This encoder is also used by the provenance mapper. -pub(crate) fn escape_jsx_text(input: &str) -> String { - let characters = input.chars().collect::>(); - let leading = characters - .iter() - .take_while(|character| is_js_whitespace(**character)) - .count(); - let trailing = if leading == characters.len() { - 0 - } else { - characters - .iter() - .rev() - .take_while(|character| is_js_whitespace(**character)) - .count() - }; - let middle = &characters[leading..characters.len() - trailing]; - - let mut result = String::new(); - push_edge_whitespace(&characters[..leading], &mut result); - let mut index = 0; - while index < middle.len() { - let character = middle[index]; - match character { - '{' | '}' | '&' | '<' | '>' | '\'' => { - let run_end = middle[index..] - .iter() - .position(|other| !matches!(other, '{' | '}' | '&' | '<' | '>' | '\'')) - .map_or(middle.len(), |offset| index + offset); - let run = middle[index..run_end].iter().collect::(); - result.push_str(&format!("{{\"{run}\"}}")); - index = run_end; - continue; - } - '\r' => { - if middle.get(index + 1) == Some(&'\n') { - index += 1; - } - result.push_str("
    "); - } - '\n' | '\u{2028}' | '\u{2029}' => result.push_str("
    "), - '\t' => result.push_str("{\"\\t\"}"), - other => result.push(other), - } - index += 1; - } - push_edge_whitespace(&characters[characters.len() - trailing..], &mut result); - result -} - -/// Preserve each whitespace run verbatim, with explicit design line breaks. -fn push_edge_whitespace(run: &[char], into: &mut String) { - let mut start = 0; - while start < run.len() { - if matches!(run[start], '\r' | '\n' | '\u{2028}' | '\u{2029}') { - if run[start] == '\r' && run.get(start + 1) == Some(&'\n') { - start += 1; - } - into.push_str("
    "); - start += 1; - } else { - let end = run[start..] - .iter() - .position(|ch| matches!(ch, '\r' | '\n' | '\u{2028}' | '\u{2029}')) - .map_or(run.len(), |offset| start + offset); - let whitespace = run[start..end].iter().collect::(); - into.push('{'); - into.push_str(&serde_json::to_string(&whitespace).expect("serialize whitespace")); - into.push('}'); - start = end; - } - } -} - -/// CSS normal and nowrap both collapse ASCII spaces/tabs; JSX expressions do -/// not prevent that. Report the remaining visual loss without changing layout. -pub(super) fn whitespace_collapse_diagnostic( - view: &TypedNode<'_>, -) -> Option { - if view.node_type() != "TEXT" || preserves_hard_break_spaces(view) { - return None; - } - let characters = source_characters(view); - let collapses = characters - .split(['\r', '\n', '\u{2028}', '\u{2029}']) - .any(|line| { - line.starts_with(' ') - || line.ends_with(' ') - || line.contains(" ") - || line.contains('\t') - }); - if !collapses { - return None; - } - let mut details = serde_json::json!({ - "originalValue": characters.chars().take(800).collect::(), - "classification": "css-whitespace-collapse", - "whiteSpace": if view.number("maxLines") == Some(1.0) { "nowrap" } else { "normal" }, - "nextAction": "Review intentional spaces or tabs in Figma and choose an explicit whitespace-preserving style if required." - }); - let count = characters.chars().count(); - if count > 800 { - details["originalValueTruncated"] = serde_json::json!(true); - details["originalValueCharCount"] = serde_json::json!(count); - details["fullValueSource"] = serde_json::json!( - "The original node characters in rawSnapshot; generated TSX retains the complete text." - ); - } - Some(devup_mcp_figma::Diagnostic { - code: "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE".into(), - node_id: Some(view.id().to_owned()), - property: Some("characters".into()), - message: "Text characters are retained in JSX, but CSS collapses source spaces or tabs." - .into(), - fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), - details: Some(details), - ..devup_mcp_figma::Diagnostic::default() - }) -} - -fn source_characters(view: &TypedNode<'_>) -> String { - view.string("characters") - .map(str::to_owned) - .unwrap_or_else(|| { - view.value("styledTextSegments") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|segment| segment.get("characters").and_then(Value::as_str)) - .collect() - }) -} - -/// Preserve spaces adjacent to explicit breaks when Figma fixes the inline -/// width and grows only the height. HUG text needs a separate intrinsic-width -/// treatment: pre-wrap includes trailing spaces in its max-content width. -/// Lists, tabs and clamps retain their existing projection and loss reporting. -pub(crate) fn preserves_hard_break_spaces(view: &TypedNode<'_>) -> bool { - if view.node_type() != "TEXT" - || view.string("textAutoResize") != Some("HEIGHT") - || view.number("maxLines").is_some_and(|lines| lines > 0.0) - || view - .value("styledTextSegments") - .and_then(Value::as_array) - .is_some_and(|segments| { - segments.iter().any(|segment| { - segment - .get("listOptions") - .and_then(|options| options.get("type")) - .and_then(Value::as_str) - .is_some_and(|kind| kind != "NONE") - }) - }) - { - return false; - } - let characters = source_characters(view); - if characters.contains('\t') { - return false; - } - let is_break = |ch| matches!(ch, '\r' | '\n' | '\u{2028}' | '\u{2029}'); - characters - .chars() - .zip(characters.chars().skip(1)) - .any(|(left, right)| (left == ' ' && is_break(right)) || (is_break(left) && right == ' ')) -} +include!("text/wrapping_policy.rs"); +include!("text/bound_line_advance.rs"); +use std::collections::{BTreeMap, BTreeSet}; + +use devup_mcp_figma::TypedNode; +use serde_json::Value; + +use super::{ + component::{Prop, PropValue, render_static_attribute}, + layout::{format_number, px, string_prop}, + style::first_solid_color, +}; + +pub(super) fn push_text_props( + view: &TypedNode<'_>, + text_style_tokens: &BTreeMap, + typography_values: &BTreeMap, + variable_tokens: &BTreeMap, + used_tokens: &mut BTreeSet, + props: &mut Vec, +) { + if view.node_type() != "TEXT" { + return; + } + let segment = default_segment(view); + let typography = segment + .and_then(|value| value.get("textStyleId")) + .or_else(|| view.value("textStyleId")) + .and_then(Value::as_str) + .and_then(|id| text_style_tokens.get(id)); + let value = |field: &str| { + view.value(field) + .filter(|value| is_resolved_value(value)) + .or_else(|| segment.and_then(|value| value.get(field))) + }; + if let Some(color) = bound_segment_color(value("fills"), variable_tokens) + .or_else(|| first_solid_color(value("fills"))) + { + record_used_color(&color, used_tokens); + replace_prop(props, "color", color); + } + if let Some(typography) = typography { + string_prop(props, "typography", typography); + } else if let Some(family) = value("fontName") + .and_then(Value::as_object) + .and_then(|font| font.get("family")) + .and_then(Value::as_str) + { + string_prop(props, "fontFamily", family); + } + if typography.is_none() + && value("fontName") + .and_then(Value::as_object) + .and_then(|font| font.get("style")) + .and_then(Value::as_str) + .is_some_and(|style| style.contains("Italic")) + { + string_prop(props, "fontStyle", "italic"); + } + if let Some(font_size) = value("fontSize").and_then(Value::as_f64) { + string_prop(props, "fontSize", px(font_size)); + } + // A shared text style can have local weight overrides (including mixed + // regular/bold runs). The token name does not carry those resolved values. + if let Some(weight) = value("fontWeight").and_then(Value::as_f64) { + string_prop(props, "fontWeight", format_number(weight)); + } + if let Some(letter_spacing) = letter_spacing(value("letterSpacing")) { + string_prop(props, "letterSpacing", letter_spacing); + } + if let Some(line_height) = source_line_height( + value("lineHeight"), + value("fontSize"), + value("boundVariables"), + ) { + string_prop(props, "lineHeight", line_height); + } + if typography.is_none() { + match value("textDecoration").and_then(Value::as_str) { + Some("UNDERLINE") => string_prop(props, "textDecoration", "underline"), + Some("STRIKETHROUGH") => string_prop(props, "textDecoration", "line-through"), + _ => {} + } + if let Some(case) = value("textCase").and_then(Value::as_str) + && case != "ORIGINAL" + { + string_prop(props, "textTransform", case.to_ascii_lowercase()); + } + } + if let Some(max_lines) = view.number("maxLines") { + if max_lines == 1.0 { + string_prop(props, "whiteSpace", "nowrap"); + } else if max_lines > 1.0 { + string_prop(props, "WebkitBoxOrient", "vertical"); + string_prop(props, "WebkitLineClamp", format_number(max_lines)); + string_prop(props, "display", "-webkit-box"); + } + } + if preserves_intrinsic_text_width(view) { + string_prop(props, "whiteSpace", "pre"); + } else if preserves_hard_break_spaces(view) || preserves_wrapped_ascii_spaces(view) { + string_prop(props, "whiteSpace", "pre-wrap"); + } + // Reads the designer's own truncation setting, which Figma always + // reports — provided it is collected. It was missing from the field + // manifest, so this saw nothing and every text claimed an ellipsis the + // design never asked for. + if view.string("textTruncation") != Some("DISABLED") + && view.string("layoutSizingHorizontal") != Some("HUG") + { + string_prop(props, "overflow", "hidden"); + string_prop(props, "textOverflow", "ellipsis"); + } + let horizontal_hug = view + .string("textAutoResize") + .is_some_and(|value| value.contains("WIDTH")); + let single_line = !view.string("characters").unwrap_or_default().contains('\n'); + if !(horizontal_hug && single_line) + && let Some(alignment) = view.string("textAlignHorizontal") + { + let value = match alignment { + "RIGHT" => Some("right"), + "CENTER" => Some("center"), + "JUSTIFIED" => Some("justify"), + _ => None, + }; + if let Some(value) = value { + string_prop(props, "textAlign", value); + } + } + let vertical_hug = view + .string("textAutoResize") + .is_some_and(|value| value.contains("HEIGHT")); + if !vertical_hug { + match view.string("textAlignVertical") { + Some("CENTER") => string_prop(props, "alignContent", "center"), + Some("BOTTOM") => string_prop(props, "alignContent", "end"), + _ => {} + } + } + push_korean_wrap_policy( + view, + typography.and_then(|token| typography_values.get(token)), + props, + ); + if let Some(strokes) = view.value("strokes") + && let Some(color) = first_solid_color(Some(strokes)) + { + string_prop( + props, + "WebkitTextStroke", + format!("{} {color}", px(view.number("strokeWeight").unwrap_or(1.0))), + ); + string_prop(props, "paintOrder", "stroke fill"); + } + if let Some(value) = typography.and_then(|token| typography_values.get(token)) { + elide_typography_duplicates(props, value); + } + if let Some(list) = segment + .and_then(|value| value.get("listOptions")) + .and_then(Value::as_object) + .and_then(|value| value.get("type")) + .and_then(Value::as_str) + { + match list { + "UNORDERED" => string_prop(props, "as", "ul"), + "ORDERED" => string_prop(props, "as", "ol"), + _ => return, + } + string_prop(props, "my", "0px"); + string_prop(props, "pl", "1.5em"); + } +} + +fn elide_typography_duplicates(props: &mut Vec, typography: &Value) { + elide_metrics(props, typography, &BTreeMap::new()); +} + +/// The metrics a `typography` token already sets. +/// +/// A text with a text style applied reports the style's own metrics as they +/// resolve in the mode of the frame it is drawn in: a desktop frame says +/// `40px` for an `h2` that is `28px` on mobile. Figma detaches the style +/// when one of its metrics is edited, so a value the token defines at any +/// width is the token speaking, not an override. Writing it beside +/// `typography` pins every width to the drawn one and breaks the token's +/// responsive values; it is left out. A value the token defines at no width +/// is a real override and stays. A responsive value (a length token) stays +/// only where it differs from the token width by width. +fn elide_metrics( + props: &mut Vec, + typography: &Value, + lengths: &BTreeMap, +) { + const METRICS: [&str; 4] = ["fontSize", "fontWeight", "lineHeight", "letterSpacing"]; + let Some(slots) = typography_slots(typography) else { + return; + }; + props.retain(|(name, PropValue::String(value))| { + if !METRICS.contains(&name.as_str()) { + return true; + } + let expected = (0..SERIES) + .map(|slot| { + slots[slot] + .and_then(|slot_values| slot_values.get(name)) + .map(|value| series(&resolve_length(value, lengths))[slot].clone()) + .unwrap_or(Value::Null) + }) + .collect::>(); + if expected.iter().all(Value::is_null) { + return true; + } + let written = resolve_length(&Value::String(value.clone()), lengths); + let elided = if written.is_array() { + series(&written) + .iter() + .zip(&expected) + .all(|(written, expected)| same_metric(written, expected)) + } else { + expected + .iter() + .any(|expected| same_metric(&written, expected)) + }; + !elided + }); +} + +const SERIES: usize = 5; + +/// A token's slot objects, the last one carried forward over `null`. +fn typography_slots( + typography: &Value, +) -> Option<[Option<&serde_json::Map>; SERIES]> { + match typography { + Value::Object(value) => Some([Some(value); SERIES]), + Value::Array(slots) if slots.first().is_some_and(Value::is_object) => { + let mut carried = None; + let mut out = [None; SERIES]; + for (slot, entry) in out.iter_mut().enumerate() { + match slots.get(slot) { + Some(Value::Object(value)) => carried = Some(value), + Some(Value::Null) | None => {} + Some(_) => return None, + } + *entry = carried; + } + Some(out) + } + _ => None, + } +} + +/// A value as each slot reads it: a scalar everywhere, an array carried +/// forward over `null`. +fn series(value: &Value) -> Vec { + match value { + Value::Array(values) => { + let mut carried = Value::Null; + (0..SERIES) + .map(|slot| { + if let Some(value) = values.get(slot).filter(|value| !value.is_null()) { + carried = value.clone(); + } + carried.clone() + }) + .collect() + } + value => vec![value.clone(); SERIES], + } +} + +fn resolve_length( + value: &Value, + lengths: &BTreeMap, +) -> Value { + match value { + Value::String(name) if name.starts_with('$') => lengths + .values() + .find(|token| token.token == name[1..]) + .map_or_else(|| value.clone(), |token| token.value.clone()), + _ => value.clone(), + } +} + +fn same_metric(left: &Value, right: &Value) -> bool { + fn text(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + } + } + let (Some(left), Some(right)) = (text(left), text(right)) else { + return false; + }; + left == right || matches!((left.parse::(), right.parse::()), (Ok(l), Ok(r)) if l == r) +} + +pub(super) fn elide_tokenized_typography( + props: &mut Vec, + typography_values: &BTreeMap, + lengths: &BTreeMap, +) { + let Some(typography) = props.iter().find_map(|(name, PropValue::String(value))| { + (name == "typography") + .then(|| typography_values.get(value)) + .flatten() + }) else { + return; + }; + let typography = typography.clone(); + elide_metrics(props, &typography, lengths); +} + +fn is_resolved_value(value: &Value) -> bool { + !value.as_object().is_some_and(|object| { + ["$unsupported", "$undefined", "$truncated", "$error"] + .iter() + .any(|marker| object.contains_key(*marker)) + }) +} + +fn default_segment<'a>(view: &'a TypedNode<'a>) -> Option<&'a Value> { + let segments = view.value("styledTextSegments")?.as_array()?; + let mut selected = segments.first()?; + let mut longest = selected + .get("characters") + .and_then(Value::as_str) + .map(str::len) + .unwrap_or_default(); + for segment in segments.iter().skip(1) { + let length = segment + .get("characters") + .and_then(Value::as_str) + .map(str::len) + .unwrap_or_default(); + if length >= longest { + selected = segment; + longest = length; + } + } + Some(selected) +} + +fn letter_spacing(value: Option<&Value>) -> Option { + let value = value?; + if let Some(number) = value.as_f64() { + return Some(px(number)); + } + let object = value.as_object()?; + let number = object.get("value").and_then(Value::as_f64).unwrap_or(0.0); + match object.get("unit").and_then(Value::as_str) { + Some("PERCENT") => Some(format!("{}em", format_number(number.round() / 100.0))), + _ => Some(px(number)), + } +} + +/// 크기가 변수에 묶인 텍스트는 해석된 px 를 행간 계산에 쓰지 않는다. 그 값은 지금 +/// 모드에서만 참이라, 다른 모드에서 조용히 어긋난 행간을 남긴다. +fn line_height_font_size(size: Option<&Value>, bound: Option<&Value>) -> Option { + if bound.and_then(|value| value.get("fontSize")).is_some() { + return None; + } + size.and_then(Value::as_f64) +} + +fn line_height(value: Option<&Value>, font_size: Option) -> Option { + let value = value?; + if let Some(number) = value.as_f64() { + return Some(px(number)); + } + let object = value.as_object()?; + match object.get("unit").and_then(Value::as_str) { + Some("AUTO") => Some("normal".to_owned()), + // 퍼센트 행간은 적용되는 font-size 에 비례한다는 점에서 CSS 와 뜻이 같다. + // 크기가 변수에 묶여 있으면 `font_size` 가 비어 오고, 그때는 px 로 굳히지 + // 않고 비율을 남긴다 — 어느 모드에서도 맞는 유일한 표현이다. + Some("PERCENT") => { + object + .get("value") + .and_then(Value::as_f64) + .map(|number| match font_size { + Some(size) => px((size * number / 100.0).round()), + None => format!("{}%", format_number(number)), + }) + } + _ => object.get("value").and_then(Value::as_f64).map(px), + } +} + +fn replace_prop(props: &mut Vec, name: &str, value: String) { + if let Some((_, existing)) = props.iter_mut().find(|(prop, _)| prop == name) { + *existing = PropValue::String(value); + } else { + string_prop(props, name, value); + } +} + +pub(super) fn render_text_children( + view: &TypedNode<'_>, + text_style_tokens: &BTreeMap, + typography_values: &BTreeMap, + variable_tokens: &BTreeMap, + used_tokens: &mut BTreeSet, + depth: usize, +) -> String { + let indent = " ".repeat(depth); + let Some(segments) = view + .value("styledTextSegments") + .and_then(Value::as_array) + .filter(|segments| !segments.is_empty()) + else { + return format!( + "{indent}{}", + escape_jsx_text(view.string("characters").unwrap_or_default()) + ); + }; + let default = default_segment(view).expect("non-empty styledTextSegments"); + let default_props = typography_props(default, text_style_tokens, variable_tokens, used_tokens); + let mut rendered: Vec = Vec::new(); + let rendered_characters = render_segment_characters(segments); + for (segment, value) in segments.iter().zip(&rendered_characters) { + let value = value.as_str(); + let list = segment + .get("listOptions") + .and_then(Value::as_object) + .and_then(|value| value.get("type")) + .and_then(Value::as_str) + .unwrap_or("NONE"); + if list != "NONE" { + let lines = value.replace(['\u{2028}', '\u{2029}'], "\n"); + let items = lines.split_terminator('\n').collect::>(); + rendered.extend(items.iter().enumerate().map(|(index, line)| { + // A trailing separator is a break inside the last item, not + // another item (which would introduce an extra list marker). + let trailing_break = if index + 1 == items.len() && lines.ends_with('\n') { + "
    " + } else { + "" + }; + format!( + "{indent}
  • \n{}{}{trailing_break}\n{indent}
  • ", + " ".repeat(depth + 1), + escape_jsx_text(line) + ) + })); + continue; + } + + let mut segment_props = + typography_props(segment, text_style_tokens, variable_tokens, used_tokens); + let changes_size = segment_props.iter().any(|(name, value)| { + name == "fontSize" + && !default_props + .iter() + .any(|(base, old)| base == name && old == value) + }); + segment_props.retain(|(name, value)| { + (changes_size + && name == "lineHeight" + && matches!(value, PropValue::String(value) if value.starts_with("round("))) + || !default_props.iter().any(|(default_name, default_value)| { + default_name == name && default_value == value + }) + }); + // A different run style establishes its own metrics. Equal metrics + // can be omitted there; same-style runs keep local overrides relative + // to the parent, even when a run happens to equal the token again. + if segment_props.iter().any(|(name, _)| name == "typography") + && let Some(value) = segment + .get("textStyleId") + .and_then(Value::as_str) + .and_then(|id| text_style_tokens.get(id)) + .and_then(|token| typography_values.get(token)) + { + elide_typography_duplicates(&mut segment_props, value); + } + let content = escape_jsx_text(value); + if segment_props.is_empty() { + if content.is_empty() { + continue; + } + // Two bare children are one JSXText token. A formatting newline + // between nonempty lines becomes a space, even inside a word. + // Join only that hazardous boundary; element/expression boundaries + // already prevent JSX from inserting a space and stay unchanged. + if !content.starts_with(['<', '{']) + && let Some(previous) = rendered.last_mut() + && !previous.ends_with(['>', '}']) + { + previous.push_str(&content); + } else { + rendered.push(format!("{indent}{content}")); + } + } else { + segment_props.sort_by(|left, right| left.0.cmp(&right.0)); + let props = segment_props + .into_iter() + .map(|(name, value)| match value { + PropValue::String(value) => render_static_attribute(&name, &value), + }) + .collect::>() + .join(" "); + rendered.push(format!( + "{indent}\n{}{}\n{indent}", + " ".repeat(depth + 1), + content + )); + } + } + rendered.join("\n") +} + +fn typography_props( + segment: &Value, + text_style_tokens: &BTreeMap, + variable_tokens: &BTreeMap, + used_tokens: &mut BTreeSet, +) -> Vec { + let mut props = Vec::new(); + if let Some(color) = bound_segment_color(segment.get("fills"), variable_tokens) + .or_else(|| first_solid_color(segment.get("fills"))) + { + record_used_color(&color, used_tokens); + string_prop(&mut props, "color", color); + } + let typography = segment + .get("textStyleId") + .and_then(Value::as_str) + .and_then(|id| text_style_tokens.get(id)); + if let Some(typography) = typography { + string_prop(&mut props, "typography", typography); + } else if let Some(family) = segment + .get("fontName") + .and_then(Value::as_object) + .and_then(|font| font.get("family")) + .and_then(Value::as_str) + { + string_prop(&mut props, "fontFamily", family); + } + if typography.is_none() + && segment + .get("fontName") + .and_then(Value::as_object) + .and_then(|font| font.get("style")) + .and_then(Value::as_str) + .is_some_and(|style| style.contains("Italic")) + { + string_prop(&mut props, "fontStyle", "italic"); + } + if let Some(value) = segment.get("fontSize").and_then(Value::as_f64) { + string_prop(&mut props, "fontSize", px(value)); + } + if let Some(value) = segment.get("fontWeight").and_then(Value::as_f64) { + string_prop(&mut props, "fontWeight", format_number(value)); + } + if let Some(value) = letter_spacing(segment.get("letterSpacing")) { + string_prop(&mut props, "letterSpacing", value); + } + if let Some(value) = source_line_height( + segment.get("lineHeight"), + segment.get("fontSize"), + segment.get("boundVariables"), + ) { + string_prop(&mut props, "lineHeight", value); + } + if typography.is_none() { + match segment.get("textDecoration").and_then(Value::as_str) { + Some("UNDERLINE") => string_prop(&mut props, "textDecoration", "underline"), + Some("STRIKETHROUGH") => string_prop(&mut props, "textDecoration", "line-through"), + _ => {} + } + if let Some(case) = segment.get("textCase").and_then(Value::as_str) + && case != "ORIGINAL" + { + string_prop(&mut props, "textTransform", case.to_ascii_lowercase()); + } + } + props +} + +fn record_used_color(color: &str, used_tokens: &mut BTreeSet) { + if let Some(token) = color.strip_prefix('$') { + used_tokens.insert(token.to_owned()); + } +} + +/// Token names carry no metrics. Resolved overrides must travel as a pair; +/// otherwise a pixel advance from the token may belong to another font size. +pub(super) fn validate_line_metrics( + view: &TypedNode<'_>, + tokens: &BTreeMap, +) -> Result<(), devup_mcp_figma::DevupError> { + if view.node_type() != "TEXT" { + return Ok(()); + } + let validate = |size: Option<&Value>, + height: Option<&Value>, + bound: Option<&Value>, + styled: bool| { + let percent = height.and_then(|h| h.get("unit")).and_then(Value::as_str) == Some("PERCENT"); + let ratio = height.and_then(|h| h.get("value")).and_then(Value::as_f64); + let invalid = (percent && ratio.is_none()) + || (styled + && size.is_some() + && line_height(height, line_height_font_size(size, bound)).is_none()); + if invalid { + Err(devup_mcp_figma::DevupError::new( + devup_mcp_figma::ErrorCode::DevupCodegenFailed, + format!( + "Text node '{}' cannot represent a size-dependent line advance without resolved fontSize and lineHeight; variable sizes require mode-aware metrics.", + view.id() + ), + false, + )) + } else { + Ok(()) + } + }; + let segment = default_segment(view); + let value = |field: &str| { + view.value(field) + .filter(|v| is_resolved_value(v)) + .or_else(|| segment.and_then(|s| s.get(field))) + }; + let styled = segment + .and_then(|s| s.get("textStyleId")) + .and_then(Value::as_str) + .is_some_and(|id| tokens.contains_key(id)); + validate( + value("fontSize"), + value("lineHeight"), + value("boundVariables"), + styled, + )?; + for segment in view + .value("styledTextSegments") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let styled = segment + .get("textStyleId") + .and_then(Value::as_str) + .is_some_and(|id| tokens.contains_key(id)); + validate( + segment.get("fontSize"), + segment.get("lineHeight"), + segment.get("boundVariables"), + styled, + )?; + } + Ok(()) +} + +fn bound_segment_color( + fills: Option<&Value>, + variable_tokens: &BTreeMap, +) -> Option { + let id = fills?.as_array()?.iter().find_map(|paint| { + paint + .get("boundVariables")? + .get("color")? + .get("id")? + .as_str() + })?; + variable_tokens.get(id).map(|token| format!("${token}")) +} + +/// Whether a character is whitespace to a JavaScript regex's `\s`. +/// +/// Wider than ASCII: it takes in the no-break space, the Unicode spaces and +/// the line and paragraph separators. The last two matter — Figma writes a +/// soft return as U+2028, and a run of them at the edge of a segment is +/// whitespace to the plugin. +fn is_js_whitespace(character: char) -> bool { + matches!( + character, + '\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +/// Normalize adjacent styled runs with the same CRLF boundary rule as plain +/// text. A CR at one run's end is consumed only when the next nonempty run +/// begins with LF; that following run owns the single rendered break. +pub(crate) fn render_segment_characters(segments: &[Value]) -> Vec { + let mut output = Vec::with_capacity(segments.len()); + let mut next_character = None; + for segment in segments.iter().rev() { + let raw = segment + .get("characters") + .and_then(Value::as_str) + .unwrap_or_default(); + let visible = if raw.ends_with('\r') && next_character == Some('\n') { + &raw[..raw.len() - 1] + } else { + raw + }; + output.push(normalize_render_characters(visible)); + next_character = raw.chars().next().or(next_character); + } + output.reverse(); + output +} + +/// Figma's lone carriage return occupies an inline space (captured transit +/// strings remain a single line). CRLF is one hard break. Keep raw values in +/// the snapshot/provenance; apply this only to render text and prop defaults. +pub(crate) fn normalize_render_characters(input: &str) -> String { + input.replace("\r\n", "\n").replace('\r', " ") +} + +/// Preserve ordinary text in the plugin's JSX spelling. Edge spaces use +/// string expressions because source indentation would trim them; other edge +/// whitespace retains its characters after render-control normalization. +/// Design line separators paint as explicit breaks (CRLF is one separator). +/// This encoder is also used by the provenance mapper. +pub(crate) fn escape_jsx_text(input: &str) -> String { + let normalized = normalize_render_characters(input); + let input = normalized.as_str(); + let characters = input.chars().collect::>(); + let leading = characters + .iter() + .take_while(|character| is_js_whitespace(**character)) + .count(); + let trailing = if leading == characters.len() { + 0 + } else { + characters + .iter() + .rev() + .take_while(|character| is_js_whitespace(**character)) + .count() + }; + let middle = &characters[leading..characters.len() - trailing]; + + let mut result = String::new(); + push_edge_whitespace(&characters[..leading], &mut result); + let mut index = 0; + while index < middle.len() { + let character = middle[index]; + match character { + ' ' if middle.get(index + 1) == Some(&' ') => { + let count = middle[index..].iter().take_while(|ch| **ch == ' ').count(); + result.push_str(&format!( + "{{{}}}", + serde_json::to_string(&" ".repeat(count)).expect("serialize spaces") + )); + index += count; + continue; + } + '{' | '}' | '&' | '<' | '>' | '\'' => { + let run_end = middle[index..] + .iter() + .position(|other| !matches!(other, '{' | '}' | '&' | '<' | '>' | '\'')) + .map_or(middle.len(), |offset| index + offset); + let run = middle[index..run_end].iter().collect::(); + result.push_str(&format!("{{\"{run}\"}}")); + index = run_end; + continue; + } + '\r' => { + if middle.get(index + 1) == Some(&'\n') { + index += 1; + } + result.push_str("
    "); + } + '\n' | '\u{2028}' | '\u{2029}' => result.push_str("
    "), + '\t' => result.push_str("{\"\\t\"}"), + other => result.push(other), + } + index += 1; + } + push_edge_whitespace(&characters[characters.len() - trailing..], &mut result); + result +} + +/// Preserve each whitespace run verbatim, with explicit design line breaks. +fn push_edge_whitespace(run: &[char], into: &mut String) { + let mut start = 0; + while start < run.len() { + if matches!(run[start], '\r' | '\n' | '\u{2028}' | '\u{2029}') { + if run[start] == '\r' && run.get(start + 1) == Some(&'\n') { + start += 1; + } + into.push_str("
    "); + start += 1; + } else { + let end = run[start..] + .iter() + .position(|ch| matches!(ch, '\r' | '\n' | '\u{2028}' | '\u{2029}')) + .map_or(run.len(), |offset| start + offset); + let whitespace = run[start..end].iter().collect::(); + into.push('{'); + into.push_str(&serde_json::to_string(&whitespace).expect("serialize whitespace")); + into.push('}'); + start = end; + } + } +} + +/// CSS normal and nowrap both collapse ASCII spaces/tabs; JSX expressions do +/// not prevent that. Report the remaining visual loss without changing layout. +pub(super) fn whitespace_collapse_diagnostic( + view: &TypedNode<'_>, +) -> Option { + if view.node_type() != "TEXT" + || preserves_hard_break_spaces(view) + || preserves_wrapped_ascii_spaces(view) + || preserves_intrinsic_text_width(view) + { + return None; + } + let characters = source_characters(view); + let collapses = characters + .split(['\r', '\n', '\u{2028}', '\u{2029}']) + .any(|line| { + line.starts_with(' ') + || line.ends_with(' ') + || line.contains(" ") + || line.contains('\t') + }); + if !collapses { + return None; + } + let mut details = serde_json::json!({ + "originalValue": characters.chars().take(800).collect::(), + "classification": "css-whitespace-collapse", + "whiteSpace": if view.number("maxLines") == Some(1.0) { "nowrap" } else { "normal" }, + "nextAction": "Review intentional spaces or tabs in Figma and choose an explicit whitespace-preserving style if required." + }); + let count = characters.chars().count(); + if count > 800 { + details["originalValueTruncated"] = serde_json::json!(true); + details["originalValueCharCount"] = serde_json::json!(count); + details["fullValueSource"] = serde_json::json!( + "The original node characters in rawSnapshot; generated TSX retains the complete text." + ); + } + Some(devup_mcp_figma::Diagnostic { + code: "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE".into(), + node_id: Some(view.id().to_owned()), + property: Some("characters".into()), + message: "Text characters are retained in JSX, but CSS collapses source spaces or tabs." + .into(), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), + details: Some(details), + ..devup_mcp_figma::Diagnostic::default() + }) +} + +fn source_characters(view: &TypedNode<'_>) -> String { + view.string("characters") + .map(str::to_owned) + .unwrap_or_else(|| { + view.value("styledTextSegments") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|segment| segment.get("characters").and_then(Value::as_str)) + .collect() + }) +} + +/// Preserve spaces adjacent to explicit breaks when Figma fixes the inline +/// width and grows only the height. HUG text needs a separate intrinsic-width +/// treatment: pre-wrap includes trailing spaces in its max-content width. +/// Lists, tabs and clamps retain their existing projection and loss reporting. +pub(crate) fn preserves_intrinsic_text_width(view: &TypedNode<'_>) -> bool { + view.node_type() == "TEXT" + && view.string("textAutoResize") == Some("WIDTH_AND_HEIGHT") + && !view.number("maxLines").is_some_and(|lines| lines > 0.0) + && !view + .value("styledTextSegments") + .and_then(Value::as_array) + .is_some_and(|segments| { + segments.iter().any(|segment| { + segment + .pointer("/listOptions/type") + .and_then(Value::as_str) + .is_some_and(|kind| kind != "NONE") + }) + }) +} + +fn wrapped_space_characters(view: &TypedNode<'_>) -> Option { + if view.node_type() != "TEXT" + || view.string("textAutoResize") != Some("HEIGHT") + || view.number("maxLines").is_some_and(|lines| lines > 0.0) + || view + .value("styledTextSegments") + .and_then(Value::as_array) + .is_some_and(|segments| { + segments.iter().any(|segment| { + segment + .get("listOptions") + .and_then(|options| options.get("type")) + .and_then(Value::as_str) + .is_some_and(|kind| kind != "NONE") + }) + }) + { + return None; + } + let characters = source_characters(view); + if characters.contains('\t') { + return None; + } + Some(characters) +} + +pub(crate) fn preserves_hard_break_spaces(view: &TypedNode<'_>) -> bool { + let Some(characters) = wrapped_space_characters(view) else { + return false; + }; + let is_break = |ch| matches!(ch, '\r' | '\n' | '\u{2028}' | '\u{2029}'); + characters + .chars() + .zip(characters.chars().skip(1)) + .any(|(left, right)| (left == ' ' && is_break(right)) || (is_break(left) && right == ' ')) +} + +/// HEIGHT text has a captured inline width and still soft-wraps. Preserve +/// authored ASCII spacing without making its width intrinsic or guessing a +/// height. Lists, tabs, clamps and unknown/HUG widths keep their existing policy. +pub(crate) fn preserves_wrapped_ascii_spaces(view: &TypedNode<'_>) -> bool { + if view.string("layoutSizingHorizontal") == Some("HUG") + || !view + .number("width") + .is_some_and(|width| width.is_finite() && width > 0.0) + { + return false; + } + wrapped_space_characters(view).is_some_and(|characters| { + characters + .split(['\r', '\n', '\u{2028}', '\u{2029}']) + .any(|line| line.starts_with(' ') || line.ends_with(' ') || line.contains(" ")) + }) +} + +include!("text/wrapping_policy_tests.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/text/bound_line_advance.rs b/crates/devup-mcp-devup-ui/src/codegen/text/bound_line_advance.rs new file mode 100644 index 00000000..36e99b8f --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/text/bound_line_advance.rs @@ -0,0 +1,23 @@ +fn source_line_height( + value: Option<&Value>, + size: Option<&Value>, + bound: Option<&Value>, +) -> Option { + if bound.and_then(|b| b.get("fontSize")).is_some() + && bound.and_then(|b| b.get("lineHeight")).is_none() + && size + .and_then(Value::as_f64) + .is_some_and(|size| size.is_finite() && size > 0.0) + && value?.get("unit").and_then(Value::as_str) == Some("PERCENT") + && let Some(percent) = value? + .get("value") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && *v >= 0.0) + { + return Some(format!( + "round(nearest, {}em, 1px)", + (percent * 100.0).round() / 10_000.0 + )); + } + line_height(value, line_height_font_size(size, bound)) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy.rs b/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy.rs new file mode 100644 index 00000000..3cad8e0f --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy.rs @@ -0,0 +1,42 @@ +fn contains_korean(text: &str) -> bool { + text.chars().any(|character| matches!(character as u32, 0xAC00..=0xD7AF | 0x1100..=0x11FF | 0x3130..=0x318F)) +} + +/// Detect Korean from captured plain text and styled segments. +fn segments_contain_korean(view: &TypedNode<'_>) -> bool { + view.string("characters").is_some_and(contains_korean) || view.value("styledTextSegments") + .and_then(Value::as_array) + .is_some_and(|segments| { + segments.iter().any(|segment| { + segment + .get("characters") + .and_then(Value::as_str) + .is_some_and(|text| { + contains_korean(text) + }) + }) + }) +} + +fn theme_has_word_break(value: &Value) -> bool { + match value { + Value::Array(values) => values.iter().any(theme_has_word_break), + Value::Object(value) => value.contains_key("wordBreak"), + _ => false, + } +} + +/// Korean copy wraps at word boundaries even in measured fixed-width text. +fn push_korean_wrap_policy(view: &TypedNode<'_>, theme: Option<&Value>, props: &mut Vec) { + if !segments_contain_korean(view) + || props.iter().any(|(name, _)| name == "wordBreak") + || theme.is_some_and(theme_has_word_break) + { + return; + } + string_prop(props, "wordBreak", "keep-all"); + if !props.iter().any(|(name, _)| name == "overflowWrap") { + string_prop(props, "overflowWrap", "break-word"); + } +} + diff --git a/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy_tests.rs b/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy_tests.rs new file mode 100644 index 00000000..8dd12ca6 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy_tests.rs @@ -0,0 +1,16 @@ +#[cfg(test)] +mod wrapping_policy_tests { + use super::*; + + #[test] + fn explicit_existing_word_break_policy_is_not_overwritten() { + let snapshot: devup_mcp_figma::Snapshot = serde_json::from_str(include_str!( + "../../../tests/fixtures/vision-wrapping-text.json" + )).unwrap(); + let mut props = vec![("wordBreak".into(), PropValue::String("break-all".into()))]; + push_korean_wrap_policy(&snapshot.nodes["I44:3228;44:3621"].typed_view(), None, &mut props); + assert_eq!(props.len(), 1); + assert!(matches!(&props[0].1, PropValue::String(value) if value == "break-all")); + } +} + diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant.rs b/crates/devup-mcp-devup-ui/src/codegen/variant.rs index 5038f065..95f8c6e7 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant.rs @@ -1,6 +1,11 @@ // Source sections share this module to preserve privacy and test names. include!("variant/variant_projection.rs"); +include!("variant/unnamed_state_axis.rs"); include!("variant/child_keys.rs"); include!("variant/drawn_when.rs"); include!("variant/text_properties.rs"); include!("variant/scalar_properties.rs"); +include!("variant/structural_branches.rs"); +include!("variant/instance_booleans.rs"); + +include!("variant/tree_signature.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs index 31f16558..ad2665e3 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs @@ -245,6 +245,9 @@ fn record_value( path: &NodePath, prop: &str, ) -> Option { + if prop == "alt" && !visible_tree_at(&record.tree, path) { + return None; + } let value = tree_at(&record.tree, path).and_then(|tree| tree_field(tree, prop)); let (Some(baseline), Some(effect_name)) = (baseline, effect_name) else { return value; @@ -514,6 +517,12 @@ fn viewport_expression( .iter() .find(|value| !value.eq_ignore_ascii_case("mobile")) .and_then(|value| lookup(Some(value))); + // HTML alternatives are scalar attributes, not CSS breakpoint values. + // Only a visible width supplies an alt. Conflicting visible alternatives + // are retained as a lossy diagnostic by viewport_alt_diagnostics. + if prop == "alt" { + return mobile.or(desktop).map(Expression::Literal); + } if mobile == desktop { mobile.or(desktop).map(Expression::Literal) } else { @@ -582,6 +591,16 @@ fn render_component( selectors: &BTreeMap>, transition: Option<&(String, String)>, text_properties: &BTreeMap, +) -> String { + let body = render_tree(tree, selectors, transition, 2); + render_component_body(name, dimensions, text_properties, &body) +} + +fn render_component_body( + name: &str, + dimensions: &[Definition], + text_properties: &BTreeMap, + body: &str, ) -> String { let interface = if dimensions.is_empty() && text_properties.is_empty() { String::new() @@ -626,7 +645,6 @@ fn render_component( .join(", "); format!("export function {name}({{ {props} }}: {name}Props)") }; - let body = render_tree(tree, selectors, transition, 2); format!("{interface}{signature} {{\n return (\n{body}\n )\n}}") } @@ -722,10 +740,6 @@ fn render_tree( // A node a boolean property switches on is written as that condition. The // brace has to wrap the whole element, so it is applied after the element // is rendered rather than woven into it. - let rendered = match &tree.leading_comment { - Some(comment) => format!("{indent}{{/* {comment} */}}\n{rendered}"), - None => rendered, - }; let guards = tree .visible_when .iter() @@ -762,6 +776,7 @@ fn needs_const(prop: &str) -> bool { fn render_expression_attr(prop: &str, expression: &Expression) -> String { match expression { Expression::Undefined => "={undefined}".into(), + Expression::Literal(value) if prop == "alt" => super::semantics::scalar_html_value(value), Expression::Literal(value) => format!("=\"{value}\""), Expression::Variant(name, _) if needs_const(prop) => { let rendered = render_expression_value(expression, 0); diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/instance_booleans.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/instance_booleans.rs new file mode 100644 index 00000000..8246859d --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/instance_booleans.rs @@ -0,0 +1,47 @@ +// Inline responsive projection must resolve the same captured boolean overrides +// as the ordinary renderer. A component master keeps its symbolic properties. +fn captured_instance_visibility(snapshot: &Snapshot, node: &RawNode) -> Option { + let reference = node + .typed_view() + .value("componentPropertyReferences")? + .get("visible")? + .as_str()?; + let mut current = node.typed_view().string("parentId").map(str::to_owned); + let mut seen = BTreeSet::new(); + while let Some(id) = current { + if !seen.insert(id.clone()) { + break; + } + let ancestor = snapshot.nodes.get(&id)?; + let view = ancestor.typed_view(); + if ancestor.node_type == "INSTANCE" + && let Some(property) = view + .value("componentProperties") + .and_then(|values| values.get(reference)) + && property["type"] == "BOOLEAN" + && let Some(value) = property["value"].as_bool() + { + return Some(value); + } + current = view.string("parentId").map(str::to_owned); + } + None +} + +fn concretely_hidden_tree(node: &RawNode) -> Tree { + Tree { + node_id: node.id.clone(), + node_name: node.typed_view().name().unwrap_or_default().into(), + source_node_ids: BTreeSet::from([node.id.clone()]), + component: "Box".into(), + props: BTreeMap::from([("display".into(), "none".into())]), + children: Vec::new(), + content: None, + is_component: false, + visible_when: None, + drawn_when: Vec::new(), + leading_comment: None, + slots: Vec::new(), + scalar_sources: Vec::new(), + } +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/scalar_properties.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/scalar_properties.rs index dd046c1c..ff8647f2 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant/scalar_properties.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/scalar_properties.rs @@ -60,6 +60,47 @@ fn has_responsive_value(expression: &Expression) -> bool { } } +fn visible_tree_at(tree: &Tree, path: &NodePath) -> bool { + (0..=path.len()).all(|end| { + tree_at(tree, &path[..end]) + .is_some_and(|node| node.props.get("display").is_none_or(|value| value != "none")) + }) +} + +fn viewport_alt_diagnostics(records: &[Record], tree: &Tree, path: &NodePath) -> Vec { + let mut diagnostics = Vec::new(); + if tree.component == "Image" { + let mut groups = BTreeMap::, BTreeSet>::new(); + for record in records { + if !visible_tree_at(&record.tree, path) { continue; } + let Some(alt) = tree_at(&record.tree, path).and_then(|node| node.props.get("alt")) else { continue; }; + let dimensions = record.values.iter().filter(|(name, _)| !name.eq_ignore_ascii_case("viewport")) + .map(|(name, value)| (name.clone(), value.clone())).collect(); + groups.entry(dimensions).or_default().insert(alt.clone()); + } + for (dimensions, alternatives) in groups.into_iter().filter(|(_, values)| values.len() > 1) { + diagnostics.push(Diagnostic { + code: "DEVUP_CODEGEN_RESPONSIVE_ALT_UNREPRESENTED".into(), + message: "Visible viewport variants have different image alternatives. A scalar fallback is emitted; the alternatives are not represented responsively.".into(), + node_id: Some(tree.node_id.clone()), + property: Some("alt".into()), + severity: Some(DiagnosticSeverity::Warning), + fidelity_impact: Some(FidelityImpact::Lossy), + details: Some(serde_json::json!({"classification":"scalar-html-attribute","dimensions":dimensions,"alternatives":alternatives, + "nextAction":"Use separate per-width image branches preserving each captured alt, or explicitly resolve their shared text; never pass responsive arrays to HTML alt."})), + ..Default::default() + }); + } + } + let keys = child_keys(tree); + for (index, child) in tree.children.iter().enumerate() { + let Some(key) = keys.get(index) else { continue; }; + let mut child_path = path.to_vec(); child_path.push(key.clone()); + diagnostics.extend(viewport_alt_diagnostics(records, child, &child_path)); + } + diagnostics +} + fn fill_absent_display(expression: &mut Expression) { match expression { Expression::Responsive(slots) => { diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/structural_branches.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/structural_branches.rs new file mode 100644 index 00000000..c3fe1547 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/structural_branches.rs @@ -0,0 +1,335 @@ +// Preserve captured variant topology instead of substituting a default tree. + +fn viewport_slot(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "mobile" => Some(0), + "tablet" => Some(2), + "desktop" | "pc" => Some(4), + _ => None, + } +} + +fn branch_display(value: &str, options: &[String], display: &str) -> Option { + let selected = viewport_slot(value)?; + let mut slots = vec![None::; 5]; + slots[0] = Some("none".into()); + for option in options { + let slot = viewport_slot(option)?; + slots[slot] = Some(if slot == selected { display } else { "none" }.into()); + } + while slots.last().is_some_and(Option::is_none) { + slots.pop(); + } + Some(serde_json::to_string(&slots).expect("literal display slots")) +} + +fn natural_branch_display(component: &str) -> &str { + match component { + "Flex" | "VStack" | "Center" => "flex", + "Grid" => "grid", + "Image" => "inline", + _ => "block", + } +} + +/// The exact root gate the branch renderer writes, replayed by property audit. +pub(super) fn display_gate_derivation( + snapshot: &Snapshot, + node: &RawNode, + component: &str, + props: &[super::component::Prop], +) -> Option { + if node.node_type != "COMPONENT" { + return None; + } + let view = node.typed_view(); + let parent = snapshot.nodes.get(view.string("parentId")?)?; + if parent.node_type != "COMPONENT_SET" { + return None; + } + let viewport = definitions(parent) + .into_iter() + .find(|d| d.name.eq_ignore_ascii_case("viewport"))?; + if viewport.options.len() < 2 { + return None; + } + let selected = view + .value("variantProperties")? + .get(&viewport.source)? + .as_str()?; + let display = props + .iter() + .find_map(|(name, value)| { + (name == "display").then_some(match value { + PropValue::String(value) => value.as_str(), + }) + }) + .unwrap_or_else(|| natural_branch_display(component)); + let value = branch_display(selected, &viewport.options, display)?; + Some( + serde_json::json!({"stage":"variant-branch","generatedProperty":super::component::render_static_attribute("display",&value), + "sourceFields":["variantProperties","parentId"],"originalValue":{"variantProperties":view.value("variantProperties"),"parentComponentSet":parent.id,"viewportOptions":viewport.options}, + "calculation":"Captured viewport branches are separate roots with CSS display gates at Mobile/Tablet/Desktop slots. The visible branch restores its captured/natural display; no layout wrapper or geometry is added."}), + ) +} + +fn branch_issue(code: &str, node: &str, message: &str, details: Value) -> Diagnostic { + Diagnostic { + code: code.into(), + message: message.into(), + node_id: Some(node.into()), + severity: Some(DiagnosticSeverity::Warning), + fidelity_impact: Some(FidelityImpact::Lossy), + details: Some(details), + ..Default::default() + } +} + +fn rename_text_binding(tree: &mut Tree, from: &str, to: &str) { + if tree.content.as_deref() == Some(&format!("{{{from}}}")) { + tree.content = Some(format!("{{{to}}}")); + } + for child in &mut tree.children { + rename_text_binding(child, from, to); + } +} + +fn effect_branch_issues(base: &Tree, other: &Tree, node_id: &str, out: &mut Vec) { + if structure_signature(base) != structure_signature(other) { + out.push(branch_issue("DEVUP_CODEGEN_EFFECT_TOPOLOGY_UNSUPPORTED",node_id, + "An interaction effect changes captured topology. Its baseline subtree is retained; the effect topology requires an explicit interaction owner.", + serde_json::json!({"classification":"effect-topology","baselineNodeId":base.node_id,"effectNodeId":other.node_id, + "nextAction":"Implement the captured effect subtree using an explicit shared hover/focus/pressed owner, or report this effect as unresolved. Do not substitute default geometry and claim exactness."}))); + return; + } + for (left, right) in base.children.iter().zip(&other.children) { + if left.props != right.props || left.content != right.content { + out.push(branch_issue("DEVUP_CODEGEN_EFFECT_DESCENDANT_UNSUPPORTED",&right.node_id, + "A component interaction effect changes a descendant. Root state styles do not prove that descendant state behavior is implemented.", + serde_json::json!({"classification":"effect-descendant","baselineNodeId":left.node_id,"effectNodeId":right.node_id, + "nextAction":"Bind the descendant's captured styles to the parent interaction owner and verify the state at runtime."}))); + } + effect_branch_issues(left, right, node_id, out); + } +} + +fn preserve_structural_variants( + snapshot: &Snapshot, + set: &RawNode, + records: &[Record], + definitions: &[Definition], +) -> Result, DevupError> { + let Some(first) = records.first() else { + return Ok(None); + }; + let topology = |tree: &Tree| { + ( + tree.component.clone(), + tree.content.is_some(), + tree.children + .iter() + .map(structure_signature) + .collect::>(), + ) + }; + if records + .iter() + .all(|record| topology(&record.tree) == topology(&first.tree)) + { + return Ok(None); + } + let effect = definitions.iter().find(|d| { + d.name.eq_ignore_ascii_case("effect") + && records + .iter() + .all(|record| record.tree.component != "Image") + && d.options.iter().all(|value| { + value.eq_ignore_ascii_case(&d.default) || effect_selector(value).is_some() + }) + }); + let viewport = definitions + .iter() + .find(|d| d.name.eq_ignore_ascii_case("viewport")); + let mut public_dimensions = definitions + .iter() + .filter(|d| { + !d.name.eq_ignore_ascii_case("viewport") + && effect.is_none_or(|effect| d.name != effect.name) + }) + .cloned() + .collect::>(); + let mut groups = BTreeMap::, Vec>::new(); + for record in records { + let group = record + .values + .iter() + .filter(|(name, _)| effect.is_none_or(|e| *name != &e.source)) + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + groups.entry(group).or_default().push(record.clone()); + } + let mut imports = BTreeSet::new(); + let mut text_properties = BTreeMap::::new(); + let mut diagnostics = Vec::new(); + let mut branches = Vec::new(); + for (fixed, group) in groups { + let baseline = effect + .and_then(|e| { + group + .iter() + .find(|record| record.values.get(&e.source) == Some(&e.default)) + }) + .unwrap_or(&group[0]); + if let Some(effect) = effect { + for record in &group { + if record.node_id != baseline.node_id { + effect_branch_issues( + &baseline.tree, + &record.tree, + &record.node_id, + &mut diagnostics, + ); + } + } + if !group + .iter() + .any(|record| record.values.get(&effect.source) == Some(&effect.default)) + { + diagnostics.push(branch_issue("DEVUP_CODEGEN_EFFECT_BASELINE_MISSING",&baseline.node_id, + "This captured variant combination has no declared default interaction state. Its first capture is retained explicitly.", + serde_json::json!({"classification":"missing-effect-baseline","fixedVariants":fixed,"defaultEffect":effect.default}))); + } + } + let scoped_definitions = definitions + .iter() + .map(|definition| { + let mut scoped = definition.clone(); + if let Some(value) = fixed.get(&definition.source) { + scoped.default = value.clone(); + scoped.options = vec![value.clone()]; + } + scoped + }) + .collect::>(); + let scoped_dimensions = scoped_definitions + .iter() + .filter(|d| { + !d.name.eq_ignore_ascii_case("viewport") && effect.is_none_or(|e| d.name != e.name) + }) + .cloned() + .collect::>(); + let mut root = baseline.tree.clone(); + root.props = merged_props( + &group, + &scoped_definitions, + &scoped_dimensions, + None, + effect.map_or("default", |e| e.default.as_str()), + None, + &baseline.tree.props, + ); + merge_child_props( + &mut root, + &group, + &scoped_definitions, + &scoped_dimensions, + None, + effect.map_or("default", |e| e.default.as_str()), + &[], + ); + bind_referenced_properties(&mut root, &mut public_dimensions); + let branch_text = bind_text_properties(snapshot, set, &mut root, &public_dimensions); + for (name, default) in branch_text { + let mut selected = name.clone(); + let mut suffix = 2; + while text_properties + .get(&selected) + .is_some_and(|value| value != &default) + { + selected = format!("{name}Variant{suffix}"); + suffix += 1; + } + if selected != name { + rename_text_binding(&mut root, &name, &selected); + } + text_properties.insert(selected, default); + } + let mut selectors = BTreeMap::new(); + let mut transitioned = BTreeSet::new(); + if let Some(effect) = effect { + for value in &effect.options { + if value == &effect.default { + continue; + } + let changed = changed_effect_props( + &group, + &scoped_definitions, + &scoped_dimensions, + None, + &effect.default, + value, + ); + if !changed.is_empty() + && let Some(selector) = effect_selector(value) + { + transitioned.extend(changed.keys().cloned()); + selectors.insert(selector.into(), changed); + } + } + } + if let Some(viewport) = viewport.filter(|d| d.options.len() > 1) { + let selected = fixed + .get(&viewport.source) + .map(String::as_str) + .unwrap_or(&viewport.default); + let captured = baseline + .tree + .props + .get("display") + .map(String::as_str) + .unwrap_or_else(|| natural_branch_display(&root.component)); + if let Some(value) = branch_display(selected, &viewport.options, captured) { + root.props.insert("display".into(), format!("={{{value}}}")); + } else { + root.props.insert("display".into(), "=\"none\"".into()); + diagnostics.push(branch_issue("DEVUP_CODEGEN_VARIANT_VIEWPORT_UNSUPPORTED",&baseline.node_id, + "A captured viewport label has no established responsive slot. The retained branch stays hidden rather than guessing its breakpoint.", + serde_json::json!({"classification":"unknown-viewport","viewport":selected,"options":viewport.options, + "nextAction":"Map the named viewport to a confirmed breakpoint before making this branch visible."}))); + } + } + let guards = public_dimensions + .iter() + .filter(|d| !d.boolean) + .filter_map(|d| { + fixed.get(&d.source).map(|value| { + format!("{} === {}", d.name, serde_json::to_string(value).unwrap()) + }) + }) + .collect::>(); + root.drawn_when.extend(guards); + collect_imports(&root, &mut imports); + let transition = transition(snapshot, &baseline.node_id, &transitioned); + branches.push(render_tree(&root, &selectors, transition.as_ref(), 3)); + } + let body = format!(" <>\n{}\n ", branches.join("\n")); + let name = legacy_component_name(set.typed_view().name().unwrap_or("Component")); + let code = mark_node( + &set.id, + render_component_body(&name, &public_dimensions, &text_properties, &body), + ); + diagnostics.push(Diagnostic {code:"DEVUP_CODEGEN_VARIANT_BRANCHES_PRESERVED".into(), + message:"Different captured variant structures are emitted as explicit prop/viewport branches without adding layout wrappers.".into(), + node_id:Some(set.id.clone()),severity:Some(DiagnosticSeverity::Info),fidelity_impact:Some(FidelityImpact::None), + details:Some(serde_json::json!({"branches":branches.len(),"capturedRecords":records.len(),"viewportSlots":{"Mobile":0,"Tablet":2,"Desktop":4},"evidenceLimit":"Static captured branch preservation; browser interaction and geometry still require runtime verification."})),..Default::default()}); + Ok(Some(CodegenOutput { + tsx: code, + imports: imports.into_iter().collect(), + used_tokens: BTreeSet::new(), + diagnostics, + source_map: crate::provenance::SourceMap::empty(), + projection_trace: crate::provenance::ProjectionTrace::default(), + fidelity_report: crate::provenance::FidelityReport::default(), + width_policy: None, + })) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/text_properties.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/text_properties.rs index ad4c65f1..93e34d49 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant/text_properties.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/text_properties.rs @@ -4,10 +4,13 @@ fn bind_text_properties( tree: &mut Tree, dimensions: &[Definition], ) -> BTreeMap { - let mut used = dimensions.iter().map(|definition| definition.name.clone()) + let mut used = dimensions + .iter() + .map(|definition| definition.name.clone()) .collect::>(); let mut properties = BTreeMap::new(); - for (name, definition) in set.typed_view() + for (name, definition) in set + .typed_view() .value("componentPropertyDefinitions") .and_then(Value::as_object) .into_iter() @@ -22,13 +25,21 @@ fn bind_text_properties( suffix += 1; } used.insert(property.clone()); - properties.insert(name.clone(), (property, definition["defaultValue"].to_string())); + let default = definition["defaultValue"].as_str().map_or_else( + || definition["defaultValue"].to_string(), + |value| { + serde_json::to_string(&super::text::normalize_render_characters(value)).unwrap() + }, + ); + properties.insert(name.clone(), (property, default)); } let mut defaults = properties.values().cloned().collect::>(); let mut fallback_index = 0; let mut pending = vec![tree]; while let Some(node) = pending.pop() { - if let Some((name, _)) = snapshot.nodes.get(&node.node_id) + if let Some((name, _)) = snapshot + .nodes + .get(&node.node_id) .and_then(|raw| raw.typed_view().value("componentPropertyReferences")) .and_then(|references| references.get("characters")) .and_then(Value::as_str) @@ -39,11 +50,19 @@ fn bind_text_properties( && raw.node_type == "TEXT" { let layer = raw.typed_view().name().unwrap_or_default(); - let base = if layer.is_ascii() && layer.chars().any(|character| character.is_ascii_alphabetic()) { - let mut parts = layer.split(|character: char| !character.is_ascii_alphanumeric()) + let base = if layer.is_ascii() + && layer + .chars() + .any(|character| character.is_ascii_alphabetic()) + { + let mut parts = layer + .split(|character: char| !character.is_ascii_alphanumeric()) .filter(|part| !part.is_empty()); let mut name = parts.next().unwrap_or_default().to_owned(); - if name.chars().all(|character| !character.is_ascii_lowercase()) { + if name + .chars() + .all(|character| !character.is_ascii_lowercase()) + { name.make_ascii_lowercase(); } else if let Some(first) = name.get_mut(..1) { first.make_ascii_lowercase(); @@ -65,22 +84,70 @@ fn bind_text_properties( }; let mut name = base.clone(); let mut suffix = 2; - while used.contains(&name) || matches!(name.as_str(), - "break" | "case" | "catch" | "class" | "const" | "continue" | "debugger" | - "default" | "delete" | "do" | "else" | "enum" | "export" | "extends" | - "false" | "finally" | "for" | "function" | "if" | "import" | "in" | - "instanceof" | "new" | "null" | "return" | "super" | "switch" | "this" | - "throw" | "true" | "try" | "typeof" | "var" | "void" | "while" | "with" | - "let" | "yield" | "await" | "implements" | "interface" | "package" | - "private" | "protected" | "public" | "static" | "eval" | "arguments") { + while used.contains(&name) + || matches!( + name.as_str(), + "break" + | "case" + | "catch" + | "class" + | "const" + | "continue" + | "debugger" + | "default" + | "delete" + | "do" + | "else" + | "enum" + | "export" + | "extends" + | "false" + | "finally" + | "for" + | "function" + | "if" + | "import" + | "in" + | "instanceof" + | "new" + | "null" + | "return" + | "super" + | "switch" + | "this" + | "throw" + | "true" + | "try" + | "typeof" + | "var" + | "void" + | "while" + | "with" + | "let" + | "yield" + | "await" + | "implements" + | "interface" + | "package" + | "private" + | "protected" + | "public" + | "static" + | "eval" + | "arguments" + ) + { name = format!("{base}{suffix}"); suffix += 1; } used.insert(name.clone()); let characters = raw.typed_view().string("characters").unwrap_or_default(); let default = match node.content.as_deref() { - Some(content) if content.starts_with("{{") && content.ends_with('}') => content[1..content.len()-1].to_owned(), - _ => serde_json::to_string(characters).unwrap_or_default(), + Some(content) if content.starts_with("{{") && content.ends_with('}') => { + content[1..content.len() - 1].to_owned() + } + _ => serde_json::to_string(&super::text::normalize_render_characters(characters)) + .unwrap_or_default(), }; defaults.insert(name.clone(), default); node.content = Some(format!("{{{name}}}")); diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/tree_signature.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/tree_signature.rs new file mode 100644 index 00000000..271f7175 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/tree_signature.rs @@ -0,0 +1,44 @@ +/// What a node looks like, ignoring the values it holds. +/// +/// This is the plugin's `getChildStructureSignature`. Prop *names* count but +/// their values do not, so a node that only changed a colour still matches +/// itself in another variant — less the names the plugin writes on every +/// node regardless, see [`KEYS_THE_PLUGIN_ALWAYS_WRITES`]. +pub(super) fn structure_signature(tree: &Tree) -> String { + let absolute = tree + .props + .get("pos") + .is_some_and(|value| value == "absolute"); + let laid_out = COMPONENTS_THE_PLUGIN_ALWAYS_GIVES_DISPLAY.contains(&tree.component.as_str()); + let props = tree + .props + .keys() + .filter(|name| !KEYS_THE_PLUGIN_ALWAYS_WRITES.contains(&name.as_str())) + .filter(|name| { + !(absolute && KEYS_THE_PLUGIN_WRITES_ON_AN_ABSOLUTE_NODE.contains(&name.as_str())) + }) + .filter(|name| !(laid_out && name.as_str() == "display")) + // `getObjectFitProps` answers for every image-filled asset, `null` + // for the scale modes that need nothing said. + .filter(|name| !(tree.component == "Image" && name.as_str() == "objectFit")) + .cloned() + .collect::>() + .join(","); + let children = tree + .children + .iter() + .map(structure_signature) + .collect::>() + .join("|"); + let kind = if tree.is_component { + "component" + } else { + "node" + }; + let text = if tree.content.is_some() { + "text" + } else { + "notext" + }; + format!("{}::{kind}::{text}::{props}::{children}", tree.component) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/unnamed_state_axis.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/unnamed_state_axis.rs new file mode 100644 index 00000000..6c664241 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/unnamed_state_axis.rs @@ -0,0 +1,128 @@ +/// Autogenerated property names do not describe business state. Infer only a +/// complete default/hover[/active] axis with identical non-root state content. +fn promote_unnamed_state_axis( + definitions: &mut [Definition], + records: &mut [Record], +) -> Option { + if definitions + .iter() + .any(|d| d.name.eq_ignore_ascii_case("effect")) + { + return None; + } + let candidates = definitions + .iter() + .enumerate() + .filter(|(_, d)| { + let name = d + .source + .split('#') + .next() + .unwrap_or_default() + .chars() + .filter(|c| !c.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + let number = name + .strip_prefix("property") + .or_else(|| name.strip_prefix("\u{c18d}\u{c131}")); + !d.boolean + && number.is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) + && d.default == "default" + && d.options.iter().any(|v| v == "default") + && d.options.iter().any(|v| v == "hover") + && d.options + .iter() + .all(|v| matches!(v.as_str(), "default" | "hover" | "active")) + }) + .map(|(index, _)| index) + .collect::>(); + let [index] = candidates.as_slice() else { + return None; + }; + let definition = &definitions[*index]; + let mut groups = BTreeMap::, Vec<&Record>>::new(); + for record in records.iter() { + let fixed = record + .values + .iter() + .filter(|(name, _)| *name != &definition.source) + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + groups.entry(fixed).or_default().push(record); + } + if groups.is_empty() + || !groups.values().all(|group| { + let Some(baseline) = group + .iter() + .find(|r| r.values.get(&definition.source) == Some(&definition.default)) + else { + return false; + }; + definition.options.iter().all(|option| { + group + .iter() + .any(|r| r.values.get(&definition.source) == Some(option)) + }) && group + .iter() + .all(|r| root_only_state_change(&baseline.tree, &r.tree, true)) + }) + { + return None; + } + let source = definitions[*index].source.clone(); + definitions[*index].name = "effect".into(); + definitions[*index].source = "effect".into(); + for record in records { + if let Some(value) = record.values.remove(&source) { + record.values.insert("effect".into(), value); + } + } + Some(source) +} + +fn root_only_state_change(base: &Tree, other: &Tree, root: bool) -> bool { + base.component == other.component + && !base.is_component + && !other.is_component + && base.component != "Image" + && base.content == other.content + && base.visible_when == other.visible_when + && base.children.len() == other.children.len() + && (root || (base.props == other.props && base.node_name == other.node_name)) + && base + .children + .iter() + .zip(&other.children) + .all(|(left, right)| root_only_state_change(left, right, false)) +} + +fn state_axis_diagnostics( + set: &RawNode, + definitions: &[Definition], + inferred: Option<&str>, +) -> Vec { + let mut diagnostics = Vec::new(); + if let Some(axis) = inferred { + diagnostics.push(Diagnostic { + code:"DEVUP_CODEGEN_UNNAMED_STATE_AXIS".into(), + message:"An autogenerated pure default/hover/active axis with identical captured descendants was projected as root CSS states. Runtime semantics and event wiring are still consumer responsibilities.".into(), + node_id:Some(set.id.clone()),severity:Some(DiagnosticSeverity::Info), + details:Some(serde_json::json!({"sourceAxis":axis,"mapping":{"default":"&","hover":"_hover","active":"_active"},"runtimeVerified":false,"output":"componentTsx"})), + ..Diagnostic::default() + }); + } + for axis in definitions.iter().filter(|d| { + d.name.eq_ignore_ascii_case("effect") + && d.options.iter().any(|v| effect_selector(v).is_none()) + }) { + diagnostics.push(Diagnostic { + code:"DEVUP_CODEGEN_MIXED_STATE_AXIS".into(), + message:"An interaction axis mixes CSS states with runtime variants. It remains an explicit prop; map states to the captured viewport/base owner instead of coercing every value to a pseudo-state.".into(), + node_id:Some(set.id.clone()),severity:Some(DiagnosticSeverity::Warning), + details:Some(serde_json::json!({"sourceAxis":axis.source,"options":axis.options,"preservedAsProp":true,"runtimeVerified":false,"output":"componentTsx"})), + ..Diagnostic::default() + }); + } + diagnostics +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs index 4bec688a..6a8d3ec0 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs @@ -29,8 +29,8 @@ pub(super) struct Tree { /// The variant options this node is drawn at, when it is not drawn at all /// of them. Rendered as the condition guarding it. pub drawn_when: Vec, - /// A JSX comment to write immediately above this node, naming the - /// component it was spelled out from. + /// Internal provenance naming the inlined component. Renderers never + /// copy this inspection evidence into delivered application source. pub leading_comment: Option, /// In a responsive merge, the node each slot's width drew this element /// from and that node's text; empty everywhere else. @@ -68,6 +68,7 @@ enum Expression { Variant(String, Vec<(String, Expression)>), Conditional(String, String, String), } + pub(super) fn generate_variant_component_set( snapshot: &Snapshot, set_id: &str, @@ -147,8 +148,14 @@ pub(super) fn generate_variant_component_set( } } } + let inferred_state_axis = if merge_all { promote_unnamed_state_axis(&mut definitions, &mut records) } else { None }; + let state_diagnostics = state_axis_diagnostics(set, &definitions, inferred_state_axis.as_deref()); // A boolean property is not part of a variant's identity — it never appears // in `variantProperties` — so filtering records by it matches nothing. + if let Some(mut output) = preserve_structural_variants(snapshot, set, &records, &definitions)? { + output.diagnostics.extend(state_diagnostics); + return Ok(Some(output)); + } let default_filters = definitions .iter() .filter(|definition| !definition.boolean) @@ -273,6 +280,8 @@ pub(super) fn generate_variant_component_set( fallback: Some("default-variant-structure".to_owned()), ..Diagnostic::default() }) + .chain(viewport_alt_diagnostics(&records, &root, &[])) + .chain(state_diagnostics) .collect(), source_map: crate::provenance::SourceMap::empty(), projection_trace: crate::provenance::ProjectionTrace::default(), @@ -513,13 +522,22 @@ fn project_tree_inner( is_render_root: bool, keep_instances: bool, ) -> Result { + if options.inline_instances && let Some(visible)=captured_instance_visibility(snapshot,node) { + if !visible { return Ok(concretely_hidden_tree(node)); } + let mut resolved=node.clone(); + resolved.fields.insert("visible".into(),Value::Bool(true)); + if let Some(references)=resolved.fields.get_mut("componentPropertyReferences").and_then(Value::as_object_mut) { + references.remove("visible"); + } + return project_tree_inner(snapshot,&resolved,options,is_render_root,keep_instances); + } let view = node.typed_view(); if keep_instances && view.node_type() == "INSTANCE" { // An instance whose component is nothing but a shape is spelled out // rather than referenced. `` would be a component whose entire // body is one masked Box, so the reference costs a file and an import - // and says less than the Box does. The plugin does the same, and marks - // the spot with a `{/* */}` comment. + // and says less than the Box does. Retain the component relationship + // as structured evidence instead of an application source comment. let mut props = instance_variant_props(&view); let mut expanded = project_tree_inner(snapshot, node, options, is_render_root, false)?; if (is_asset_leaf(&expanded) && !set_has_non_asset_variant(snapshot, node)) @@ -613,6 +631,7 @@ fn project_tree_inner( None, super::image::original_image(node, &options.asset_policies), ); + if options.inline_instances { super::overlay_anchors::apply_props(snapshot, node, &mut props); } let mut used_tokens = BTreeSet::new(); style::push_style_props( snapshot, @@ -639,6 +658,7 @@ fn project_tree_inner( &mut used_tokens, &mut props, ); + super::semantics::push_semantic_props(snapshot, node, &component, &mut props); style::tokenize_lengths( snapshot, node, @@ -647,7 +667,7 @@ fn project_tree_inner( &mut used_tokens, ); text::elide_tokenized_typography(&mut props, &options.typography_values, &options.length_tokens); - if visible_when(&view).is_some() && view.bool("visible") == Some(false) { + if !options.inline_instances && visible_when(&view).is_some() && view.bool("visible") == Some(false) { props.retain(|(name, _)| name != "display"); } if asset.is_some() { @@ -882,7 +902,7 @@ fn merged_props( None, path.unwrap_or_default(), &prop, - )?; + ).or_else(|| (prop == "alt").then(|| defaults.get("alt").cloned().map(Expression::Literal)).flatten())?; if prop == "typography" { return Some(typography_attribute(&expression)); } @@ -946,48 +966,3 @@ const KEYS_THE_PLUGIN_WRITES_ON_AN_ABSOLUTE_NODE: &[&str] = /// `getAutoLayoutProps` writes `display` on every laid-out node and the /// renderer drops the `flex`; a hidden one differs by value, not by key. const COMPONENTS_THE_PLUGIN_ALWAYS_GIVES_DISPLAY: &[&str] = &["Flex", "VStack", "Center", "Grid"]; - -/// What a node looks like, ignoring the values it holds. -/// -/// This is the plugin's `getChildStructureSignature`. Prop *names* count but -/// their values do not, so a node that only changed a colour still matches -/// itself in another variant — less the names the plugin writes on every -/// node regardless, see [`KEYS_THE_PLUGIN_ALWAYS_WRITES`]. -pub(super) fn structure_signature(tree: &Tree) -> String { - let absolute = tree - .props - .get("pos") - .is_some_and(|value| value == "absolute"); - let laid_out = COMPONENTS_THE_PLUGIN_ALWAYS_GIVES_DISPLAY.contains(&tree.component.as_str()); - let props = tree - .props - .keys() - .filter(|name| !KEYS_THE_PLUGIN_ALWAYS_WRITES.contains(&name.as_str())) - .filter(|name| { - !(absolute && KEYS_THE_PLUGIN_WRITES_ON_AN_ABSOLUTE_NODE.contains(&name.as_str())) - }) - .filter(|name| !(laid_out && name.as_str() == "display")) - // `getObjectFitProps` answers for every image-filled asset, `null` - // for the scale modes that need nothing said. - .filter(|name| !(tree.component == "Image" && name.as_str() == "objectFit")) - .cloned() - .collect::>() - .join(","); - let children = tree - .children - .iter() - .map(structure_signature) - .collect::>() - .join("|"); - let kind = if tree.is_component { - "component" - } else { - "node" - }; - let text = if tree.content.is_some() { - "text" - } else { - "notext" - }; - format!("{}::{kind}::{text}::{props}::{children}", tree.component) -} diff --git a/crates/devup-mcp-devup-ui/src/provenance/add_flattened_resource_entries.rs b/crates/devup-mcp-devup-ui/src/provenance/add_flattened_resource_entries.rs index f905e7d9..bd068884 100644 --- a/crates/devup-mcp-devup-ui/src/provenance/add_flattened_resource_entries.rs +++ b/crates/devup-mcp-devup-ui/src/provenance/add_flattened_resource_entries.rs @@ -1,5 +1,3 @@ - - fn add_flattened_resource_entries( tsx: &str, snapshot: &Snapshot, @@ -296,7 +294,9 @@ const PROP_SOURCES: &[(&str, &str)] = &[ ("typography", "textStyleId"), ("w", "width"), ("whiteSpace", "textAutoResize"), - ("wordBreak", "characters"), + ("wordBreak", "styledTextSegments"), + ("wordBreak", "textAutoResize"), + ("wordBreak", "width"), ]; fn add_text_entries( @@ -310,29 +310,27 @@ fn add_text_entries( ) { let source = &tsx[range.start..range.end]; let view = node.typed_view(); - let mut text_segments = view + let segments = view .value("styledTextSegments") - .and_then(serde_json::Value::as_array) + .and_then(serde_json::Value::as_array); + let rendered = crate::codegen::render_segment_characters(segments.map_or(&[], Vec::as_slice)); + let mut text_segments = segments .into_iter() .flatten() - .filter_map(|segment| { - segment - .get("characters") - .and_then(serde_json::Value::as_str) - .map(|characters| (characters, Some(segment))) - }) - .filter(|(characters, _)| !characters.is_empty()) + .zip(rendered) + .filter(|(_, characters)| !characters.is_empty()) + .map(|(segment, characters)| (characters, Some(segment))) .collect::>(); if text_segments.is_empty() && let Some(characters) = view .string("characters") .filter(|characters| !characters.is_empty()) { - text_segments.push((characters, None)); + text_segments.push((characters.to_owned(), None)); } let mut cursor = 0; for (characters, segment) in text_segments { - if let Some((start, end)) = find_text_span(source, characters, cursor) { + if let Some((start, end)) = find_text_span(source, &characters, cursor) { // A rich-text wrapper can carry its own advance and weight. // Attribute ownership belongs to the segment whose text directly // follows this opening, rather than the node's default metrics. diff --git a/crates/devup-mcp-devup-ui/src/provenance/baked_into_assets.rs b/crates/devup-mcp-devup-ui/src/provenance/baked_into_assets.rs index 5439516c..b4446350 100644 --- a/crates/devup-mcp-devup-ui/src/provenance/baked_into_assets.rs +++ b/crates/devup-mcp-devup-ui/src/provenance/baked_into_assets.rs @@ -1,5 +1,3 @@ - - /// Nodes whose variable bindings no correct generator could write out. /// /// An asset drawn in one colour becomes a Box masked to its shape with `bg` @@ -123,20 +121,15 @@ fn text_sources(snapshot: &Snapshot, text_nodes: &BTreeSet<&str>) -> Vec<(String let Some(node) = snapshot.nodes.get(*node_id) else { continue; }; - let segments = node - .typed_view() - .value("styledTextSegments") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(|segment| { - segment - .get("characters") - .and_then(serde_json::Value::as_str) - }) - .filter(|characters| !characters.is_empty()) - .map(str::to_owned) - .collect::>(); + let segments = crate::codegen::render_segment_characters( + node.typed_view() + .value("styledTextSegments") + .and_then(serde_json::Value::as_array) + .map_or(&[], Vec::as_slice), + ) + .into_iter() + .filter(|characters| !characters.is_empty()) + .collect::>(); if segments.is_empty() { if let Some(characters) = node .typed_view() @@ -643,6 +636,7 @@ pub(crate) fn finalize_tsx( } let mut prop_sources = PROP_SOURCES.to_vec(); + prop_sources.extend(wrapped_gaps::sources(node)); if crate::codegen::asset_kind(snapshot, node).is_none() && crate::codegen::single_outside_stroke(&node.typed_view()).is_some() { @@ -718,10 +712,20 @@ pub(crate) fn finalize_tsx( }) .flatten(); let resolution = if *prop == "whiteSpace" + && value == "pre" + && crate::codegen::preserves_intrinsic_text_width(&node.typed_view()) + { + "derived-intrinsic-text-width" + } else if *prop == "whiteSpace" && value == "pre-wrap" && crate::codegen::preserves_hard_break_spaces(&node.typed_view()) { "derived-hard-break-whitespace" + } else if *prop == "whiteSpace" + && value == "pre-wrap" + && crate::codegen::preserves_wrapped_ascii_spaces(&node.typed_view()) + { + "derived-wrapped-ascii-whitespace" } else if *prop == "boxShadow" && crate::codegen::asset_kind(snapshot, node).is_none() && crate::codegen::single_outside_stroke(&node.typed_view()).is_some() @@ -752,8 +756,14 @@ pub(crate) fn finalize_tsx( "raw-fallback" }; let source_property = if *prop == "whiteSpace" + && value == "pre" + && crate::codegen::preserves_intrinsic_text_width(&node.typed_view()) + { + "textAutoResize" + } else if *prop == "whiteSpace" && value == "pre-wrap" - && crate::codegen::preserves_hard_break_spaces(&node.typed_view()) + && (crate::codegen::preserves_hard_break_spaces(&node.typed_view()) + || crate::codegen::preserves_wrapped_ascii_spaces(&node.typed_view())) { if node.typed_view().string("characters").is_some() { "characters" @@ -890,4 +900,4 @@ pub(crate) fn finalize_tsx( style_tokens: style_tokens.clone(), }, ) -} \ No newline at end of file +} diff --git a/crates/devup-mcp-devup-ui/src/provenance/line_height_calculation.rs b/crates/devup-mcp-devup-ui/src/provenance/line_height_calculation.rs new file mode 100644 index 00000000..81df1208 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/provenance/line_height_calculation.rs @@ -0,0 +1,66 @@ +use super::SourceMap; + +impl SourceMap { + /// Explain unit conversions the generator performs, so a reader can + /// reproduce `lineHeight="26px"` from the raw `{unit: PERCENT, value: 160}`. + pub(crate) fn describe_conversions(&mut self, snapshot: &devup_mcp_figma::Snapshot) { + for entry in &mut self.entries { + if let (Some(id), Some(generated)) = ( + entry.node_id.as_deref(), + entry.generated_property.as_deref(), + ) && let Some(calculation) = + crate::codegen::absolute_border_origin_calculation(snapshot, id, generated) + { + entry.calculation = Some(calculation); + } + if entry.property.as_deref() != Some("lineHeight") { + continue; + } + let Some(node) = entry + .node_id + .as_deref() + .and_then(|id| snapshot.nodes.get(id)) + else { + continue; + }; + let view = node.typed_view(); + let Some(line_height) = view.value("lineHeight") else { + continue; + }; + if line_height["unit"] != "PERCENT" { + continue; + } + let percent = + (line_height["value"].as_f64().unwrap_or_default() * 100.0).round() / 100.0; + let bound = view + .value("boundVariables") + .and_then(|b| b.get("fontSize")) + .is_some(); + entry.calculation = Some( + if entry + .generated_property + .as_deref() + .is_some_and(|prop| prop.starts_with("lineHeight=\"round(")) + { + format!( + "round(nearest, {}em, 1px) rounds the effective CSS fontSize per mode; source percentage {percent}% is not frozen to captured fontSize", + percent / 100.0 + ) + } else { + match ( + view.value("fontSize").and_then(serde_json::Value::as_f64), + bound, + ) { + (Some(size), false) => format!( + "round(fontSize * percent / 100) = round({size} * {percent} / 100) = {}px", + (size * percent / 100.0).round() + ), + _ => format!( + "{percent}% kept as a percentage because fontSize is variable-bound or unknown" + ), + } + }, + ); + } + } +} diff --git a/crates/devup-mcp-devup-ui/src/provenance/resolution.rs b/crates/devup-mcp-devup-ui/src/provenance/resolution.rs index 9ca108ff..105a2f8d 100644 --- a/crates/devup-mcp-devup-ui/src/provenance/resolution.rs +++ b/crates/devup-mcp-devup-ui/src/provenance/resolution.rs @@ -12,6 +12,8 @@ pub fn resolution_semantics() -> Value { "derived-lone-child-center":include_str!("../text/resolution-resolution_semantics-2373f8c7.md"), "derived-single-edge-outside-stroke":include_str!("../text/resolution-resolution_semantics-6489c0ec.md"), "derived-hard-break-whitespace":include_str!("../text/resolution-resolution_semantics-2c171ecb.md"), + "derived-wrapped-ascii-whitespace":"Captured fixed-inline-width HEIGHT text uses pre-wrap to retain authored repeated/edge ASCII spaces and soft wrapping. Lists, tabs, clamps and unknown/HUG widths are excluded.", + "derived-intrinsic-text-width":"Captured WIDTH_AND_HEIGHT text uses whiteSpace=pre to retain its intrinsic unwrapped width, spaces, and explicit breaks; clamped/list text is excluded.", "verified-explicit-dimension":"Error-free source = emitted px.", "verified-layout-sizing":"FIXED/FILL/layoutGrow to dimension/flex.", "accounted-for-content-sizing":"textAutoResize omission; pixels unmeasured.", diff --git a/crates/devup-mcp-devup-ui/src/provenance/sizing.rs b/crates/devup-mcp-devup-ui/src/provenance/sizing.rs index 9f46b911..982cce13 100644 --- a/crates/devup-mcp-devup-ui/src/provenance/sizing.rs +++ b/crates/devup-mcp-devup-ui/src/provenance/sizing.rs @@ -1,4 +1,4 @@ - // Source sections share this module to preserve privacy and test names. include!("sizing/types_and_helpers.rs"); +include!("sizing/transferred_minimum.rs"); include!("sizing/account_for_sizing.rs"); diff --git a/crates/devup-mcp-devup-ui/src/provenance/sizing/transferred_minimum.rs b/crates/devup-mcp-devup-ui/src/provenance/sizing/transferred_minimum.rs new file mode 100644 index 00000000..8c19e43e --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/provenance/sizing/transferred_minimum.rs @@ -0,0 +1,65 @@ +fn transferred_minimum_is_emitted( + snapshot: &Snapshot, + node: &devup_mcp_figma::RawNode, + tag: &str, +) -> Option { + let width = crate::codegen::horizontal_transferred_image_minimum(snapshot, node)?; + if !tag.trim_start().starts_with("().ok()? / y.trim().parse::().ok()?; + ((height - emitted_height).abs() <= 0.5 + && ratio.is_finite() + && (height * ratio - width).abs() <= 0.5) + .then_some(width) +} + +/// Positive-free-space flex allocation with only source-proven replaced-image +/// minimums. Unknown text/content constraints still reject in the caller. +fn allocate_with_transferred_minimums( + siblings: &mut [Value], + free: f64, + total_grow: f64, +) -> Result<(), &'static str> { + let mut remaining = free; + let mut grow = total_grow; + loop { + let freezing = siblings + .iter() + .enumerate() + .filter_map(|(index, sibling)| { + let own_grow = sibling["grow"].as_f64()?; + let minimum = sibling["transferredMinimum"].as_f64()?; + (sibling["allocatedWidth"].is_null() + && remaining * own_grow / grow.max(1.0) < minimum) + .then_some((index, minimum, own_grow)) + }) + .collect::>(); + if freezing.is_empty() { + break; + } + for (index, minimum, own_grow) in freezing { + siblings[index]["allocatedWidth"] = json!(minimum); + remaining -= minimum; + grow -= own_grow; + } + if remaining < -0.5 { + return Err("transferred-minimum-exceeds-parent"); + } + } + for sibling in siblings { + if let Some(g) = sibling["grow"].as_f64() { + let allocated = sibling["allocatedWidth"] + .as_f64() + .unwrap_or(remaining * g / grow.max(1.0)); + sibling["allocatedWidth"] = json!(allocated); + if (allocated - sibling["capturedWidth"].as_f64().unwrap()).abs() > 0.5 { + return Err("allocated-width-mismatch"); + } + } + } + Ok(()) +} diff --git a/crates/devup-mcp-devup-ui/src/provenance/sizing/types_and_helpers.rs b/crates/devup-mcp-devup-ui/src/provenance/sizing/types_and_helpers.rs index 0ce5f74b..3a242981 100644 --- a/crates/devup-mcp-devup-ui/src/provenance/sizing/types_and_helpers.rs +++ b/crates/devup-mcp-devup-ui/src/provenance/sizing/types_and_helpers.rs @@ -502,18 +502,13 @@ fn horizontal_allocation( if matches!(prop(tag, "pos"), Some("absolute" | "fixed")) { return Err("sibling-position-mismatch"); } - if [ - "m", - "mx", - "ml", - "mr", - "minW", - "maxW", - "flexBasis", - "flexGrow", - ] - .iter() - .any(|p| prop(tag, p).is_some()) + let transferred_minimum = transferred_minimum_is_emitted(snapshot, sibling, tag); + if prop(tag, "minW").is_some() && transferred_minimum.is_none() { + return Err("sibling-flex-constraint"); + } + if ["m", "mx", "ml", "mr", "maxW", "flexBasis", "flexGrow"] + .iter() + .any(|p| prop(tag, p).is_some()) { return Err("sibling-flex-constraint"); } @@ -532,16 +527,22 @@ fn horizontal_allocation( .split(|c: char| c.is_whitespace() || c == '/') .next() .unwrap_or(""); - if !matches!(primitive, "Box" | "Flex" | "Center" | "VStack") { + if !matches!(primitive, "Box" | "Flex" | "Center" | "VStack") + && transferred_minimum.is_none() + { return Err("intrinsic-replaced-minimum-unverified"); } let grow = prop(tag, "flex") .and_then(|s| s.parse::().ok()) .filter(|g| g.is_finite() && *g > 0.0) .ok_or("flex-shorthand-unverified")?; - if prop(tag, "w").is_some() + // With flex-basis zero, a 100% preferred width is not the basis. + // Empty primitive items still have zero content minimum; the + // bounded image's transferred minimum is below this parent cap. + if prop(tag, "w").is_some_and(|value| value != "100%") + || transferred_minimum.is_some_and(|minimum| minimum > width + 0.5) || prop(tag, "boxSize").is_some() - || prop(tag, "aspectRatio").is_some() + || (prop(tag, "aspectRatio").is_some() && transferred_minimum.is_none()) || sv.child_ids().next().is_some() || sibling.node_type == "TEXT" || [ @@ -571,7 +572,7 @@ fn horizontal_allocation( if sid == id { own_grow = grow; } - siblings.push(json!({"nodeId":sid,"grow":grow,"basis":"0%","shrink":prop(tag,"flexShrink").unwrap_or("1"),"capturedWidth":measured})); + siblings.push(json!({"nodeId":sid,"grow":grow,"basis":"0%","shrink":prop(tag,"flexShrink").unwrap_or("1"),"capturedWidth":measured,"transferredMinimum":transferred_minimum})); } else { if prop(tag, "flex").is_some() { return Err("sibling-flex-basis-unverified"); @@ -602,17 +603,15 @@ fn horizontal_allocation( } // With positive free space, shrink does not participate. Empty flex items // have zero automatic minimum; constraints and intrinsic children reject above. - let divisor = total_grow.max(1.0); - for sibling in &siblings { - if let Some(g) = sibling["grow"].as_f64() - && (free * g / divisor - sibling["capturedWidth"].as_f64().unwrap()).abs() > 0.5 - { - return Err("allocated-width-mismatch"); - } - } + allocate_with_transferred_minimums(&mut siblings, free, total_grow)?; + let own_width = siblings + .iter() + .find(|s| s["nodeId"] == id) + .and_then(|s| s["allocatedWidth"].as_f64()) + .ok_or("child-allocation-missing")?; Ok( json!({"parentWidth":width,"padding":padding,"gap":gap,"siblings":siblings,"freeSpace":free, - "allocatedWidth":free*own_grow/divisor,"totalGrow":total_grow,"shrinkActive":false}), + "allocatedWidth":own_width,"totalGrow":total_grow,"shrinkActive":false}), ) }; match check() { @@ -621,7 +620,7 @@ fn horizontal_allocation( proof["allocation"] = allocation; proof["reasonCode"] = json!("verified-main-axis-allocation"); proof["reason"] = json!( - "The emitted flex shorthand, parent width, padding, siblings and gaps preserve positive remaining-space allocation; no minimum or shrink constraint overrides it." + "The emitted flex shorthand, parent width, padding, siblings, gaps and any source-proven transferred image minimum preserve positive remaining-space allocation; unknown intrinsic constraints are not waived." ); } Err(reason) => { @@ -856,4 +855,4 @@ pub(super) fn sizing_mapping_matches( && (!crate::codegen::vertical_fill_needs_minimum_reset(snapshot, node) || prop(child_tag, "minH") == Some("0")) && fill_axis_is_established(snapshot, output, id, "height") -} \ No newline at end of file +} diff --git a/crates/devup-mcp-devup-ui/src/provenance/source_map_and_contracts.rs b/crates/devup-mcp-devup-ui/src/provenance/source_map_and_contracts.rs index fb15dc50..3d6431c7 100644 --- a/crates/devup-mcp-devup-ui/src/provenance/source_map_and_contracts.rs +++ b/crates/devup-mcp-devup-ui/src/provenance/source_map_and_contracts.rs @@ -14,10 +14,14 @@ mod absolute_bounds; pub mod attributes; #[path = "fingerprint.rs"] mod fingerprint; +#[path = "line_height_calculation.rs"] +mod line_height_calculation; #[path = "resolution.rs"] mod resolution; #[path = "sizing.rs"] mod sizing; +#[path = "wrapped_gaps.rs"] +mod wrapped_gaps; pub use fingerprint::design_fingerprints; pub use resolution::resolution_semantics; pub(crate) use sizing::absolute_component_verification; @@ -143,7 +147,11 @@ impl SourceMap { .map(|end| source[at..at + 7 + end].to_owned()) }) } - ("derived-hard-break-whitespace", _, Some(source)) => Some(source.trim().into()), + ( + "derived-hard-break-whitespace" | "derived-wrapped-ascii-whitespace", + _, + Some(source), + ) => Some(source.trim().into()), (_, "characters", Some(_)) => Some("children".into()), (_, _, Some(source)) if !source.trim().is_empty() => Some(source.trim().into()), _ => None, @@ -154,50 +162,6 @@ impl SourceMap { } } - /// Explain unit conversions the generator performs, so a reader can - /// reproduce `lineHeight="26px"` from the raw `{unit: PERCENT, value: 160}`. - pub(crate) fn describe_conversions(&mut self, snapshot: &devup_mcp_figma::Snapshot) { - for entry in &mut self.entries { - if entry.property.as_deref() != Some("lineHeight") { - continue; - } - let Some(node) = entry - .node_id - .as_deref() - .and_then(|id| snapshot.nodes.get(id)) - else { - continue; - }; - let view = node.typed_view(); - let Some(line_height) = view.value("lineHeight") else { - continue; - }; - if line_height["unit"] != "PERCENT" { - continue; - } - let percent = - (line_height["value"].as_f64().unwrap_or_default() * 100.0).round() / 100.0; - let bound = view - .value("boundVariables") - .and_then(|b| b.get("fontSize")) - .is_some(); - entry.calculation = Some( - match ( - view.value("fontSize").and_then(serde_json::Value::as_f64), - bound, - ) { - (Some(size), false) => format!( - "round(fontSize * percent / 100) = round({size} * {percent} / 100) = {}px", - (size * percent / 100.0).round() - ), - _ => format!( - "{percent}% kept as a percentage because fontSize is variable-bound or unknown" - ), - }, - ); - } - } - pub fn empty() -> Self { Self { version: 2, @@ -714,6 +678,11 @@ pub fn validate_fidelity( snapshot, output, node_id, property, source, ); } + if let Some(matches) = + wrapped_gaps::mapping_matches(snapshot, node_id, property, source) + { + return matches; + } layout_source_matches(property, source) && dimension_value_matches( snapshot, output, node_id, property, source, @@ -977,4 +946,4 @@ fn semantic_nodes<'a>(snapshot: &'a Snapshot, root_id: &str) -> BTreeSet<&'a str } } visible -} \ No newline at end of file +} diff --git a/crates/devup-mcp-devup-ui/src/provenance/wrapped_gaps.rs b/crates/devup-mcp-devup-ui/src/provenance/wrapped_gaps.rs new file mode 100644 index 00000000..c215fc36 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/provenance/wrapped_gaps.rs @@ -0,0 +1,61 @@ +use super::*; + +/// Main-axis gap comes from itemSpacing; cross-axis gap is independent. +/// Only explicitly captured flex WRAP axes participate, never grid gaps. +pub(super) fn sources(node: &devup_mcp_figma::RawNode) -> Vec<(&'static str, &'static str)> { + let view = node.typed_view(); + if view.string("layoutWrap") != Some("WRAP") { + return Vec::new(); + } + let fields = match view.string("layoutMode") { + Some("HORIZONTAL") => [ + ("columnGap", "itemSpacing"), + ("rowGap", "counterAxisSpacing"), + ], + Some("VERTICAL") => [ + ("rowGap", "itemSpacing"), + ("columnGap", "counterAxisSpacing"), + ], + _ => return Vec::new(), + }; + fields + .into_iter() + .filter(|(_, field)| { + !node.field_errors.contains_key(*field) + && view + .number(field) + .is_some_and(|value| value.is_finite() && value >= 0.0) + }) + .collect() +} + +/// A similarly named property or the other axis cannot satisfy this mapping. +/// Verify the exact captured main gap, including the generator's px rounding. +pub(super) fn mapping_matches( + snapshot: &Snapshot, + node_id: &str, + field: &str, + source: &str, +) -> Option { + if field != "itemSpacing" { + return None; + } + let node = snapshot.nodes.get(node_id)?; + if node.typed_view().string("layoutWrap") != Some("WRAP") { + return None; + } + if !source.starts_with("rowGap=") && !source.starts_with("columnGap=") { + return None; + } + Some(sources(node).into_iter().any(|(property, mapped)| { + mapped == field + && source + .strip_prefix(&format!("{property}=\"")) + .and_then(|value| value.strip_suffix("px\"")) + .and_then(|value| value.parse::().ok()) + .zip(node.typed_view().number(field)) + .is_some_and(|(actual, captured)| { + actual.is_finite() && actual == (captured * 100.0).round() / 100.0 + }) + })) +} diff --git a/crates/devup-mcp-devup-ui/src/theme/devup_json/theme_generation.rs b/crates/devup-mcp-devup-ui/src/theme/devup_json/theme_generation.rs index 68c8301c..4177140f 100644 --- a/crates/devup-mcp-devup-ui/src/theme/devup_json/theme_generation.rs +++ b/crates/devup-mcp-devup-ui/src/theme/devup_json/theme_generation.rs @@ -511,6 +511,7 @@ pub fn generate_devup_json( if slots[slot].is_none() { let mut value = typography_value(&style.value, &variable_names); value["fontSize"] = Value::String(format_px(size)); + materialize_percentage_advance(&mut value, &style.value, size); slots[slot] = Some(value); } } diff --git a/crates/devup-mcp-devup-ui/src/theme/devup_json/typography_values.rs b/crates/devup-mcp-devup-ui/src/theme/devup_json/typography_values.rs index a16364ef..d3bbb080 100644 --- a/crates/devup-mcp-devup-ui/src/theme/devup_json/typography_values.rs +++ b/crates/devup-mcp-devup-ui/src/theme/devup_json/typography_values.rs @@ -34,6 +34,17 @@ fn typography_problem(style: &Value) -> Option { None } +fn materialize_percentage_advance(entry: &mut Value, style: &Value, size: f64) { + // Mode projection has resolved the size, so the same integer advance as a + // fixed-size Figma style now applies. A bound line height remains its token. + if style.pointer("/boundVariables/lineHeight").is_none() + && style.pointer("/lineHeight/unit").and_then(Value::as_str) == Some("PERCENT") + && let Some(percent) = style.pointer("/lineHeight/value").and_then(Value::as_f64) + { + entry["lineHeight"] = Value::String(format_px((size * percent / 100.0).round())); + } +} + /// A text style's value as devup-ui reads it — the plugin's /// `textStyleToTypography`. /// diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/delivery_quality.rs b/crates/devup-mcp-devup-ui/src/ui_validate/delivery_quality.rs new file mode 100644 index 00000000..4101c360 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/delivery_quality.rs @@ -0,0 +1,174 @@ +//! Source quality checks independent of whether the agent installed skills. +use super::{Severity, Violation, site}; +use crate::style_props::is_known_style_prop; +use oxc_allocator::Allocator; +use oxc_ast::ast::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; +use std::collections::BTreeMap; + +pub(super) fn findings(source: &str) -> Vec { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { return Vec::new(); } + let mut visitor = Collector { source, findings: Vec::new() }; + for comment in &parsed.program.comments { + if source[comment.span.start as usize..comment.span.end as usize].contains("@devup-") { + visitor.findings.push(site::violation(source, comment.span, "tsx-metadata-comment", Severity::Error, + "Application TSX contains devup export metadata comments.".into(), + "Keep interaction/override evidence in JSON sidecars and reflect required overrides in real props.")); + } + } + visitor.visit_program(&parsed.program); + visitor.findings +} + +struct Collector<'a> { source: &'a str, findings: Vec } + +fn literal(attribute: &JSXAttribute<'_>, source: &str) -> Option { + match attribute.value.as_ref()? { + JSXAttributeValue::StringLiteral(value) => Some(value.value.to_string()), + JSXAttributeValue::ExpressionContainer(container) => { + let expression = container.expression.as_expression()?; + if let Expression::StringLiteral(value) = expression { return Some(value.value.to_string()); } + if static_expression(expression) { + let span = expression.span(); + Some(source[span.start as usize..span.end as usize].to_owned()) + } else { None } + } + _ => None, + } +} + +fn static_expression(expression: &Expression<'_>) -> bool { + match expression { + Expression::StringLiteral(_) | Expression::NumericLiteral(_) | Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => true, + Expression::ArrayExpression(array) => array.elements.iter().all(|item| item.as_expression().is_some_and(static_expression)), + _ => false, + } +} + +impl<'a> Visit<'a> for Collector<'_> { + fn visit_jsx_opening_element(&mut self, node: &JSXOpeningElement<'a>) { + let mut props = BTreeMap::new(); + let mut spread = false; + for item in &node.attributes { + let JSXAttributeItem::Attribute(attribute) = item else { spread = true; continue }; + let JSXAttributeName::Identifier(name) = &attribute.name else { continue }; + let name = name.name.as_str(); + if matches!(name, "data-figma-node-id" | "data-figma-node-ids" | "data-source-node-id" | "data-source-node-ids") { + self.findings.push(site::violation(self.source, attribute.span, "tsx-node-identifier", Severity::Error, + "Figma identity is embedded in application markup.".into(), "Use source-map sidecars and semantic runtime targets; do not add a strip plugin or debug exception.")); + } + if let Some(value) = literal(attribute, self.source) { + if is_known_style_prop(name) && value.contains("var(--") { + self.findings.push(site::violation(self.source, attribute.span, "jsx-token-syntax", Severity::Warning, + "A JSX style uses raw CSS variable syntax.".into(), "Use an existing $token in JSX, including arrays; var(--token) belongs in external CSS objects. Verify every breakpoint's computed styles.")); + } + if matches!(name, "src" | "srcSet" | "poster" | "maskImage" | "backgroundImage") + && ["/", "url(/", "url('/", "url(\"/"].iter().any(|prefix| value.starts_with(prefix)) + && (value.contains("/icons/figma/") || value.contains("/images/web-ready/") + || value.split('/').any(|part| part.split('.').next().is_some_and(|stem| stem.len()>=24 && stem.bytes().all(|b| b.is_ascii_hexdigit())))) { + self.findings.push(site::violation(self.source, attribute.span, "opaque-public-asset", Severity::Warning, + "An asset URL contains an opaque export identity.".into(), "Use a shared semantic path from the project asset registry; migrate code, seeds and persisted URLs together.")); + } + props.insert(name, (value, attribute.span)); + } + } + if !spread { + for (left, right, axis, aliases) in [ + ("pl", "pr", "px", ["p", "px", "padding", "paddingX"]), + ("pt", "pb", "py", ["p", "py", "padding", "paddingY"]), + ("ml", "mr", "mx", ["m", "mx", "margin", "marginX"]), + ("mt", "mb", "my", ["m", "my", "margin", "marginY"]), + ] { + if !aliases.iter().any(|key| props.contains_key(key)) + && let (Some((a, span)), Some((b, _))) = (props.get(left), props.get(right)) + && a == b { + self.findings.push(site::violation(self.source, *span, "axis-shorthand", Severity::Warning, + format!("{left}/{right} repeat the same value or responsive array."), &format!("Use {axis} when no spread or longhand override changes precedence."))); + } + } + } + if let Some((background, _)) = props.get("bg").or_else(|| props.get("backgroundColor")) + && background.starts_with("$secondary") { + for item in &node.attributes { + if let JSXAttributeItem::Attribute(attribute) = item + && let JSXAttributeName::Identifier(name) = &attribute.name + && matches!(name.name.as_str(), "_hover" | "_active" | "_focusVisible") { + let span = attribute.span; + if self.source[span.start as usize..span.end as usize].contains("$primary") { + self.findings.push(site::violation(self.source, span, "variant-state-palette", Severity::Warning, + "A secondary colour variant retains primary pseudo-state paint.".into(), "Review the captured variant palette for background, border, text and icon; never invent matching suffix tokens.")); + } + } + } + } + if props.get("opacity").is_some_and(|(value, _)| value=="0") + && node.attributes.iter().any(|attribute| matches!(attribute, JSXAttributeItem::Attribute(attribute) if matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name=="data-reveal"))) { + self.findings.push(site::violation(self.source, node.span, "ssr-hidden-reveal", Severity::Warning, + "Entrance markup starts hidden before progressive enhancement.".into(), "Render visible server content; start motion after a shared observer attaches. Check no-JavaScript and reduced-motion behavior.")); + } + walk::walk_jsx_opening_element(self, node); + } + + fn visit_jsx_element(&mut self, node: &JSXElement<'a>) { + let korean = node.children.iter().any(inherited_korean); + if korean { + for item in &node.opening_element.attributes { + if let JSXAttributeItem::Attribute(attribute) = item + && matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name=="wordBreak") + && literal(attribute, self.source).as_deref()==Some("normal") { + self.findings.push(site::violation(self.source, attribute.span, "korean-word-boundary", Severity::Warning, + "Korean content explicitly uses wordBreak normal and can split within a word.".into(), "Use keep-all with normal wrapping; separately handle long URLs and tokens.")); + } + } + } + walk::walk_jsx_element(self, node); + } +} + +fn inherited_korean(child: &JSXChild<'_>) -> bool { + match child { + JSXChild::Text(text) => text.value.chars().any(|c| ('\u{ac00}'..='\u{d7a3}').contains(&c)), + JSXChild::Element(element) => { + !element.opening_element.attributes.iter().any(|item| matches!(item, JSXAttributeItem::Attribute(attribute) if matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name=="wordBreak"))) + && element.children.iter().any(inherited_korean) + } + JSXChild::Fragment(fragment) => fragment.children.iter().any(inherited_korean), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn actual_metadata_and_styles_are_checked_without_parsing_lookalike_strings() { + let source = r#"const hint='// @devup-override example'; globalCss({body:{padding:'var(--space)'}}); const C=()=> 한글 문장; // @devup-interaction {}"#; + let rules = findings(source).into_iter().map(|v|v.rule).collect::>(); + for rule in ["tsx-node-identifier", "variant-state-palette", "korean-word-boundary", "opaque-public-asset", "tsx-metadata-comment"] { assert!(rules.contains(&rule), "{rule}"); } + assert_eq!(rules.iter().filter(|r|**r=="tsx-metadata-comment").count(), 1); + assert!(!rules.contains(&"jsx-token-syntax")); + } + #[test] + fn axis_suggestions_do_not_ignore_precedence_or_spreads() { + assert!(findings(r#"const C=()=> "#).iter().any(|v|v.rule=="axis-shorthand")); + for source in [r#"const C=()=> "#,r#"const C=()=> "#,r#"const C=()=> "#,r#"const C=()=> "#] { + assert!(!findings(source).iter().any(|v|v.rule=="axis-shorthand")); + } + } + #[test] + fn explicit_child_policy_and_remote_cdn_paths_are_not_false_positives() { + let source=r#"const C=()=> 한글"#; + assert!(!findings(source).iter().any(|v| matches!(v.rule,"korean-word-boundary"|"opaque-public-asset"))); + assert!(findings(r#"const C=()=> "#).iter().any(|v|v.rule=="variant-state-palette")); + } + + #[test] + fn source_identity_aliases_are_errors_but_example_strings_are_not_markup() { + let source = r#"const example=''; const C=()=> "#; + assert_eq!(findings(source).iter().filter(|v|v.rule=="tsx-node-identifier").count(), 2); + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/fixture_flow.rs b/crates/devup-mcp-devup-ui/src/ui_validate/fixture_flow.rs new file mode 100644 index 00000000..b3ffd284 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/fixture_flow.rs @@ -0,0 +1,665 @@ +//! Positive fixture provenance for the runtime binding actually imported. +//! Unknown calls are not proof of fixture data. In particular a schema passed +//! to an opaque decoder is not the decoder's returned application data. +use super::error; +use crate::ui_validate::{SourceFile, Violation}; +use oxc_allocator::Allocator; +use oxc_ast::ast::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Default)] +enum Flow { + #[default] + Unknown, + Records, + Reference(String), + Import(String, String), + Namespace(String), + Object(BTreeMap), + Union(Vec), + Member(Box, String), + Call(Box, Vec), + Function(Vec, BTreeMap, Vec), + Scoped(BTreeMap, Box), +} + +#[derive(Default)] +struct Module { + locals: BTreeMap, + exports: BTreeMap, + stars: Vec, +} + +fn expression(node: &Expression<'_>) -> Flow { + match node.get_inner_expression() { + Expression::Identifier(id) => Flow::Reference(id.name.to_string()), + Expression::ArrayExpression(array) => { + if !array.elements.is_empty() + && array.elements.iter().all(|item| { + matches!(item, + ArrayExpressionElement::ObjectExpression(object) if object.properties.len() >= 2 + && object.properties.iter().all(|property| matches!(property, + ObjectPropertyKind::ObjectProperty(property) if matches!(property.value, + Expression::StringLiteral(_) | Expression::NumericLiteral(_) + | Expression::BooleanLiteral(_) | Expression::NullLiteral(_))))) + }) + { + Flow::Records + } else { + Flow::Union( + array + .elements + .iter() + .filter_map(|item| match item { + ArrayExpressionElement::SpreadElement(spread) => { + Some(expression(&spread.argument)) + } + other => other.as_expression().map(expression), + }) + .collect(), + ) + } + } + Expression::ObjectExpression(object) => Flow::Object( + object + .properties + .iter() + .enumerate() + .filter_map(|(index, property)| match property { + ObjectPropertyKind::ObjectProperty(property) => Some(( + property.key.static_name()?.to_string(), + expression(&property.value), + )), + ObjectPropertyKind::SpreadProperty(spread) => { + Some((format!("...{index}"), expression(&spread.argument))) + } + }) + .collect(), + ), + Expression::StaticMemberExpression(member) => Flow::Member( + Box::new(expression(&member.object)), + member.property.name.to_string(), + ), + Expression::ComputedMemberExpression(member) => Flow::Member( + Box::new(expression(&member.object)), + if let Expression::StringLiteral(key) = member.expression.get_inner_expression() { + key.value.to_string() + } else { + "*".into() + }, + ), + Expression::AwaitExpression(awaited) => expression(&awaited.argument), + Expression::ConditionalExpression(value) => Flow::Union(vec![ + expression(&value.consequent), + expression(&value.alternate), + ]), + Expression::LogicalExpression(value) => { + Flow::Union(vec![expression(&value.left), expression(&value.right)]) + } + Expression::SequenceExpression(value) => { + value.expressions.last().map(expression).unwrap_or_default() + } + Expression::CallExpression(call) => Flow::Call( + Box::new(expression(&call.callee)), + call.arguments + .iter() + .map(|arg| arg.as_expression().map(expression).unwrap_or_default()) + .collect(), + ), + Expression::FunctionExpression(function) => function_flow(function), + Expression::ArrowFunctionExpression(function) => { + let mut body = Body::default(); + if let ArrowFunctionBody::FunctionBody(block) = &function.body { + body.visit_function_body(block); + } else if let Some(value) = function.body.as_expression() { + body.returns.push(expression(value)); + } + Flow::Function(parameters(&function.params), body.locals, body.returns) + } + _ => Flow::Unknown, + } +} + +fn parameters(params: &FormalParameters<'_>) -> Vec { + params + .items + .iter() + .map(|param| match ¶m.pattern { + BindingPattern::BindingIdentifier(id) => id.name.to_string(), + _ => String::new(), + }) + .collect() +} + +fn function_flow(function: &Function<'_>) -> Flow { + let mut body = Body::default(); + if let Some(block) = &function.body { + body.visit_function_body(block); + } + Flow::Function(parameters(&function.params), body.locals, body.returns) +} + +#[derive(Default)] +struct Body { + locals: BTreeMap, + returns: Vec, +} + +impl<'a> Visit<'a> for Body { + fn visit_variable_declarator(&mut self, node: &VariableDeclarator<'a>) { + if let BindingPattern::BindingIdentifier(id) = &node.id { + self.locals.insert( + id.name.to_string(), + node.init.as_ref().map(expression).unwrap_or_default(), + ); + } + } + fn visit_return_statement(&mut self, node: &ReturnStatement<'a>) { + if let Some(value) = &node.argument { + self.returns.push(Flow::Scoped( + self.locals.clone(), + Box::new(expression(value)), + )); + } + } + fn visit_block_statement(&mut self, node: &BlockStatement<'a>) { + let outer = self.locals.clone(); + walk::walk_block_statement(self, node); + self.locals = outer; + } + fn visit_statement(&mut self, node: &Statement<'a>) { + if let Statement::FunctionDeclaration(function) = node { + if let Some(id) = &function.id { + self.locals + .insert(id.name.to_string(), function_flow(function)); + } + } else { + walk::walk_statement(self, node); + } + } + fn visit_expression(&mut self, _node: &Expression<'a>) {} + fn visit_arrow_function_expression(&mut self, _node: &ArrowFunctionExpression<'a>) {} + fn visit_ts_type(&mut self, _node: &TSType<'a>) {} +} + +fn imports(node: &ImportDeclaration<'_>) -> Vec<(String, Flow)> { + if node.import_kind == ImportOrExportKind::Type { + return Vec::new(); + } + node.specifiers + .iter() + .flatten() + .filter_map(|specifier| match specifier { + ImportDeclarationSpecifier::ImportSpecifier(value) + if value.import_kind == ImportOrExportKind::Value => + { + Some(( + value.local.name.to_string(), + Flow::Import(node.source.value.to_string(), value.imported.to_string()), + )) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(value) => Some(( + value.local.name.to_string(), + Flow::Import(node.source.value.to_string(), "default".into()), + )), + ImportDeclarationSpecifier::ImportNamespaceSpecifier(value) => Some(( + value.local.name.to_string(), + Flow::Namespace(node.source.value.to_string()), + )), + _ => None, + }) + .collect() +} + +fn declaration(node: &Declaration<'_>, module: &mut Module, exported: bool) { + match node { + Declaration::VariableDeclaration(variables) => { + for var in &variables.declarations { + if let BindingPattern::BindingIdentifier(id) = &var.id { + let name = id.name.to_string(); + module.locals.insert( + name.clone(), + var.init.as_ref().map(expression).unwrap_or_default(), + ); + if exported { + module.exports.insert(name.clone(), Flow::Reference(name)); + } + } + } + } + Declaration::FunctionDeclaration(function) => { + if let Some(id) = &function.id { + let name = id.name.to_string(); + module.locals.insert(name.clone(), function_flow(function)); + if exported { + module.exports.insert(name.clone(), Flow::Reference(name)); + } + } + } + _ => {} + } +} + +fn module(source: &str) -> Module { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let mut module = Module::default(); + if !parsed.diagnostics.is_empty() { + return module; + } + for statement in &parsed.program.body { + match statement { + Statement::ImportDeclaration(node) => module.locals.extend(imports(node)), + Statement::ExportDeclaration(node) => declaration(&node.declaration, &mut module, true), + Statement::ExportNamedDeclaration(node) + if node.export_kind == ImportOrExportKind::Value => + { + for specifier in &node.specifiers { + if specifier.export_kind == ImportOrExportKind::Value { + module.exports.insert( + specifier.exported.to_string(), + Flow::Reference(specifier.local.to_string()), + ); + } + } + } + Statement::ExportFromDeclaration(node) + if node.export_kind == ImportOrExportKind::Value => + { + for specifier in &node.specifiers { + if specifier.export_kind == ImportOrExportKind::Value { + module.exports.insert( + specifier.exported.to_string(), + Flow::Import( + node.source.value.to_string(), + specifier.local.to_string(), + ), + ); + } + } + } + Statement::ExportAllDeclaration(node) + if node.export_kind == ImportOrExportKind::Value => + { + module.stars.push(node.source.value.to_string()) + } + Statement::ExportDefaultDeclaration(node) => { + let value = if let ExportDefaultDeclarationKind::FunctionDeclaration(function) = + &node.declaration + { + function_flow(function) + } else { + node.declaration + .as_expression() + .map(expression) + .unwrap_or_default() + }; + module.exports.insert("default".into(), value); + } + other => { + if let Some(node) = other.as_declaration() { + declaration(node, &mut module, false); + } + } + } + } + module +} + +fn normalized(path: &str) -> String { + let mut parts = Vec::new(); + for part in path.split(['/', '\\']) { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + _ => parts.push(part), + } + } + parts.join("/") +} + +fn resolve_file<'a>(files: &'a [SourceFile], from: &str, import: &str) -> Option<&'a SourceFile> { + let from = normalized(from); + let base = if let Some(tail) = import.strip_prefix("@/") { + format!( + "{}/{}", + from.split("/src/") + .next() + .filter(|_| from.contains("/src/")) + .map(|root| format!("{root}/src")) + .unwrap_or_else(|| "src".into()), + tail + ) + } else if import.starts_with('.') { + format!( + "{}/{}", + from.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(""), + import + ) + } else { + return None; + }; + let base = normalized(&base); + files.iter().find(|file| { + let target = normalized(&file.path); + target == base + || [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx"] + .iter() + .any(|extension| target == format!("{base}{extension}")) + }) +} + +struct Evaluator<'a> { + files: &'a [SourceFile], + modules: BTreeMap, +} +impl Evaluator<'_> { + fn exported(&mut self, from: &str, import: &str, name: &str, depth: usize) -> bool { + if depth > 48 { + return false; + } + let Some(file) = resolve_file(self.files, from, import) else { + return false; + }; + let path = file.path.clone(); + if !self.modules.contains_key(&path) { + self.modules.insert(path.clone(), module(&file.content)); + } + let facts = &self.modules[&path]; + let flow = facts.exports.get(name).cloned(); + let stars = facts.stars.clone(); + if let Some(flow) = flow { + self.value(&path, &flow, &BTreeMap::new(), depth + 1) + } else { + stars + .iter() + .any(|star| self.exported(&path, star, name, depth + 1)) + } + } + fn lookup(&self, path: &str, name: &str, locals: &BTreeMap) -> Flow { + locals + .get(name) + .or_else(|| self.modules.get(path)?.locals.get(name)) + .cloned() + .unwrap_or_default() + } + fn dereference( + &self, + path: &str, + flow: &Flow, + locals: &BTreeMap, + depth: usize, + ) -> Flow { + if depth > 48 { + return Flow::Unknown; + } + if let Flow::Reference(name) = flow { + self.dereference(path, &self.lookup(path, name, locals), locals, depth + 1) + } else { + flow.clone() + } + } + fn function( + &mut self, + path: &str, + flow: &Flow, + args: &[Flow], + locals: &BTreeMap, + depth: usize, + ) -> bool { + if depth > 48 { + return false; + } + let flow = self.dereference(path, flow, locals, depth + 1); + if let Flow::Function(params, own, returns) = flow { + let mut scope = locals.clone(); + scope.extend(own); + for (index, name) in params.iter().enumerate() { + let value = args + .get(index) + .is_some_and(|arg| self.value(path, arg, locals, depth + 1)); + scope.insert( + name.clone(), + if value { Flow::Records } else { Flow::Unknown }, + ); + } + returns + .iter() + .any(|value| self.value(path, value, &scope, depth + 1)) + } else if let Flow::Import(import, name) = flow { + self.exported(path, &import, &name, depth + 1) + } else { + false + } + } + fn value( + &mut self, + path: &str, + flow: &Flow, + locals: &BTreeMap, + depth: usize, + ) -> bool { + if depth > 48 { + return false; + } + match flow { + Flow::Unknown | Flow::Namespace(_) => false, + Flow::Records => true, + Flow::Reference(name) => { + self.value(path, &self.lookup(path, name, locals), locals, depth + 1) + } + Flow::Import(import, name) => self.exported(path, import, name, depth + 1), + Flow::Object(values) => values + .values() + .any(|value| self.value(path, value, locals, depth + 1)), + Flow::Union(values) => values + .iter() + .any(|value| self.value(path, value, locals, depth + 1)), + Flow::Function(..) => self.function(path, flow, &[], locals, depth + 1), + Flow::Scoped(own, value) => { + let mut scope = locals.clone(); + scope.extend(own.clone()); + self.value(path, value, &scope, depth + 1) + } + Flow::Member(object, key) => match self.dereference(path, object, locals, depth + 1) { + Flow::Namespace(import) => self.exported(path, &import, key, depth + 1), + Flow::Object(values) if key != "*" => { + values + .get(key) + .is_some_and(|value| self.value(path, value, locals, depth + 1)) + || values + .iter() + .filter(|(name, _)| name.starts_with("...")) + .any(|(_, value)| { + self.value( + path, + &Flow::Member(Box::new(value.clone()), key.clone()), + locals, + depth + 1, + ) + }) + } + other => self.value(path, &other, locals, depth + 1), + }, + Flow::Call(callee, args) => { + if let Flow::Member(object, method) = callee.as_ref() { + let namespace = matches!( + self.dereference(path, object, locals, depth + 1), + Flow::Namespace(_) + ); + if namespace { + return self.function( + path, + &self.dereference(path, callee, locals, depth + 1), + args, + locals, + depth + 1, + ) || self.value(path, callee, locals, depth + 1); + } + if [ + "map", "filter", "slice", "find", "sort", "toSorted", "at", "concat", + "flat", "flatMap", "reverse", + ] + .contains(&method.as_str()) + { + return self.value(path, object, locals, depth + 1); + } + if ["then", "catch"].contains(&method.as_str()) { + return args + .iter() + .any(|arg| self.function(path, arg, &[], locals, depth + 1)); + } + } + self.function(path, callee, args, locals, depth + 1) + } + } + } +} + +#[derive(Default)] +struct RuntimeUses { + names: BTreeSet, + members: BTreeMap>, + route_metadata: bool, +} +impl<'a> Visit<'a> for RuntimeUses { + fn visit_export_declaration(&mut self, node: &ExportDeclaration<'a>) { + if self.route_metadata { + match &node.declaration { + Declaration::VariableDeclaration(variables) => { + for variable in &variables.declarations { + if !matches!(&variable.id, BindingPattern::BindingIdentifier(id) if id.name == "metadata") + { + self.visit_variable_declarator(variable); + } + } + return; + } + Declaration::FunctionDeclaration(function) + if function + .id + .as_ref() + .is_some_and(|id| id.name == "generateMetadata") => + { + return; + } + _ => {} + } + } + walk::walk_export_declaration(self, node); + } + fn visit_identifier_reference(&mut self, node: &IdentifierReference<'a>) { + self.names.insert(node.name.to_string()); + } + fn visit_ts_type(&mut self, _node: &TSType<'a>) {} + fn visit_static_member_expression(&mut self, node: &StaticMemberExpression<'a>) { + if let Expression::Identifier(id) = node.object.get_inner_expression() { + self.members + .entry(id.name.to_string()) + .or_default() + .insert(node.property.name.to_string()); + } + walk::walk_static_member_expression(self, node); + } + fn visit_computed_member_expression(&mut self, node: &ComputedMemberExpression<'a>) { + if let Expression::Identifier(id) = node.object.get_inner_expression() + && let Expression::StringLiteral(key) = node.expression.get_inner_expression() + { + self.members + .entry(id.name.to_string()) + .or_default() + .insert(key.value.to_string()); + } + walk::walk_computed_member_expression(self, node); + } +} + +pub(super) fn findings(files: &[SourceFile], path: &str, source: &str) -> Vec { + let normalized = normalized(path); + if normalized.contains("/__tests__/") + || [".test.", ".spec."].iter().any(|part| { + normalized + .rsplit('/') + .next() + .is_some_and(|file| file.contains(part)) + }) + { + return Vec::new(); + } + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + if !parsed.diagnostics.is_empty() { + return Vec::new(); + } + let mut uses = RuntimeUses { + route_metadata: crate::ui_validate::site::is_route_file(path), + ..RuntimeUses::default() + }; + uses.visit_program(&parsed.program); + let mut evaluator = Evaluator { + files, + modules: BTreeMap::new(), + }; + let mut found = Vec::new(); + for statement in &parsed.program.body { + let Statement::ImportDeclaration(node) = statement else { + continue; + }; + let Some(target) = resolve_file(files, path, node.source.value.as_str()) else { + continue; + }; + if !normalized_path_is_utils(&target.path) || target.path == path { + continue; + } + for (local, flow) in imports(node) { + if !uses.names.contains(&local) { + continue; + } + let names = match flow { + Flow::Import(_, name) => vec![name], + Flow::Namespace(_) => uses + .members + .get(&local) + .map(|names| names.iter().cloned().collect()) + .unwrap_or_default(), + _ => Vec::new(), + }; + for imported in names { + if evaluator.exported(path, node.source.value.as_str(), &imported, 0) { + let mut finding = error( + node.span, + "fixture-instead-of-api", + "UI reads a runtime import whose returned data is a hardcoded record array instead of an API source.", + ); + finding + .context + .insert("fixturePath".into(), serde_json::json!(target.path)); + finding + .context + .insert("fixtureBinding".into(), serde_json::json!(imported)); + finding + .context + .insert("localBinding".into(), serde_json::json!(local)); + finding.context.insert( + "confidence".into(), + serde_json::json!("proven-binding-flow"), + ); + found.push(finding); + } + } + } + } + found +} +fn normalized_path_is_utils(path: &str) -> bool { + normalized(path).split('/').any(|part| part == "utils") +} + +#[cfg(test)] +#[path = "fixture_flow_tests.rs"] +mod tests; diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/fixture_flow_tests.rs b/crates/devup-mcp-devup-ui/src/ui_validate/fixture_flow_tests.rs new file mode 100644 index 00000000..90cc4866 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/fixture_flow_tests.rs @@ -0,0 +1,197 @@ +use super::*; + +fn file(path: &str, content: &str) -> SourceFile { + SourceFile { + path: path.into(), + content: content.into(), + } +} + +fn rules(files: &[SourceFile], source: &str) -> Vec { + findings(files, "src/components/pages/NewsPage.tsx", source) +} + +fn adapter_files() -> Vec { + vec![ + file( + "src/utils/pageContent.ts", + r#" +import type { PageKey } from './publicData'; +import { getRawPageContent } from './publicData'; +const pageSchemas = { companyIntro: { outline: [{ label: 'string', value: 'string' }] } } as const; +function decode(schema: unknown, value: unknown) { + if (typeof schema === 'string') return value; + if (Array.isArray(schema)) return value.map(item => decode(schema[0], item)); + const record = value as Record; + return Object.fromEntries(Object.entries(schema).map(([key,field]) => [key,decode(field,record[key])])); +} +export function decodePageContent(pageKey: PageKey, value: unknown) { return decode(pageSchemas[pageKey], value); } +export async function getPageContent(pageKey: PageKey) { return decodePageContent(pageKey, await getRawPageContent(pageKey)); } +export type PageContentByKey = typeof pageSchemas; +"#, + ), + file( + "src/utils/publicData.ts", + r#" +import {client} from '../api'; +export type PageKey = 'companyIntro'; +export async function getRawPageContent(pageKey: PageKey) { + const result = await client.get('/page-contents/{pageKey}', {params:{pageKey}}); + return result.data.content; +} +"#, + ), + ] +} + +#[test] +fn schema_decoder_returns_api_values_without_importing_fixture_content() { + assert!(rules(&adapter_files(), "import {getPageContent} from '@/utils/pageContent'; export async function Page(){const data=await getPageContent('companyIntro'); return
    {data.outline.map(row=>

    {row.value}

    )}
    }").is_empty()); +} + +#[test] +fn importing_actual_fixture_still_fails_beside_api_and_schema_exports() { + let mut files = adapter_files(); + files[0].content.push_str("export const fixtures = [{id:1,title:'Fake'}]; export function getFixture(){return fixtures.slice();}"); + for binding in ["fixtures", "getFixture"] { + let source = format!( + "import {{getPageContent,{binding} as rows}} from '@/utils/pageContent'; const view=
    {{rows}}
    ;" + ); + let found = rules(&files, &source); + assert_eq!(found.len(), 1, "{binding}"); + assert_eq!(found[0].context["fixtureBinding"], binding); + } +} + +#[test] +fn type_only_unused_and_test_imports_do_not_become_ui_data_reads() { + let files = vec![file( + "src/utils/records.ts", + "export const records=[{id:1,title:'Fixture'}]; export type Shape=typeof records;", + )]; + for source in [ + "import type {records} from '@/utils/records'; type T=typeof records; const view=
    ;", + "import {type records} from '@/utils/records'; type T=typeof records; const view=
    ;", + "import {records} from '@/utils/records'; type T=typeof records; const view=
    ;", + "import {records} from '@/utils/records'; const view=
    ;", + ] { + assert!(rules(&files, source).is_empty(), "{source}"); + } + assert!( + findings( + &files, + "src/__tests__/records.test.ts", + "import {records} from '@/utils/records'; expect(records).toHaveLength(1);" + ) + .is_empty() + ); +} + +#[test] +fn namespace_default_barrel_aliases_and_returned_fallbacks_follow_the_selected_binding() { + let files = vec![ + file( + "src/utils/records.ts", + "const rows=[{id:1,title:'Fixture'}]; export {rows as records}; export default rows; export async function live(){return fetch('/api')} export async function fallback(){return fetch('/api').catch(()=>rows)}", + ), + file( + "src/utils/barrel.ts", + "export {records as items} from './records';", + ), + ]; + for source in [ + "import data from '@/utils/records'; const view=
    {data.length}
    ;", + "import {items as data} from '@/utils/barrel'; const view=
    {data.length}
    ;", + "import * as data from '@/utils/records'; const view=
    {data.records.length}
    ;", + "import * as data from '@/utils/records'; const view=
    {data['records'].length}
    ;", + "import {fallback} from '@/utils/records'; const view=
    {fallback()}
    ;", + ] { + assert_eq!(rules(&files, source).len(), 1, "{source}"); + } + assert!( + rules( + &files, + "import * as data from '@/utils/records'; const view=
    {data.live()}
    ;" + ) + .is_empty() + ); +} + +#[test] +fn block_scopes_do_not_hide_a_global_fixture_or_poison_an_api_local() { + let files = vec![file( + "src/utils/records.ts", + r#" +const rows=[{id:1,title:'Fixture'}]; +export async function live(){const rows=await fetch('/api'); if(true){const rows=[{id:2,title:'Unused'}]; console.log(rows)} return rows;} +export function fake(){if(true){const rows=fetch('/api'); console.log(rows)} return rows;} +export const spread=[...rows]; +"#, + )]; + assert!( + rules( + &files, + "import {live} from '@/utils/records'; const view=
    {live()}
    ;" + ) + .is_empty() + ); + for binding in ["fake", "spread"] { + assert_eq!(rules(&files,&format!("import {{{binding} as data}} from '@/utils/records'; const view=
    {{data}}
    ;")).len(),1); + } +} + +#[test] +fn app_router_metadata_configuration_is_not_ui_fixture_data() { + let files = vec![file( + "src/utils/pageMetadata.ts", + r#" +export const SITE_OPEN_GRAPH={images:[{url:'/opengraph-image.png',width:1440,height:5953}]}; +export function createPageMetadata(title:string){return {title,openGraph:SITE_OPEN_GRAPH};} +"#, + )]; + for source in [ + "import {createPageMetadata} from '@/utils/pageMetadata'; export const metadata=createPageMetadata('Home'); export default function Page(){return
    }", + "import {SITE_OPEN_GRAPH} from '@/utils/pageMetadata'; export const metadata={openGraph:SITE_OPEN_GRAPH}; export default function Layout(){return
    }", + "import {SITE_OPEN_GRAPH} from '@/utils/pageMetadata'; export async function generateMetadata(){return {openGraph:SITE_OPEN_GRAPH}} export default function Page(){return
    }", + ] { + assert!( + findings(&files, "src/app/page.tsx", source).is_empty(), + "{source}" + ); + } + let mixed = "import {SITE_OPEN_GRAPH} from '@/utils/pageMetadata'; export const metadata={openGraph:SITE_OPEN_GRAPH}; export default function Page(){return
    {SITE_OPEN_GRAPH.images.map(image=>

    {image.url}

    )}
    }"; + assert_eq!(findings(&files, "src/app/page.tsx", mixed).len(), 1); + let ordinary = "import {SITE_OPEN_GRAPH} from '@/utils/pageMetadata'; export const metadata=SITE_OPEN_GRAPH; const view=
    {metadata.images.length}
    ;"; + assert_eq!( + rules(&files, ordinary).len(), + 1, + "Only recognized App Router metadata exports have metadata semantics" + ); +} + +#[test] +fn unrelated_nested_fixture_returns_and_shadowed_names_do_not_poison_a_real_loader() { + let files = vec![file( + "src/utils/records.ts", + r#" +const rows=[{id:1,title:'Fixture'}]; +export async function live(){const rows=await fetch('/api'); function unused(){return [{id:2,title:'Unused'}]} return rows;} +export function fake(){const local=rows; return local.map(row=>row)} +"#, + )]; + assert!( + rules( + &files, + "import {live} from '@/utils/records'; const view=
    {live()}
    ;" + ) + .is_empty() + ); + assert_eq!( + rules( + &files, + "import {fake} from '@/utils/records'; const view=
    {fake()}
    ;" + ) + .len(), + 1 + ); +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs b/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs index 8518ba78..41b674b8 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs @@ -8,6 +8,8 @@ pub mod site; pub mod wiring; #[path = "visual_behavior.rs"] pub mod visual_behavior; +#[path = "delivery_quality.rs"] +mod delivery_quality; #[cfg(test)] #[path = "visual_behavior_tests.rs"] mod visual_behavior_tests; @@ -159,6 +161,7 @@ fn validate_source( violations.extend(visitor.violations); violations.extend(wiring::findings(tsx)); violations.extend(visual_behavior::findings(tsx)); + violations.extend(delivery_quality::findings(tsx)); let checked_tokens = visitor.checked_tokens; let attributes = crate::provenance::attributes::generated_attributes(tsx); violations.extend(blur_layers::check(tsx, &attributes)); diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/wiring.rs b/crates/devup-mcp-devup-ui/src/ui_validate/wiring.rs index 285d1a7a..5434c43f 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate/wiring.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate/wiring.rs @@ -5,6 +5,9 @@ use oxc_ast_visit::{Visit, walk}; use oxc_parser::Parser; use oxc_span::{SourceType, Span}; +#[path = "fixture_flow.rs"] +mod fixture_flow; + fn error(span: Span, rule: &'static str, message: &str) -> Violation { Violation { rule, @@ -26,16 +29,38 @@ pub fn findings(source: &str) -> Vec { for comment in &parsed.program.comments { let text = &source[comment.span.start as usize..comment.span.end as usize]; let lower = text.to_lowercase(); - if ["todo", "api 생기면", "replace when"].iter().any(|marker| lower.contains(marker)) { - found.push(error(comment.span, "deferred-wiring", "Deferred implementation comment leaves application wiring unfinished.")); + if ["todo", "api 생기면", "replace when"] + .iter() + .any(|marker| lower.contains(marker)) + { + found.push(error( + comment.span, + "deferred-wiring", + "Deferred implementation comment leaves application wiring unfinished.", + )); } } - let mut collector = Collector { source, found, functions: std::collections::BTreeMap::new(), forms: Vec::new(), submit_depth: 0, link_depth: 0 }; + let mut collector = Collector { + source, + found, + functions: std::collections::BTreeMap::new(), + forms: Vec::new(), + submit_depth: 0, + link_depth: 0, + }; collector.visit_program(&parsed.program); for (span, handler) in &collector.forms { - let body = collector.functions.get(handler).map(String::as_str).unwrap_or(handler); + let body = collector + .functions + .get(handler) + .map(String::as_str) + .unwrap_or(handler); if fake_success(body) { - collector.found.push(error(*span, "fake-submit", "Form submission only changes local success state without a request.")); + collector.found.push(error( + *span, + "fake-submit", + "Form submission only changes local success state without a request.", + )); } } collector.found @@ -43,48 +68,35 @@ pub fn findings(source: &str) -> Vec { fn fake_success(body: &str) -> bool { let lower = body.to_lowercase(); - let success = ["setsuccess(", "setsubmitted(", "setsent(", "setissuccess(", "setissubmitted(", "setissent("].iter().any(|name| lower.contains(name)); - success && !["fetch(", ".post(", ".mutate(", ".mutateasync(", "axios(", ".request("].iter().any(|call| lower.contains(call)) + let success = [ + "setsuccess(", + "setsubmitted(", + "setsent(", + "setissuccess(", + "setissubmitted(", + "setissent(", + ] + .iter() + .any(|name| lower.contains(name)); + success + && ![ + "fetch(", + ".post(", + ".mutate(", + ".mutateasync(", + "axios(", + ".request(", + ] + .iter() + .any(|call| lower.contains(call)) } -pub fn fixture_findings(files: &[crate::ui_validate::SourceFile], path: &str, source: &str) -> Vec { - struct Imports(Vec<(String, Span)>); - impl<'a> Visit<'a> for Imports { - fn visit_import_declaration(&mut self, node: &ImportDeclaration<'a>) { - self.0.push((node.source.value.to_string(), node.span)); - } - } - struct Records(bool); - impl<'a> Visit<'a> for Records { - fn visit_array_expression(&mut self, node: &ArrayExpression<'a>) { - if !node.elements.is_empty() && node.elements.iter().all(|item| matches!(item, ArrayExpressionElement::ObjectExpression(object) if object.properties.len() >= 2 && object.properties.iter().all(|property| matches!(property, ObjectPropertyKind::ObjectProperty(property) if matches!(property.value, Expression::StringLiteral(_) | Expression::NumericLiteral(_) | Expression::BooleanLiteral(_) | Expression::NullLiteral(_)))))) { - self.0 = true; - } - walk::walk_array_expression(self, node); - } - } - let allocator = Allocator::default(); - let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); - let mut imports = Imports(Vec::new()); - imports.visit_program(&parsed.program); - imports.0.into_iter().filter_map(|(import, span)| { - let import = import.replace('\\', "/"); - let tail = import.split("utils/").nth(1)?; - let target = files.iter().find(|file| { - let normalized = file.path.replace('\\', "/"); - normalized.ends_with(&format!("utils/{tail}")) || normalized.ends_with(&format!("utils/{tail}.ts")) - })?; - if target.path == path { return None; } - let parsed = Parser::new(&allocator, &target.content, SourceType::ts()).parse(); - let mut records = Records(false); - records.visit_program(&parsed.program); - records.0.then(|| { - let mut finding = error(span, "fixture-instead-of-api", "UI imports a hardcoded record array from utils instead of an API data source."); - finding.context.insert("fixturePath".into(), serde_json::json!(target.path)); - finding.context.insert("confidence".into(), serde_json::json!("medium")); - finding - }) - }).collect() +pub fn fixture_findings( + files: &[crate::ui_validate::SourceFile], + path: &str, + source: &str, +) -> Vec { + fixture_flow::findings(files, path, source) } struct Collector<'s> { @@ -98,7 +110,10 @@ struct Collector<'s> { fn attr_name<'a>(item: &'a JSXAttributeItem<'a>) -> Option<&'a str> { match item { - JSXAttributeItem::Attribute(attribute) => attribute.name.as_identifier().map(|name| name.name.as_str()), + JSXAttributeItem::Attribute(attribute) => attribute + .name + .as_identifier() + .map(|name| name.name.as_str()), _ => None, } } @@ -120,7 +135,10 @@ impl<'a> Visit<'a> for Collector<'_> { if let BindingPattern::BindingIdentifier(id) = &node.id && let Some(Expression::ArrowFunctionExpression(function)) = &node.init { - self.functions.insert(id.name.to_string(), self.source[function.span.start as usize..function.span.end as usize].into()); + self.functions.insert( + id.name.to_string(), + self.source[function.span.start as usize..function.span.end as usize].into(), + ); } walk::walk_variable_declarator(self, node); } @@ -129,21 +147,42 @@ impl<'a> Visit<'a> for Collector<'_> { if let Statement::FunctionDeclaration(function) = node && let Some(id) = &function.id { - self.functions.insert(id.name.to_string(), self.source[function.span.start as usize..function.span.end as usize].into()); + self.functions.insert( + id.name.to_string(), + self.source[function.span.start as usize..function.span.end as usize].into(), + ); } walk::walk_statement(self, node); } fn visit_jsx_element(&mut self, node: &JSXElement<'a>) { let opening = &node.opening_element; - let tag = opening.name.get_identifier_name().map(|name| name.as_str()).unwrap_or_default(); - let attribute = |name: &str| opening.attributes.iter().find_map(|item| match item { - JSXAttributeItem::Attribute(value) if attr_name(item) == Some(name) => value.value.as_ref(), - _ => None, - }); - let has = |name: &str| opening.attributes.iter().any(|item| attr_name(item) == Some(name)); - let effective = attribute("as").and_then(value_text).unwrap_or_else(|| tag.into()); - let spread = opening.attributes.iter().any(|item| matches!(item, JSXAttributeItem::SpreadAttribute(_))); + let tag = opening + .name + .get_identifier_name() + .map(|name| name.as_str()) + .unwrap_or_default(); + let attribute = |name: &str| { + opening.attributes.iter().find_map(|item| match item { + JSXAttributeItem::Attribute(value) if attr_name(item) == Some(name) => { + value.value.as_ref() + } + _ => None, + }) + }; + let has = |name: &str| { + opening + .attributes + .iter() + .any(|item| attr_name(item) == Some(name)) + }; + let effective = attribute("as") + .and_then(value_text) + .unwrap_or_else(|| tag.into()); + let spread = opening + .attributes + .iter() + .any(|item| matches!(item, JSXAttributeItem::SpreadAttribute(_))); let form = effective == "form"; let submits = form && (has("onSubmit") || has("action")); if let Some(handler) = attribute("onSubmit") { @@ -156,13 +195,38 @@ impl<'a> Visit<'a> for Collector<'_> { } let links = matches!(effective.as_str(), "a" | "Link") || has("href"); if !spread && !has("disabled") && !has("readOnly") && self.link_depth == 0 { - let input = matches!(effective.as_str(), "input" | "Input" | "select" | "Select" | "textarea" | "Textarea"); + let input = matches!( + effective.as_str(), + "input" | "Input" | "select" | "Select" | "textarea" | "Textarea" + ); let button = matches!(effective.as_str(), "button" | "Button"); - let submit = attribute("type").and_then(value_text).is_none_or(|kind| kind == "submit") && self.submit_depth > 0; - if (input && !["value", "onChange", "name", "defaultValue", "checked", "onInput", "props"].iter().any(|name| has(name))) - || (button && !submit && !["onClick", "href", "formAction", "form", "props"].iter().any(|name| has(name))) + let submit = attribute("type") + .and_then(value_text) + .is_none_or(|kind| kind == "submit") + && self.submit_depth > 0; + if (input + && ![ + "value", + "onChange", + "name", + "defaultValue", + "checked", + "onInput", + "props", + ] + .iter() + .any(|name| has(name))) + || (button + && !submit + && !["onClick", "href", "formAction", "form", "props"] + .iter() + .any(|name| has(name))) { - self.found.push(error(opening.span, "dead-control", "Visible control has no value, form binding, handler or destination.")); + self.found.push(error( + opening.span, + "dead-control", + "Visible control has no value, form binding, handler or destination.", + )); } } self.submit_depth += usize::from(submits); @@ -182,22 +246,34 @@ mod tests { let source = r#"// TODO wire API const label = 'TODO'; const view = <>;"#; - let rules = findings(source).into_iter().map(|item| item.rule).collect::>(); + let rules = findings(source) + .into_iter() + .map(|item| item.rule) + .collect::>(); assert_eq!(rules, ["deferred-wiring", "dead-control", "dead-control"]); } #[test] fn a_control_rendered_inside_a_link_takes_the_link_destination() { let source = r#"const view = <>;"#; - assert!(findings(source).iter().any(|item| item.rule == "fake-submit")); - assert!(!findings(&source.replace("setSuccess(true)", "await fetch('/contact', { method: 'POST' }); setSuccess(true)").replace("(event) =>", "async (event) =>")).iter().any(|item| item.rule == "fake-submit")); + assert!( + findings(source) + .iter() + .any(|item| item.rule == "fake-submit") + ); + assert!( + !findings( + &source + .replace( + "setSuccess(true)", + "await fetch('/contact', { method: 'POST' }); setSuccess(true)" + ) + .replace("(event) =>", "async (event) =>") + ) + .iter() + .any(|item| item.rule == "fake-submit") + ); } } diff --git a/crates/devup-mcp-devup-ui/tests/absolute_border_origin.rs b/crates/devup-mcp-devup-ui/tests/absolute_border_origin.rs new file mode 100644 index 00000000..47fd12df --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/absolute_border_origin.rs @@ -0,0 +1,200 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +fn source(mobile: bool) -> (Snapshot, &'static str, &'static str) { + let mut snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/vision-border-origin.json")).unwrap(); + let (parent, child) = if mobile { + ("44:3235", "I44:3235;44:3700") + } else { + ("44:2832", "I44:2832;44:3209") + }; + snapshot.roots = vec![parent.into()]; + snapshot.nodes.retain(|id, _| id == parent || id == child); + // A live sibling keeps the parent in the native layout path, as its real + // captured card does. The parent/ellipse paint and geometry remain exact. + snapshot + .nodes + .get_mut(parent) + .unwrap() + .fields + .insert("childrenIds".into(), json!([child, "label"])); + snapshot.nodes.insert("label".into(), serde_json::from_value(json!({"id":"label","type":"TEXT","fields":{ + "parentId":parent,"characters":"Live sibling","fontSize":16,"textAutoResize":"WIDTH_AND_HEIGHT"}})).unwrap()); + (snapshot, parent, child) +} + +#[test] +fn captured_leading_border_origin_is_subtracted_and_explained() { + for mobile in [false, true] { + let (snapshot, parent, _) = source(mobile); + let output = generate_component( + &snapshot, + parent, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap(); + let expected = if mobile { "28px" } else { "33px" }; + assert!( + output.tsx.contains(&format!("top=\"{expected}\"")), + "{}", + output.tsx + ); + assert!( + output.tsx.contains(if mobile { + "left=\"-10px\"" + } else { + "left=\"-18px\"" + }), + "{}", + output.tsx + ); + assert!( + output.source_map.entries.iter().any(|entry| entry + .calculation + .as_deref() + .is_some_and(|calculation| calculation.contains("native parent") + && calculation.contains("leading border"))), + "{:?}", + output.source_map + ); + if !mobile && let Ok(path) = std::env::var("DEVUP_BORDER_ORIGIN_PROBE_OUTPUT") { + std::fs::write(path, output.tsx).unwrap(); + } + } +} + +#[test] +fn outline_hidden_none_unknown_and_non_min_paths_are_not_rewritten() { + for variant in [ + "outline", + "hidden-paint", + "hidden-parent", + "none-parent", + "unknown-stroke", + "unknown-constraint", + "center", + "rotation", + ] { + let (mut snapshot, parent, child) = source(false); + match variant { + "outline" => { + let fields = &mut snapshot.nodes.get_mut(parent).unwrap().fields; + fields.insert("strokeWeight".into(), json!(1)); + fields.insert("strokeAlign".into(), json!("OUTSIDE")); + } + "hidden-paint" => { + snapshot + .nodes + .get_mut(parent) + .unwrap() + .fields + .get_mut("strokes") + .unwrap()[0]["visible"] = json!(false) + } + "hidden-parent" => { + snapshot + .nodes + .get_mut(parent) + .unwrap() + .fields + .insert("visible".into(), json!(false)); + } + "none-parent" => { + snapshot + .nodes + .get_mut(parent) + .unwrap() + .fields + .insert("layoutMode".into(), json!("NONE")); + } + "unknown-stroke" => { + snapshot + .nodes + .get_mut(parent) + .unwrap() + .field_errors + .insert("strokes".into(), "read failed".into()); + } + "unknown-constraint" => { + snapshot + .nodes + .get_mut(child) + .unwrap() + .fields + .insert("constraints".into(), json!({"$unsupported":"unknown"})); + } + "center" => { + snapshot + .nodes + .get_mut(child) + .unwrap() + .fields + .get_mut("constraints") + .unwrap()["vertical"] = json!("CENTER") + } + "rotation" => { + snapshot + .nodes + .get_mut(child) + .unwrap() + .fields + .insert("rotation".into(), json!(20)); + } + _ => unreachable!(), + } + let output = generate_component( + &snapshot, + parent, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap(); + assert!( + !output.tsx.contains("top=\"33px\""), + "{variant}: {}", + output.tsx + ); + assert!( + !output.source_map.entries.iter().any(|entry| entry + .calculation + .as_deref() + .is_some_and(|v| v.contains("border-origin conversion"))), + "{variant}" + ); + } +} + +#[test] +fn semantic_float_identity_transform_gets_the_same_border_conversion() { + let (mut snapshot, parent, child) = source(false); + for id in [parent, child] { + let transform = snapshot + .nodes + .get_mut(id) + .unwrap() + .fields + .get_mut("relativeTransform") + .unwrap(); + transform[0][0] = json!(1.0); + transform[0][1] = json!(0.0); + transform[1][0] = json!(0.0); + transform[1][1] = json!(1.0); + } + let output = generate_component( + &snapshot, + parent, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap(); + assert!(output.tsx.contains("top=\"33px\""), "{}", output.tsx); +} diff --git a/crates/devup-mcp-devup-ui/tests/bound_line_rounding.rs b/crates/devup-mcp-devup-ui/tests/bound_line_rounding.rs new file mode 100644 index 00000000..42fd52fb --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/bound_line_rounding.rs @@ -0,0 +1,157 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, LengthToken, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; +use std::collections::BTreeMap; + +fn scene() -> Snapshot { + serde_json::from_value(json!({"fileKey":"bound-rounding","roots":["root"],"diagnostics":[],"nodes":{ + "root":{"id":"root","type":"FRAME","fields":{"name":"Probe","width":360,"height":300,"layoutMode":"VERTICAL","childrenIds":["text"]}}, + "text":{"id":"text","type":"TEXT","fields":{"name":"Caption","parentId":"root","width":320,"layoutSizingHorizontal":"FIXED","textAutoResize":"HEIGHT","characters":"Company · Address","fontSize":13,"lineHeight":{"unit":"PERCENT","value":150},"boundVariables":{"fontSize":{"id":"size","type":"VARIABLE_ALIAS"}}}} + }})).unwrap() +} + +#[test] +fn bound_resolved_font_uses_dynamic_integer_advance_with_source_provenance() { + let output = generate_component(&scene(), "root", &CodegenOptions::default()).unwrap(); + assert!( + output + .tsx + .contains("lineHeight=\"round(nearest, 1.5em, 1px)\""), + "{}", + output.tsx + ); + assert!(output.source_map.entries.iter().any(|e| { + e.property.as_deref() == Some("lineHeight") + && e.calculation + .as_deref() + .is_some_and(|c| c.contains("effective CSS fontSize per mode")) + })); + assert!(output.tsx.contains("{\" \"}")); + assert!(output.tsx.contains("{\" \"}")); +} + +#[test] +fn fixed_unknown_and_independently_bound_line_height_keep_their_policies() { + for case in ["fixed", "unknown", "bound-line"] { + let mut source = scene(); + let f = &mut source.nodes.get_mut("text").unwrap().fields; + if case == "fixed" { + f.remove("boundVariables"); + } + if case == "unknown" { + f.remove("fontSize"); + } + if case == "bound-line" { + f["boundVariables"]["lineHeight"] = json!({"id":"line","type":"VARIABLE_ALIAS"}); + } + let output = generate_component(&source, "root", &CodegenOptions::default()).unwrap(); + assert!( + output.tsx.contains(if case == "fixed" { + "lineHeight=\"20px\"" + } else { + "lineHeight=\"150%\"" + }), + "{}", + output.tsx + ); + } +} + +#[test] +fn changed_rich_text_font_restates_relative_rounded_advance() { + let mut source = scene(); + source.nodes.get_mut("text").unwrap().fields.insert("styledTextSegments".into(),json!([ + {"characters":"first","fontSize":13,"lineHeight":{"unit":"PERCENT","value":150},"boundVariables":{"fontSize":{"id":"size","type":"VARIABLE_ALIAS"}}}, + {"characters":"second","fontSize":15,"lineHeight":{"unit":"PERCENT","value":150},"boundVariables":{"fontSize":{"id":"other","type":"VARIABLE_ALIAS"}}} + ])); + let output = generate_component(&source, "root", &CodegenOptions::default()).unwrap(); + assert!( + output + .tsx + .matches("lineHeight=\"round(nearest, 1.5em, 1px)\"") + .count() + >= 2, + "{}", + output.tsx + ); +} + +#[test] +fn captured_footer_copy_and_dynamic_metrics_survive_formatter_probe_export() { + let captured: Snapshot = serde_json::from_str(include_str!( + "fixtures/chondrogen_footer_ascii_spacing.json" + )) + .unwrap(); + let mut source = scene(); + let node = source.nodes.get_mut("text").unwrap(); + node.fields = captured.nodes["43:1195"].fields.clone(); + node.fields.insert("parentId".into(), json!("root")); + node.fields.insert( + "boundVariables".into(), + json!({"fontSize":{"id":"size","type":"VARIABLE_ALIAS"}}), + ); + node.fields.remove("styledTextSegments"); + let options = CodegenOptions { + component_name: Some("CapturedFooterProbe".into()), + length_tokens: BTreeMap::from([( + "size".into(), + LengthToken { + token: "captionSize".into(), + value: json!(["13px", null, null, null, "14px"]), + }, + )]), + variable_tokens: BTreeMap::from([("size".into(), "captionSize".into())]), + ..Default::default() + }; + let output = generate_component(&source, "root", &options).unwrap(); + assert!(output.tsx.contains("round(nearest, 1.5em, 1px)")); + assert!(output.tsx.contains("{\" \"}")); + if let Ok(file) = std::env::var("DEVUP_FOOTER_TEXT_PROBE") { + std::fs::write(file, &output.tsx).unwrap(); + } + if let (Ok(snapshot_file), Ok(context_file), Ok(directory)) = ( + std::env::var("DEVUP_FULL_FOOTER_SNAPSHOT"), + std::env::var("DEVUP_FULL_FOOTER_CONTEXT"), + std::env::var("DEVUP_FULL_FOOTER_OUTPUT"), + ) { + let source: Snapshot = + serde_json::from_slice(&std::fs::read(snapshot_file).unwrap()).unwrap(); + let context: serde_json::Value = + serde_json::from_slice(&std::fs::read(context_file).unwrap()).unwrap(); + for (id, name) in [ + ("30:1119", "FooterDesktopProbe"), + ("43:1187", "FooterMobileProbe"), + ] { + let options = CodegenOptions { + root_layout: devup_mcp_devup_ui::codegen::RootLayout::Embedded, + inline_instances: true, + component_name: Some(name.into()), + variable_tokens: serde_json::from_value(context["variables"].clone()).unwrap(), + text_style_tokens: serde_json::from_value(context["styles"].clone()).unwrap(), + typography_values: serde_json::from_value(context["typography"].clone()).unwrap(), + length_tokens: context["lengths"] + .as_object() + .unwrap() + .iter() + .map(|(id, token)| { + ( + id.clone(), + LengthToken { + token: token["token"].as_str().unwrap().into(), + value: token["value"].clone(), + }, + ) + }) + .collect(), + ..Default::default() + }; + let generated = generate_component(&source, id, &options).unwrap(); + assert!(generated.tsx.contains("round(nearest, 1.5em, 1px)")); + std::fs::write( + std::path::Path::new(&directory).join(format!("{name}.tsx")), + generated.tsx, + ) + .unwrap(); + } + } +} diff --git a/crates/devup-mcp-devup-ui/tests/captured_korean_wrapping.rs b/crates/devup-mcp-devup-ui/tests/captured_korean_wrapping.rs new file mode 100644 index 00000000..7cd06ee9 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/captured_korean_wrapping.rs @@ -0,0 +1,142 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +fn fixture() -> Snapshot { + serde_json::from_str(include_str!("fixtures/vision-wrapping-text.json")).unwrap() +} + +fn render(snapshot: &Snapshot, id: &str, options: &CodegenOptions) -> String { + generate_component(snapshot, id, options).unwrap().tsx +} + +#[test] +fn captured_vision_height_text_preserves_korean_word_boundaries_and_provenance() { + let snapshot = fixture(); + for (id, name, width, height) in [ + ("I44:3228;44:3621", "MissionWrap", 240, 46), + ("I44:3238;44:3704", "ValueWrap", 260, 52), + ] { + let options = CodegenOptions { + component_name: Some(name.into()), + ..Default::default() + }; + let output = generate_component(&snapshot, id, &options).unwrap(); + assert!( + output.tsx.contains("wordBreak=\"keep-all\""), + "{}", + output.tsx + ); + assert!( + !output.tsx.contains(&format!("h=\"{height}px\"")), + "No captured text height may be forced" + ); + assert_eq!(snapshot.nodes[id].fields["width"], width); + assert!( + output + .source_map + .entries + .iter() + .any( + |entry| entry.generated_property.as_deref() == Some("wordBreak=\"keep-all\"") + && entry.property.as_deref() == Some("width") + ), + "{:?}", + output.source_map + ); + if let Ok(directory) = std::env::var("DEVUP_VISION_WRAP_PROBE_OUTPUT") { + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + std::path::Path::new(&directory).join(format!("{name}.tsx")), + output.tsx, + ) + .unwrap(); + } + } +} + +#[test] +fn unbounded_no_wrap_clamped_and_unknown_text_keep_the_existing_policy() { + for changes in [ + json!({"width":null}), + json!({"width":{"$unsupported":"unknown"}}), + json!({"layoutSizingHorizontal":"HUG"}), + json!({"layoutSizingHorizontal":null}), + json!({"textAutoResize":"WIDTH_AND_HEIGHT"}), + json!({"textAutoResize":"NONE"}), + json!({"textAutoResize":null}), + json!({"textTruncation":null}), + json!({"textTruncation":"ENDING"}), + json!({"maxLines":1}), + json!({"maxLines":2}), + json!({"maxLines":{"$unsupported":"unreadable"}}), + ] { + let mut snapshot = fixture(); + snapshot + .nodes + .get_mut("I44:3228;44:3621") + .unwrap() + .fields + .extend(changes.as_object().unwrap().clone()); + let source = render(&snapshot, "I44:3228;44:3621", &CodegenOptions::default()); + assert!( + !source.contains("wordBreak=\"normal\""), + "{changes}: {source}" + ); + } + let mut snapshot = fixture(); + snapshot + .nodes + .get_mut("I44:3228;44:3621") + .unwrap() + .field_errors + .insert("maxLines".into(), "read failed".into()); + let source = render(&snapshot, "I44:3228;44:3621", &CodegenOptions::default()); + assert!(source.contains("wordBreak=\"keep-all\""), "{source}"); +} + +#[test] +fn latin_text_gets_no_korean_default() { + let mut snapshot = fixture(); + let fields = &mut snapshot.nodes.get_mut("I44:3228;44:3621").unwrap().fields; + fields.insert("characters".into(), json!("A captured line of Latin text")); + fields.insert( + "styledTextSegments".into(), + json!([{"characters":"A captured line of Latin text"}]), + ); + let source = render(&snapshot, "I44:3228;44:3621", &CodegenOptions::default()); + assert!(!source.contains("wordBreak="), "{source}"); +} + +#[test] +fn korean_plain_text_without_styled_segments_keeps_word_boundaries() { + let mut snapshot = fixture(); + let fields = &mut snapshot.nodes.get_mut("I44:3228;44:3621").unwrap().fields; + fields.remove("styledTextSegments"); + fields.insert("characters".into(), json!("오르고 앉는 일상을 지킵니다.")); + let source = render(&snapshot, "I44:3228;44:3621", &CodegenOptions::default()); + assert!(source.contains("wordBreak=\"keep-all\""), "{source}"); + assert!(source.contains("overflowWrap=\"break-word\""), "{source}"); + assert!(!source.contains("wordBreak=\"normal\""), "{source}"); +} + +#[test] +fn explicit_user_typography_word_break_is_preserved_including_responsive_modes() { + for typography in [ + json!({"wordBreak":"keep-all"}), + json!([{"wordBreak":"break-all"},null,{"wordBreak":"keep-all"}]), + ] { + let mut snapshot = fixture(); + let fields = &mut snapshot.nodes.get_mut("I44:3228;44:3621").unwrap().fields; + fields.insert("textStyleId".into(), json!("style")); + fields["styledTextSegments"][0]["textStyleId"] = json!("style"); + let options = CodegenOptions { + text_style_tokens: [("style".into(), "body".into())].into(), + typography_values: [("body".into(), typography)].into(), + ..Default::default() + }; + let source = render(&snapshot, "I44:3228;44:3621", &options); + assert!(source.contains("typography=\"body\""), "{source}"); + assert!(!source.contains("wordBreak="), "{source}"); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/captured_wrapping.rs b/crates/devup-mcp-devup-ui/tests/captured_wrapping.rs new file mode 100644 index 00000000..f9a1efe1 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/captured_wrapping.rs @@ -0,0 +1,197 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +fn scene(mode: &str, cross: Option) -> Snapshot { + let mut nodes = serde_json::Map::new(); + let children = (0..9).map(|i| format!("card-{i}")).collect::>(); + let mut root = json!({"id":"root","type":"FRAME","fields":{ + "name":"Captured cards","width":1280,"height":993,"layoutMode":mode, + "layoutWrap":"WRAP","layoutSizingHorizontal":"FILL","layoutSizingVertical":"HUG", + "itemSpacing":32,"childrenIds":children, + "inferredAutoLayout":{"layoutMode":mode,"itemSpacing":32} + }}); + if let Some(cross) = cross { + root["fields"]["counterAxisSpacing"] = json!(cross); + } + nodes.insert("root".into(), root); + for id in children { + nodes.insert( + id.clone(), + json!({"id":id,"type":"FRAME","fields":{ + "name":"Card","parentId":"root","width":405,"height":299, + "layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED" + }}), + ); + } + serde_json::from_value( + json!({"fileKey":"captured-wrap","roots":["root"],"nodes":nodes,"diagnostics":[]}), + ) + .unwrap() +} + +#[test] +fn captured_horizontal_wrap_keeps_nine_fixed_cards_and_independent_gaps() { + let output = generate_component( + &scene("HORIZONTAL", Some(48.0)), + "root", + &CodegenOptions::default(), + ) + .unwrap(); + assert!( + !devup_mcp_devup_ui::provenance::validate_fidelity( + &scene("HORIZONTAL", Some(48.0)), + "root", + &output + ) + .unwrap() + .uncovered_layout + .contains(&"root#itemSpacing".to_owned()) + ); + assert!( + output + .source_map + .entries + .iter() + .any(|entry| entry.node_id.as_deref() == Some("root") + && entry.property.as_deref() == Some("itemSpacing") + && entry.generated_property.as_deref() == Some("columnGap=\"32px\"")) + ); + for expected in ["flexWrap=\"wrap\"", "columnGap=\"32px\"", "rowGap=\"48px\""] { + assert!( + output.tsx.contains(expected), + "missing {expected}: {}", + output.tsx + ); + } + assert!(!output.tsx.contains("gridTemplate"), "{}", output.tsx); + assert_eq!(output.tsx.matches("w=\"405px\"").count(), 9); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED"), + "{:?}", + output.diagnostics + ); +} + +#[test] +fn wrap_gap_contract_rejects_wrong_main_gap_value_and_cross_axis_evidence() { + for mode in ["HORIZONTAL", "VERTICAL"] { + let snapshot = scene(mode, Some(48.0)); + let output = generate_component(&snapshot, "root", &CodegenOptions::default()).unwrap(); + assert!( + !devup_mcp_devup_ui::provenance::validate_fidelity(&snapshot, "root", &output) + .unwrap() + .uncovered_layout + .contains(&"root#itemSpacing".to_owned()) + ); + let mut changed = output.clone(); + let main = if mode == "HORIZONTAL" { + "columnGap" + } else { + "rowGap" + }; + changed.tsx = changed + .tsx + .replace(&format!("{main}=\"32px\""), &format!("{main}=\"33px\"")); + let fidelity = + devup_mcp_devup_ui::provenance::validate_fidelity(&snapshot, "root", &changed).unwrap(); + assert!( + fidelity + .uncovered_layout + .contains(&"root#itemSpacing".to_owned()) + ); + let mut wrong_axis = output.clone(); + let cross = wrong_axis + .source_map + .entries + .iter() + .find(|e| { + e.node_id.as_deref() == Some("root") + && e.property.as_deref() == Some("counterAxisSpacing") + }) + .unwrap() + .generated_range + .clone(); + wrong_axis + .source_map + .entries + .iter_mut() + .filter(|e| { + e.node_id.as_deref() == Some("root") && e.property.as_deref() == Some("itemSpacing") + }) + .for_each(|entry| entry.generated_range = cross.clone()); + let fidelity = + devup_mcp_devup_ui::provenance::validate_fidelity(&snapshot, "root", &wrong_axis) + .unwrap(); + assert!( + fidelity + .uncovered_layout + .contains(&"root#itemSpacing".to_owned()) + ); + } +} + +#[test] +fn vertical_wrap_exchanges_main_and_cross_gap_axes() { + let output = generate_component( + &scene("VERTICAL", Some(12.0)), + "root", + &CodegenOptions::default(), + ) + .unwrap(); + for expected in ["flexWrap=\"wrap\"", "rowGap=\"32px\"", "columnGap=\"12px\""] { + assert!( + output.tsx.contains(expected), + "missing {expected}: {}", + output.tsx + ); + } +} + +#[test] +fn absent_cross_gap_is_not_invented() { + let wrapped = generate_component( + &scene("HORIZONTAL", None), + "root", + &CodegenOptions::default(), + ) + .unwrap(); + assert!(wrapped.tsx.contains("flexWrap=\"wrap\""), "{}", wrapped.tsx); + assert!(wrapped.tsx.contains("gap=\"32px\""), "{}", wrapped.tsx); + assert!(!wrapped.tsx.contains("rowGap=")); +} + +#[test] +fn wrapped_space_between_keeps_zero_cross_gap_and_distribution() { + let mut snapshot = scene("HORIZONTAL", Some(0.0)); + snapshot + .nodes + .get_mut("root") + .unwrap() + .fields + .insert("counterAxisAlignContent".into(), json!("SPACE_BETWEEN")); + let output = generate_component(&snapshot, "root", &CodegenOptions::default()).unwrap(); + for expected in [ + "flexWrap=\"wrap\"", + "columnGap=\"32px\"", + "rowGap=\"0px\"", + "alignContent=\"space-between\"", + ] { + assert!( + output.tsx.contains(expected), + "missing {expected}: {}", + output.tsx + ); + } + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED"), + "{:?}", + output.diagnostics + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/carriage_return_rendering.rs b/crates/devup-mcp-devup-ui/tests/carriage_return_rendering.rs new file mode 100644 index 00000000..6e00a08f --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/carriage_return_rendering.rs @@ -0,0 +1,79 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +#[test] +fn captured_transit_controls_remain_one_line_and_keep_raw_provenance() { + // Captured 2026-11-02. Figma native reference-company-location PNGs paint + // these transit suffixes on the same line, separated by a space. Their + // captured single-line boxes are 24px desktop and 23px mobile. + let snapshot: Snapshot = serde_json::from_str(include_str!( + "fixtures/chondrogen_transit_carriage_returns.json" + )) + .unwrap(); + let original = snapshot.clone(); + for id in &snapshot.roots { + let raw = snapshot.nodes[id] + .typed_view() + .string("characters") + .unwrap(); + assert!(raw.contains('\r') && !raw.contains('\n')); + let height = snapshot.nodes[id].typed_view().number("height").unwrap(); + assert!([23.0, 24.0].contains(&height)); + let output = generate_component(&snapshot, id, &CodegenOptions::default()).unwrap(); + assert!(!output.tsx.contains("(); + // This captured unnamed axis is exactly default/hover/active. Its root + // paints bind VariableID:13:161 / 11:2 / 11:3 respectively; descendants + // retain the same option label, typography, colour and spacing. + assert!(compact.contains("_active={{\"bg\":\"$3\"}}"), "{tsx}"); + assert!(compact.contains("_hover={{\"bg\":\"$2\"}}"), "{tsx}"); + assert!(tsx.contains("bg=\"$161\""), "{tsx}"); + assert!(!tsx.contains("property1:"), "{tsx}"); + assert!(tsx.contains("typography=\"bodySm\""), "{tsx}"); + assert!(tsx.contains("color=\"#374051\""), "{tsx}"); + assert!( + tsx.contains("px=\"16px\"") && tsx.contains("py=\"12px\""), + "{tsx}" + ); + let label = source.nodes["45:2030"].fields["characters"] + .as_str() + .unwrap(); + assert!( + tsx.contains(&serde_json::to_string(label).unwrap()), + "{tsx}" + ); + assert!(tsx.contains("{text1}"), "{tsx}"); } #[test] diff --git a/crates/devup-mcp-devup-ui/tests/clipped_free_offset.rs b/crates/devup-mcp-devup-ui/tests/clipped_free_offset.rs new file mode 100644 index 00000000..f35a62aa --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/clipped_free_offset.rs @@ -0,0 +1,91 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +fn source() -> Snapshot { + serde_json::from_str(include_str!("fixtures/clipped-free-pagemenu.json")).unwrap() +} + +fn render(snapshot: &Snapshot) -> String { + generate_component( + snapshot, + "73:8525", + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .tsx +} + +#[test] +fn inferred_layout_cannot_discard_the_captured_negative_free_frame_offset() { + let snapshot = source(); + let output = render(&snapshot); + if let Ok(path) = std::env::var("DEVUP_OFFSET_PROBE_OUTPUT") { + std::fs::write(path, &output).unwrap(); + } + assert_eq!(snapshot.nodes["73:8525"].fields["layoutMode"], "NONE"); + assert_eq!( + snapshot.nodes["73:8525"].fields["overflowDirection"], + "NONE" + ); + for expected in [ + "pos=\"relative\"", + "h=\"47px\"", + "overflow=\"hidden\"", + "pos=\"absolute\"", + "left=\"clamp(-151px, calc(100% - 582px), 0px)\"", + "top=\"0px\"", + "w=\"582px\"", + ] { + assert!(output.contains(expected), "missing {expected}: {output}"); + } + assert!( + !output.contains("overflowX="), + "scrolling was never captured: {output}" + ); +} + +#[test] +fn actual_auto_layout_and_unclipped_inference_are_not_replaced_by_free_positioning() { + for (field, value) in [ + ("layoutMode", json!("HORIZONTAL")), + ("clipsContent", json!(false)), + ] { + let mut snapshot = source(); + snapshot + .nodes + .get_mut("73:8525") + .unwrap() + .fields + .insert(field.into(), value); + let output = render(&snapshot); + assert!(!output.contains("clamp("), "{field}: {output}"); + } +} + +#[test] +fn a_child_inside_the_free_frame_does_not_gain_an_absolute_offset() { + let mut snapshot = source(); + let child = snapshot.nodes.get_mut("73:8526").unwrap(); + child.fields.insert("x".into(), json!(0)); + child.fields["absoluteBoundingBox"]["x"] = json!(38352); + let output = render(&snapshot); + assert!(!output.contains("pos=\"absolute\""), "{output}"); +} + +#[test] +fn a_fixed_parent_keeps_its_captured_offset_without_fluid_clamping() { + let mut snapshot = source(); + snapshot + .nodes + .get_mut("73:8525") + .unwrap() + .fields + .insert("layoutSizingHorizontal".into(), json!("FIXED")); + let output = render(&snapshot); + assert!(output.contains("left=\"-151px\""), "{output}"); + assert!(!output.contains("clamp("), "{output}"); +} diff --git a/crates/devup-mcp-devup-ui/tests/codegen.rs b/crates/devup-mcp-devup-ui/tests/codegen.rs index 8d0832e6..d429d575 100644 --- a/crates/devup-mcp-devup-ui/tests/codegen.rs +++ b/crates/devup-mcp-devup-ui/tests/codegen.rs @@ -61,7 +61,14 @@ fn generates_deterministic_devup_ui_tsx() { "export function Fr026본연체() {\n", " return (\n", " \n", - " \n", + " \n", " 안녕 {\"<\"}Devup{\">\"}\n", " \n", " \n", diff --git a/crates/devup-mcp-devup-ui/tests/fixed_absolute_ellipses.rs b/crates/devup-mcp-devup-ui/tests/fixed_absolute_ellipses.rs new file mode 100644 index 00000000..13953461 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixed_absolute_ellipses.rs @@ -0,0 +1,111 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +fn source(id: &str) -> Snapshot { + let captured: Snapshot = + serde_json::from_str(include_str!("fixtures/vision-solid-ellipses.json")).unwrap(); + let mut ellipse = captured.nodes[id].clone(); + ellipse.fields.insert("parentId".into(), json!("parent")); + let mut snapshot: Snapshot = serde_json::from_value(json!({"fileKey":"fixed-ellipse","roots":["parent"],"diagnostics":[],"nodes":{ + "parent":{"id":"parent","type":"FRAME","fields":{"name":"EllipseProbe","width":400,"height":300, + "layoutMode":"VERTICAL","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED", + "childrenIds":[id,"label"],"absoluteBoundingBox":{"x":0,"y":0,"width":400,"height":300}}}, + "label":{"id":"label","type":"TEXT","fields":{"parentId":"parent","characters":"Live sibling", + "fontSize":16,"textAutoResize":"WIDTH_AND_HEIGHT","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG"}} + }})).unwrap(); + snapshot.nodes.insert(id.into(), ellipse); + snapshot +} + +fn render(snapshot: &Snapshot) -> String { + generate_component( + snapshot, + "parent", + &CodegenOptions { + inline_instances: true, + component_name: Some("EllipseProbe".into()), + ..Default::default() + }, + ) + .unwrap() + .tsx +} + +#[test] +fn captured_vision_solid_ellipses_preserve_both_absolute_fixed_axes() { + for (id, size) in [ + ("I44:2832;44:3209", 48), + ("I44:2833;44:3209", 48), + ("I44:2834;44:3209", 48), + ("I44:2835;44:3209", 48), + ("I44:3235;44:3700", 32), + ("I44:3236;44:3700", 32), + ("I44:3237;44:3700", 32), + ("I44:3238;44:3700", 32), + ] { + let output = render(&source(id)); + assert!( + output.contains(&format!("boxSize=\"{size}px\"")), + "{id}: {output}" + ); + assert!(output.contains("borderRadius=\"50%\""), "{output}"); + assert!(!output.contains("h=\"100%\""), "{output}"); + if id == "I44:2832;44:3209" + && let Ok(path) = std::env::var("DEVUP_ELLIPSE_PROBE_OUTPUT") + { + std::fs::write(path, output).unwrap(); + } + } +} + +#[test] +fn fluid_unknown_and_unreadable_geometry_do_not_gain_fixed_shape_sizes() { + for (field, value) in [ + ("width", json!(null)), + ("height", json!({"$unsupported":"unknown"})), + ("layoutSizingHorizontal", json!("FILL")), + ("layoutSizingVertical", json!("HUG")), + ] { + let mut snapshot = source("I44:2832;44:3209"); + snapshot + .nodes + .get_mut("I44:2832;44:3209") + .unwrap() + .fields + .insert(field.into(), value); + let output = render(&snapshot); + assert!(!output.contains("boxSize=\"48px\""), "{field}: {output}"); + } + let mut snapshot = source("I44:2832;44:3209"); + snapshot + .nodes + .get_mut("I44:2832;44:3209") + .unwrap() + .field_errors + .insert("width".into(), "read failed".into()); + assert!(!render(&snapshot).contains("boxSize=\"48px\"")); +} + +#[test] +fn relative_ellipse_and_other_shape_paths_keep_their_existing_sizing() { + for positioning in [json!("AUTO"), json!({"$unsupported":"unknown"})] { + let mut snapshot = source("I44:2832;44:3209"); + snapshot + .nodes + .get_mut("I44:2832;44:3209") + .unwrap() + .fields + .insert("layoutPositioning".into(), positioning); + let output = render(&snapshot); + assert!(!output.contains("pos=\"absolute\""), "{output}"); + } + let mut snapshot = source("I44:2832;44:3209"); + snapshot + .nodes + .get_mut("I44:2832;44:3209") + .unwrap() + .node_type = "RECTANGLE".into(); + let output = render(&snapshot); + assert!(!output.contains("boxSize=\"48px\""), "{output}"); +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/about-grid-gap-aliases.json b/crates/devup-mcp-devup-ui/tests/fixtures/about-grid-gap-aliases.json new file mode 100644 index 00000000..4fd2ee07 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/about-grid-gap-aliases.json @@ -0,0 +1,3706 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "37:213", + "42:944" + ], + "nodes": { + "41:771": { + "id": "41:771", + "type": "FRAME", + "fields": { + "parentId": "37:213", + "childrenIds": [ + "41:758", + "41:765", + "41:772" + ], + "absoluteBoundingBox": { + "height": 294, + "width": 1280, + "x": 20428, + "y": 2809 + }, + "absoluteRenderBounds": { + "height": 294, + "width": 1280, + "x": 20428, + "y": 2809 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "gridColumnCount": 6, + "gridColumnGap": 40, + "gridColumnSizes": [ + { + "type": "FLEX", + "value": 1 + }, + { + "type": "FLEX", + "value": 1 + }, + { + "type": "FLEX", + "value": 1 + }, + { + "type": "FLEX", + "value": 1 + }, + { + "type": "FLEX", + "value": 1 + }, + { + "type": "FLEX", + "value": 1 + } + ], + "gridRowCount": 1, + "gridRowGap": 48, + "gridRowSizes": [ + { + "type": "HUG", + "value": 1 + } + ], + "height": 294, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 48, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "GRID", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 48, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "GRID", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 488", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 528 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1280, + "x": 0, + "y": 528 + }, + "extra": {}, + "fieldErrors": {} + }, + "41:758": { + "id": "41:758", + "type": "INSTANCE", + "fields": { + "parentId": "41:771", + "childrenIds": [ + "I41:758;41:752", + "I41:758;41:754" + ], + "absoluteBoundingBox": { + "height": 294, + "width": 180, + "x": 20428, + "y": 2809 + }, + "absoluteRenderBounds": { + "height": 294, + "width": 180, + "x": 20428, + "y": 2809 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Team" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Desktop" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "gridColumnAnchorIndex": 0, + "gridRowAnchorIndex": 0, + "height": 294, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "ProfileCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "41:758", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I41:758;41:755", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I41:758;41:756", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "type": "Team", + "viewport": "Desktop" + }, + "visible": true, + "width": 180, + "x": 0, + "y": 0, + "mainComponentId": "41:751", + "mainComponentName": "type=Team, viewport=Desktop", + "mainComponentSetId": "41:750" + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:758;41:752": { + "id": "I41:758;41:752", + "type": "RECTANGLE", + "fields": { + "parentId": "41:758", + "absoluteBoundingBox": { + "height": 225, + "width": 180, + "x": 20428, + "y": 2809 + }, + "absoluteRenderBounds": { + "height": 225, + "width": 180, + "x": 20428, + "y": 2809 + }, + "bottomLeftRadius": 12, + "bottomRightRadius": 12, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 12, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.8509804010391235, + "g": 0.8509804010391235, + "r": 0.8509804010391235 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 225, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Rectangle 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 4, + "y": 5 + }, + "topLeftRadius": 12, + "topRightRadius": 12, + "visible": true, + "width": 180, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:758;41:754": { + "id": "I41:758;41:754", + "type": "FRAME", + "fields": { + "parentId": "41:758", + "childrenIds": [ + "I41:758;41:756", + "I41:758;41:755" + ], + "absoluteBoundingBox": { + "height": 53, + "width": 180, + "x": 20428, + "y": 3050 + }, + "absoluteRenderBounds": { + "height": 53, + "width": 180, + "x": 20428, + "y": 3050 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 53, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 486", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 241 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 180, + "x": 0, + "y": 241 + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:758;41:756": { + "id": "I41:758;41:756", + "type": "TEXT", + "fields": { + "parentId": "I41:758;41:754", + "absoluteBoundingBox": { + "height": 28, + "width": 180, + "x": 20428, + "y": 3050 + }, + "absoluteRenderBounds": { + "height": 18.02001953125, + "width": 49.12109375, + "x": 20492.83984375, + "y": 3054.699951171875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "김보희", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 20, + "height": 28, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "임군일", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "DISABLED", + "visible": true, + "width": 180, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "김보희", + "end": 3, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:758;41:755": { + "id": "I41:758;41:755", + "type": "TEXT", + "fields": { + "parentId": "I41:758;41:754", + "absoluteBoundingBox": { + "height": 21, + "width": 180, + "x": 20428, + "y": 3082 + }, + "absoluteRenderBounds": { + "height": 12.4599609375, + "width": 89.595703125, + "x": 20473.29296875, + "y": 3086.674072265625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "분석 및 QC 팀장", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "대표이사 (CEO)", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 32 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "HEIGHT", + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d,", + "textTruncation": "DISABLED", + "visible": true, + "width": 180, + "x": 0, + "y": 32, + "styledTextSegments": [ + { + "characters": "분석 및 QC 팀장", + "end": 10, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "41:765": { + "id": "41:765", + "type": "INSTANCE", + "fields": { + "parentId": "41:771", + "childrenIds": [ + "I41:765;41:752", + "I41:765;41:754" + ], + "absoluteBoundingBox": { + "height": 294, + "width": 180, + "x": 20648, + "y": 2809 + }, + "absoluteRenderBounds": { + "height": 294, + "width": 180, + "x": 20648, + "y": 2809 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Team" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Desktop" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "gridColumnAnchorIndex": 1, + "gridRowAnchorIndex": 0, + "height": 294, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "ProfileCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "41:765", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I41:765;41:755", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I41:765;41:756", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 220 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "type": "Team", + "viewport": "Desktop" + }, + "visible": true, + "width": 180, + "x": 220, + "y": 0, + "mainComponentId": "41:751", + "mainComponentName": "type=Team, viewport=Desktop", + "mainComponentSetId": "41:750" + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:765;41:752": { + "id": "I41:765;41:752", + "type": "RECTANGLE", + "fields": { + "parentId": "41:765", + "absoluteBoundingBox": { + "height": 225, + "width": 180, + "x": 20648, + "y": 2809 + }, + "absoluteRenderBounds": { + "height": 225, + "width": 180, + "x": 20648, + "y": 2809 + }, + "bottomLeftRadius": 12, + "bottomRightRadius": 12, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 12, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.8509804010391235, + "g": 0.8509804010391235, + "r": 0.8509804010391235 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 225, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Rectangle 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 4, + "y": 5 + }, + "topLeftRadius": 12, + "topRightRadius": 12, + "visible": true, + "width": 180, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:765;41:754": { + "id": "I41:765;41:754", + "type": "FRAME", + "fields": { + "parentId": "41:765", + "childrenIds": [ + "I41:765;41:756", + "I41:765;41:755" + ], + "absoluteBoundingBox": { + "height": 53, + "width": 180, + "x": 20648, + "y": 3050 + }, + "absoluteRenderBounds": { + "height": 53, + "width": 180, + "x": 20648, + "y": 3050 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 53, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 486", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 241 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 180, + "x": 0, + "y": 241 + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:765;41:756": { + "id": "I41:765;41:756", + "type": "TEXT", + "fields": { + "parentId": "I41:765;41:754", + "absoluteBoundingBox": { + "height": 28, + "width": 180, + "x": 20648, + "y": 3050 + }, + "absoluteRenderBounds": { + "height": 18.02001953125, + "width": 49.400390625, + "x": 20712.66015625, + "y": 3054.719970703125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "정혜정", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 20, + "height": 28, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "임군일", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "DISABLED", + "visible": true, + "width": 180, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "정혜정", + "end": 3, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:765;41:755": { + "id": "I41:765;41:755", + "type": "TEXT", + "fields": { + "parentId": "I41:765;41:754", + "absoluteBoundingBox": { + "height": 21, + "width": 180, + "x": 20648, + "y": 3082 + }, + "absoluteRenderBounds": { + "height": 12.599853515625, + "width": 85.51953125, + "x": 20695.66015625, + "y": 3086.548095703125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "비임상시험 팀장", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "대표이사 (CEO)", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 32 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "HEIGHT", + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d,", + "textTruncation": "DISABLED", + "visible": true, + "width": 180, + "x": 0, + "y": 32, + "styledTextSegments": [ + { + "characters": "비임상시험 팀장", + "end": 8, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "41:772": { + "id": "41:772", + "type": "INSTANCE", + "fields": { + "parentId": "41:771", + "childrenIds": [ + "I41:772;41:752", + "I41:772;41:754" + ], + "absoluteBoundingBox": { + "height": 294, + "width": 180, + "x": 20868, + "y": 2809 + }, + "absoluteRenderBounds": { + "height": 294, + "width": 180, + "x": 20868, + "y": 2809 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Team" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Desktop" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "gridColumnAnchorIndex": 2, + "gridRowAnchorIndex": 0, + "height": 294, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "ProfileCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "41:772", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I41:772;41:755", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I41:772;41:756", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 440 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "type": "Team", + "viewport": "Desktop" + }, + "visible": true, + "width": 180, + "x": 440, + "y": 0, + "mainComponentId": "41:751", + "mainComponentName": "type=Team, viewport=Desktop", + "mainComponentSetId": "41:750" + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:772;41:752": { + "id": "I41:772;41:752", + "type": "RECTANGLE", + "fields": { + "parentId": "41:772", + "absoluteBoundingBox": { + "height": 225, + "width": 180, + "x": 20868, + "y": 2809 + }, + "absoluteRenderBounds": { + "height": 225, + "width": 180, + "x": 20868, + "y": 2809 + }, + "bottomLeftRadius": 12, + "bottomRightRadius": 12, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 12, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.8509804010391235, + "g": 0.8509804010391235, + "r": 0.8509804010391235 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 225, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Rectangle 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 4, + "y": 5 + }, + "topLeftRadius": 12, + "topRightRadius": 12, + "visible": true, + "width": 180, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:772;41:754": { + "id": "I41:772;41:754", + "type": "FRAME", + "fields": { + "parentId": "41:772", + "childrenIds": [ + "I41:772;41:756", + "I41:772;41:755" + ], + "absoluteBoundingBox": { + "height": 53, + "width": 180, + "x": 20868, + "y": 3050 + }, + "absoluteRenderBounds": { + "height": 53, + "width": 180, + "x": 20868, + "y": 3050 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 53, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 486", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 241 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 180, + "x": 0, + "y": 241 + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:772;41:756": { + "id": "I41:772;41:756", + "type": "TEXT", + "fields": { + "parentId": "I41:772;41:754", + "absoluteBoundingBox": { + "height": 28, + "width": 180, + "x": 20868, + "y": 3050 + }, + "absoluteRenderBounds": { + "height": 18, + "width": 50.58203125, + "x": 20932.638671875, + "y": 3054.719970703125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "전희수", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 20, + "height": 28, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "임군일", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "DISABLED", + "visible": true, + "width": 180, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "전희수", + "end": 3, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I41:772;41:755": { + "id": "I41:772;41:755", + "type": "TEXT", + "fields": { + "parentId": "I41:772;41:754", + "absoluteBoundingBox": { + "height": 21, + "width": 180, + "x": 20868, + "y": 3082 + }, + "absoluteRenderBounds": { + "height": 12.47412109375, + "width": 74.095703125, + "x": 20921.10546875, + "y": 3086.659912109375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "경영지원 팀장", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "대표이사 (CEO)", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 32 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "HEIGHT", + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d,", + "textTruncation": "DISABLED", + "visible": true, + "width": 180, + "x": 0, + "y": 32, + "styledTextSegments": [ + { + "characters": "경영지원 팀장", + "end": 7, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "37:213": { + "id": "37:213", + "type": "FRAME", + "fields": { + "parentId": "43:1232", + "parentType": "SECTION", + "parentName": "기업소개", + "childrenIds": [ + "41:771" + ], + "absoluteBoundingBox": { + "height": 3356, + "width": 1440, + "x": 20348, + "y": 1138 + }, + "absoluteRenderBounds": { + "height": 3356, + "width": 1440, + "x": 20348, + "y": 1138 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 3356, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Desktop", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 100 + ], + [ + 0, + 1, + 100 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 100, + "y": 100 + }, + "extra": {}, + "fieldErrors": {} + }, + "42:964": { + "id": "42:964", + "type": "FRAME", + "fields": { + "parentId": "42:944", + "childrenIds": [ + "42:965", + "42:966", + "42:967" + ], + "absoluteBoundingBox": { + "height": 340, + "width": 320, + "x": 21849, + "y": 2913 + }, + "absoluteRenderBounds": { + "height": 340, + "width": 320, + "x": 21849, + "y": 2913 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 340, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 20, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 20, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 488", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 731 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 731 + }, + "extra": {}, + "fieldErrors": {} + }, + "42:965": { + "id": "42:965", + "type": "INSTANCE", + "fields": { + "parentId": "42:964", + "childrenIds": [ + "I42:965;44:1253", + "I42:965;44:1254" + ], + "absoluteBoundingBox": { + "height": 100, + "width": 320, + "x": 21849, + "y": 2913 + }, + "absoluteRenderBounds": { + "height": 100, + "width": 320, + "x": 21849, + "y": 2913 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Team" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Mobile" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 100, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "ProfileCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "42:965", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I42:965;44:1255", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I42:965;44:1256", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "type": "Team", + "viewport": "Mobile" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 0, + "mainComponentId": "44:1252", + "mainComponentName": "type=Team, viewport=Mobile", + "mainComponentSetId": "41:750" + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:965;44:1253": { + "id": "I42:965;44:1253", + "type": "RECTANGLE", + "fields": { + "parentId": "42:965", + "absoluteBoundingBox": { + "height": 100, + "width": 80, + "x": 21849, + "y": 2913 + }, + "absoluteRenderBounds": { + "height": 100, + "width": 80, + "x": 21849, + "y": 2913 + }, + "bottomLeftRadius": 8, + "bottomRightRadius": 8, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 8, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.8509804010391235, + "g": 0.8509804010391235, + "r": 0.8509804010391235 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 100, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Rectangle 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 4, + "y": 5 + }, + "topLeftRadius": 8, + "topRightRadius": 8, + "visible": true, + "width": 80, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:965;44:1254": { + "id": "I42:965;44:1254", + "type": "FRAME", + "fields": { + "parentId": "42:965", + "childrenIds": [ + "I42:965;44:1255", + "I42:965;44:1256" + ], + "absoluteBoundingBox": { + "height": 73, + "width": 224, + "x": 21945, + "y": 2926.5 + }, + "absoluteRenderBounds": { + "height": 73, + "width": 224, + "x": 21945, + "y": 2926.5 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 73, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 4, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 4, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 486", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 12, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 12, + "relativeTransform": [ + [ + 1, + 0, + 96 + ], + [ + 0, + 1, + 13.5 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 224, + "x": 96, + "y": 13.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:965;44:1255": { + "id": "I42:965;44:1255", + "type": "TEXT", + "fields": { + "parentId": "I42:965;44:1254", + "absoluteBoundingBox": { + "height": 25, + "width": 224, + "x": 21945, + "y": 2938.5 + }, + "absoluteRenderBounds": { + "height": 16.218017578125, + "width": 44.208984375, + "x": 21945.59375, + "y": 2942.830078125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "김보희", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 18, + "height": 25, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "임군일", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 12 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "DISABLED", + "visible": true, + "width": 224, + "x": 0, + "y": 12, + "styledTextSegments": [ + { + "characters": "김보희", + "end": 3, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:965;44:1256": { + "id": "I42:965;44:1256", + "type": "TEXT", + "fields": { + "parentId": "I42:965;44:1254", + "absoluteBoundingBox": { + "height": 20, + "width": 224, + "x": 21945, + "y": 2967.5 + }, + "absoluteRenderBounds": { + "height": 11.570068359375, + "width": 83.197265625, + "x": 21945.4296875, + "y": 2971.98291015625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "분석 및 QC 팀장", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "대표이사 (CEO)", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 41 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d,", + "textTruncation": "DISABLED", + "visible": true, + "width": 224, + "x": 0, + "y": 41, + "styledTextSegments": [ + { + "characters": "분석 및 QC 팀장", + "end": 10, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "42:966": { + "id": "42:966", + "type": "INSTANCE", + "fields": { + "parentId": "42:964", + "childrenIds": [ + "I42:966;44:1253", + "I42:966;44:1254" + ], + "absoluteBoundingBox": { + "height": 100, + "width": 320, + "x": 21849, + "y": 3033 + }, + "absoluteRenderBounds": { + "height": 100, + "width": 320, + "x": 21849, + "y": 3033 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Team" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Mobile" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 100, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "ProfileCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "42:966", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I42:966;44:1255", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I42:966;44:1256", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 120 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "type": "Team", + "viewport": "Mobile" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 120, + "mainComponentId": "44:1252", + "mainComponentName": "type=Team, viewport=Mobile", + "mainComponentSetId": "41:750" + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:966;44:1253": { + "id": "I42:966;44:1253", + "type": "RECTANGLE", + "fields": { + "parentId": "42:966", + "absoluteBoundingBox": { + "height": 100, + "width": 80, + "x": 21849, + "y": 3033 + }, + "absoluteRenderBounds": { + "height": 100, + "width": 80, + "x": 21849, + "y": 3033 + }, + "bottomLeftRadius": 8, + "bottomRightRadius": 8, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 8, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.8509804010391235, + "g": 0.8509804010391235, + "r": 0.8509804010391235 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 100, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Rectangle 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 4, + "y": 5 + }, + "topLeftRadius": 8, + "topRightRadius": 8, + "visible": true, + "width": 80, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:966;44:1254": { + "id": "I42:966;44:1254", + "type": "FRAME", + "fields": { + "parentId": "42:966", + "childrenIds": [ + "I42:966;44:1255", + "I42:966;44:1256" + ], + "absoluteBoundingBox": { + "height": 73, + "width": 224, + "x": 21945, + "y": 3046.5 + }, + "absoluteRenderBounds": { + "height": 73, + "width": 224, + "x": 21945, + "y": 3046.5 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 73, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 4, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 4, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 486", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 12, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 12, + "relativeTransform": [ + [ + 1, + 0, + 96 + ], + [ + 0, + 1, + 13.5 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 224, + "x": 96, + "y": 13.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:966;44:1255": { + "id": "I42:966;44:1255", + "type": "TEXT", + "fields": { + "parentId": "I42:966;44:1254", + "absoluteBoundingBox": { + "height": 25, + "width": 224, + "x": 21945, + "y": 3058.5 + }, + "absoluteRenderBounds": { + "height": 16.218017578125, + "width": 44.4609375, + "x": 21945.431640625, + "y": 3062.847900390625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "정혜정", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 18, + "height": 25, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "임군일", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 12 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "DISABLED", + "visible": true, + "width": 224, + "x": 0, + "y": 12, + "styledTextSegments": [ + { + "characters": "정혜정", + "end": 3, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:966;44:1256": { + "id": "I42:966;44:1256", + "type": "TEXT", + "fields": { + "parentId": "I42:966;44:1254", + "absoluteBoundingBox": { + "height": 20, + "width": 224, + "x": 21945, + "y": 3087.5 + }, + "absoluteRenderBounds": { + "height": 11.699951171875, + "width": 79.412109375, + "x": 21946.0390625, + "y": 3091.865966796875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "비임상시험 팀장", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "대표이사 (CEO)", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 41 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d,", + "textTruncation": "DISABLED", + "visible": true, + "width": 224, + "x": 0, + "y": 41, + "styledTextSegments": [ + { + "characters": "비임상시험 팀장", + "end": 8, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "42:967": { + "id": "42:967", + "type": "INSTANCE", + "fields": { + "parentId": "42:964", + "childrenIds": [ + "I42:967;44:1253", + "I42:967;44:1254" + ], + "absoluteBoundingBox": { + "height": 100, + "width": 320, + "x": 21849, + "y": 3153 + }, + "absoluteRenderBounds": { + "height": 100, + "width": 320, + "x": 21849, + "y": 3153 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Team" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Mobile" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 100, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "ProfileCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "42:967", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I42:967;44:1255", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I42:967;44:1256", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 240 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "type": "Team", + "viewport": "Mobile" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 240, + "mainComponentId": "44:1252", + "mainComponentName": "type=Team, viewport=Mobile", + "mainComponentSetId": "41:750" + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:967;44:1253": { + "id": "I42:967;44:1253", + "type": "RECTANGLE", + "fields": { + "parentId": "42:967", + "absoluteBoundingBox": { + "height": 100, + "width": 80, + "x": 21849, + "y": 3153 + }, + "absoluteRenderBounds": { + "height": 100, + "width": 80, + "x": 21849, + "y": 3153 + }, + "bottomLeftRadius": 8, + "bottomRightRadius": 8, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 8, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.8509804010391235, + "g": 0.8509804010391235, + "r": 0.8509804010391235 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 100, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Rectangle 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 4, + "y": 5 + }, + "topLeftRadius": 8, + "topRightRadius": 8, + "visible": true, + "width": 80, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:967;44:1254": { + "id": "I42:967;44:1254", + "type": "FRAME", + "fields": { + "parentId": "42:967", + "childrenIds": [ + "I42:967;44:1255", + "I42:967;44:1256" + ], + "absoluteBoundingBox": { + "height": 73, + "width": 224, + "x": 21945, + "y": 3166.5 + }, + "absoluteRenderBounds": { + "height": 73, + "width": 224, + "x": 21945, + "y": 3166.5 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 73, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 4, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 4, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 486", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 12, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 12, + "relativeTransform": [ + [ + 1, + 0, + 96 + ], + [ + 0, + 1, + 13.5 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 224, + "x": 96, + "y": 13.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:967;44:1255": { + "id": "I42:967;44:1255", + "type": "TEXT", + "fields": { + "parentId": "I42:967;44:1254", + "absoluteBoundingBox": { + "height": 25, + "width": 224, + "x": 21945, + "y": 3178.5 + }, + "absoluteRenderBounds": { + "height": 16.2001953125, + "width": 45.5234375, + "x": 21945.4140625, + "y": 3182.847900390625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "전희수", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 18, + "height": 25, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "임군일", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 12 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "DISABLED", + "visible": true, + "width": 224, + "x": 0, + "y": 12, + "styledTextSegments": [ + { + "characters": "전희수", + "end": 3, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I42:967;44:1256": { + "id": "I42:967;44:1256", + "type": "TEXT", + "fields": { + "parentId": "I42:967;44:1254", + "absoluteBoundingBox": { + "height": 20, + "width": 224, + "x": 21945, + "y": 3207.5 + }, + "absoluteRenderBounds": { + "height": 11.5830078125, + "width": 68.802734375, + "x": 21945.546875, + "y": 3211.969970703125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "경영지원 팀장", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "대표이사 (CEO)", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 41 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d,", + "textTruncation": "DISABLED", + "visible": true, + "width": 224, + "x": 0, + "y": 41, + "styledTextSegments": [ + { + "characters": "경영지원 팀장", + "end": 7, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:44614ba166a8fa9b3003396a689f04927b273a5d," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "42:944": { + "id": "42:944", + "type": "FRAME", + "fields": { + "parentId": "43:1232", + "parentType": "SECTION", + "parentName": "기업소개", + "childrenIds": [ + "42:964" + ], + "absoluteBoundingBox": { + "height": 3573, + "width": 360, + "x": 21829, + "y": 1138 + }, + "absoluteRenderBounds": { + "height": 3573, + "width": 360, + "x": 21829, + "y": 1138 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 3573, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Mobile", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 1581 + ], + [ + 0, + 1, + 100 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 1581, + "y": 100 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_dropdown_anchor_nodes.json b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_dropdown_anchor_nodes.json new file mode 100644 index 00000000..9804171e --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_dropdown_anchor_nodes.json @@ -0,0 +1,4387 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "44:5652", + "44:6155" + ], + "nodes": { + "44:5652": { + "id": "44:5652", + "type": "FRAME", + "fields": { + "parentId": "44:6542", + "parentType": "SECTION", + "parentName": "문의하기", + "childrenIds": [ + "44:5653", + "44:5654", + "44:5655", + "44:5677" + ], + "absoluteBoundingBox": { + "height": 1426, + "width": 1440, + "x": 27756, + "y": 2778 + }, + "absoluteRenderBounds": { + "height": 1426, + "width": 1440, + "x": 27756, + "y": 2778 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1426, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Desktop", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 97, + "y": 1740 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:5655": { + "id": "44:5655", + "type": "FRAME", + "fields": { + "parentId": "44:5652", + "childrenIds": [ + "44:5656" + ], + "absoluteBoundingBox": { + "height": 929, + "width": 1440, + "x": 27756, + "y": 3094 + }, + "absoluteRenderBounds": { + "height": 929, + "width": 1440, + "x": 27756, + "y": 3094 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingBottom": { + "id": "VariableID:30:632", + "type": "VARIABLE_ALIAS" + }, + "paddingLeft": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + }, + "paddingRight": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 929, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 80, + "paddingLeft": 40, + "paddingRight": 40, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 480", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 80, + "paddingLeft": 40, + "paddingRight": 40, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 0, + "y": 316 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:5656": { + "id": "44:5656", + "type": "FRAME", + "fields": { + "parentId": "44:5655", + "childrenIds": [ + "44:5657", + "44:5660", + "44:5676" + ], + "absoluteBoundingBox": { + "height": 849, + "width": 540, + "x": 28206, + "y": 3094 + }, + "absoluteRenderBounds": { + "height": 849, + "width": 564, + "x": 28194, + "y": 3094 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 849, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 40, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 40, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 540, + "name": "Frame 482", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 540, + "x": 450, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:5660": { + "id": "44:5660", + "type": "FRAME", + "fields": { + "parentId": "44:5656", + "childrenIds": [ + "44:5661", + "44:5662", + "44:5663", + "44:5664", + "44:5665", + "44:5666", + "45:2039" + ], + "absoluteBoundingBox": { + "height": 650, + "width": 540, + "x": 28206, + "y": 3193 + }, + "absoluteRenderBounds": { + "height": 650, + "width": 564, + "x": 28194, + "y": 3193 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 650, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 32, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 32, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 540", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 540, + "x": 0, + "y": 99 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:5664": { + "id": "44:5664", + "type": "INSTANCE", + "fields": { + "parentId": "44:5660", + "childrenIds": [ + "I44:5664;44:5506", + "I44:5664;44:5509", + "I44:5664;44:5569", + "I44:5664;44:5510" + ], + "absoluteBoundingBox": { + "height": 81, + "width": 540, + "x": 28206, + "y": 3526 + }, + "absoluteRenderBounds": { + "height": 81, + "width": 540, + "x": 28206, + "y": 3526 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "errorMessage#44:18": { + "type": "BOOLEAN", + "value": false + }, + "optional#44:17": { + "type": "BOOLEAN", + "value": true + }, + "textarea#44:19": { + "type": "BOOLEAN", + "value": false + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 81, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "FormField", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "44:5664", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I44:5664;44:5507", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I44:5664;44:5509", + "overriddenFields": [ + "componentProperties" + ] + }, + { + "id": "I44:5664;44:5509;44:5487", + "overriddenFields": [ + "characters", + "opacity", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 540, + "x": 0, + "y": 333, + "mainComponentId": "44:5514", + "mainComponentName": "FormField", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "44:6155": { + "id": "44:6155", + "type": "FRAME", + "fields": { + "parentId": "44:6542", + "parentType": "SECTION", + "parentName": "문의하기", + "childrenIds": [ + "44:6156", + "44:6157", + "44:6158", + "44:6180" + ], + "absoluteBoundingBox": { + "height": 1286, + "width": 360, + "x": 29224, + "y": 2778 + }, + "absoluteRenderBounds": { + "height": 1286, + "width": 360, + "x": 29224, + "y": 2778 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1286, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Mobile", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 1565, + "y": 1740 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:6158": { + "id": "44:6158", + "type": "FRAME", + "fields": { + "parentId": "44:6155", + "childrenIds": [ + "44:6159" + ], + "absoluteBoundingBox": { + "height": 837, + "width": 360, + "x": 29224, + "y": 2980 + }, + "absoluteRenderBounds": { + "height": 837, + "width": 360, + "x": 29224, + "y": 2980 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingBottom": { + "id": "VariableID:30:632", + "type": "VARIABLE_ALIAS" + }, + "paddingLeft": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + }, + "paddingRight": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 837, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 40, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 480", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 40, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 0, + "y": 202 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:6159": { + "id": "44:6159", + "type": "FRAME", + "fields": { + "parentId": "44:6158", + "childrenIds": [ + "44:6160", + "44:6163", + "44:6179", + "45:2050" + ], + "absoluteBoundingBox": { + "height": 797, + "width": 320, + "x": 29244, + "y": 2980 + }, + "absoluteRenderBounds": { + "height": 797, + "width": 344, + "x": 29232, + "y": 2980 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 797, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 32, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 32, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 540, + "name": "Frame 482", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 20, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:6163": { + "id": "44:6163", + "type": "FRAME", + "fields": { + "parentId": "44:6159", + "childrenIds": [ + "44:6164", + "44:6165", + "44:6166", + "44:6167", + "44:6168", + "44:6169" + ], + "absoluteBoundingBox": { + "height": 599, + "width": 320, + "x": 29244, + "y": 3087 + }, + "absoluteRenderBounds": { + "height": 599, + "width": 320, + "x": 29244, + "y": 3087 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 599, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 540", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 107 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:6167": { + "id": "44:6167", + "type": "INSTANCE", + "fields": { + "parentId": "44:6163", + "childrenIds": [ + "I44:6167;44:5506", + "I44:6167;44:5509", + "I44:6167;44:5569", + "I44:6167;44:5510" + ], + "absoluteBoundingBox": { + "height": 79, + "width": 320, + "x": 29244, + "y": 3390 + }, + "absoluteRenderBounds": { + "height": 79, + "width": 320, + "x": 29244, + "y": 3390 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "errorMessage#44:18": { + "type": "BOOLEAN", + "value": false + }, + "optional#44:17": { + "type": "BOOLEAN", + "value": true + }, + "textarea#44:19": { + "type": "BOOLEAN", + "value": false + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 79, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "FormField", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "44:6167", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I44:6167;44:5507", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I44:6167;44:5509", + "overriddenFields": [ + "componentProperties" + ] + }, + { + "id": "I44:6167;44:5509;44:5487", + "overriddenFields": [ + "characters", + "opacity", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 303, + "mainComponentId": "44:5514", + "mainComponentName": "FormField", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2035": { + "id": "45:2035", + "type": "INSTANCE", + "fields": { + "parentId": "45:2039", + "childrenIds": [ + "I45:2035;45:2025" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 538, + "x": 28207, + "y": 3608 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 538, + "x": 28207, + "y": 3608 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DropdownOption", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "45:2035", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I45:2035;45:2025", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "default" + }, + "visible": true, + "width": 538, + "x": 1, + "y": 1, + "mainComponentId": "45:2033", + "mainComponentName": "속성 1=default", + "mainComponentSetId": "45:2034" + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2037": { + "id": "45:2037", + "type": "INSTANCE", + "fields": { + "parentId": "45:2039", + "childrenIds": [ + "I45:2037;45:2025" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 538, + "x": 28207, + "y": 3656 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 538, + "x": 28207, + "y": 3656 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DropdownOption", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "45:2037", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I45:2037;45:2025", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "default" + }, + "visible": true, + "width": 538, + "x": 1, + "y": 49, + "mainComponentId": "45:2033", + "mainComponentName": "속성 1=default", + "mainComponentSetId": "45:2034" + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2039": { + "id": "45:2039", + "type": "FRAME", + "fields": { + "parentId": "44:5660", + "childrenIds": [ + "45:2035", + "45:2037", + "45:2040", + "45:2042" + ], + "absoluteBoundingBox": { + "height": 194, + "width": 540, + "x": 28206, + "y": 3607 + }, + "absoluteRenderBounds": { + "height": 218, + "width": 564, + "x": 28194, + "y": 3603 + }, + "bottomLeftRadius": 8, + "bottomRightRadius": 8, + "boundVariables": { + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 8, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "effectStyleId": "S:85e36cc63109f05275d2a8ec88f2e5d33aad71d5,", + "effects": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "a": 0.07999999821186066, + "b": 0, + "g": 0, + "r": 0 + }, + "offset": { + "x": 0, + "y": 8 + }, + "radius": 12, + "showShadowBehindNode": false, + "spread": 0, + "type": "DROP_SHADOW", + "visible": true + } + ], + "height": 194, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "ABSOLUTE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 547", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "topLeftRadius": 8, + "topRightRadius": 8, + "visible": true, + "width": 540, + "x": 0, + "y": 414 + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2040": { + "id": "45:2040", + "type": "INSTANCE", + "fields": { + "parentId": "45:2039", + "childrenIds": [ + "I45:2040;45:2025" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 538, + "x": 28207, + "y": 3704 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 538, + "x": 28207, + "y": 3704 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DropdownOption", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "45:2040", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I45:2040;45:2025", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "default" + }, + "visible": true, + "width": 538, + "x": 1, + "y": 97, + "mainComponentId": "45:2033", + "mainComponentName": "속성 1=default", + "mainComponentSetId": "45:2034" + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2042": { + "id": "45:2042", + "type": "INSTANCE", + "fields": { + "parentId": "45:2039", + "childrenIds": [ + "I45:2042;45:2025" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 538, + "x": 28207, + "y": 3752 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 538, + "x": 28207, + "y": 3752 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DropdownOption", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "45:2042", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I45:2042;45:2025", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "default" + }, + "visible": true, + "width": 538, + "x": 1, + "y": 145, + "mainComponentId": "45:2033", + "mainComponentName": "속성 1=default", + "mainComponentSetId": "45:2034" + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2050": { + "id": "45:2050", + "type": "FRAME", + "fields": { + "parentId": "44:6159", + "childrenIds": [ + "45:2051", + "45:2052", + "45:2053", + "45:2054" + ], + "absoluteBoundingBox": { + "height": 190, + "width": 320, + "x": 29244, + "y": 3469 + }, + "absoluteRenderBounds": { + "height": 214, + "width": 344, + "x": 29232, + "y": 3465 + }, + "bottomLeftRadius": 8, + "bottomRightRadius": 8, + "boundVariables": { + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 8, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "effectStyleId": "S:85e36cc63109f05275d2a8ec88f2e5d33aad71d5,", + "effects": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "a": 0.07999999821186066, + "b": 0, + "g": 0, + "r": 0 + }, + "offset": { + "x": 0, + "y": 8 + }, + "radius": 12, + "showShadowBehindNode": false, + "spread": 0, + "type": "DROP_SHADOW", + "visible": true + } + ], + "height": 190, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "ABSOLUTE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 547", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "topLeftRadius": 8, + "topRightRadius": 8, + "visible": true, + "width": 320, + "x": 0, + "y": 489 + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2051": { + "id": "45:2051", + "type": "INSTANCE", + "fields": { + "parentId": "45:2050", + "childrenIds": [ + "I45:2051;45:2025" + ], + "absoluteBoundingBox": { + "height": 47, + "width": 318, + "x": 29245, + "y": 3470 + }, + "absoluteRenderBounds": { + "height": 47, + "width": 318, + "x": 29245, + "y": 3470 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 47, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DropdownOption", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "45:2051", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I45:2051;45:2025", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "default" + }, + "visible": true, + "width": 318, + "x": 1, + "y": 1, + "mainComponentId": "45:2033", + "mainComponentName": "속성 1=default", + "mainComponentSetId": "45:2034" + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2052": { + "id": "45:2052", + "type": "INSTANCE", + "fields": { + "parentId": "45:2050", + "childrenIds": [ + "I45:2052;45:2025" + ], + "absoluteBoundingBox": { + "height": 47, + "width": 318, + "x": 29245, + "y": 3517 + }, + "absoluteRenderBounds": { + "height": 47, + "width": 318, + "x": 29245, + "y": 3517 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 47, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DropdownOption", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "45:2052", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I45:2052;45:2025", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "default" + }, + "visible": true, + "width": 318, + "x": 1, + "y": 48, + "mainComponentId": "45:2033", + "mainComponentName": "속성 1=default", + "mainComponentSetId": "45:2034" + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2053": { + "id": "45:2053", + "type": "INSTANCE", + "fields": { + "parentId": "45:2050", + "childrenIds": [ + "I45:2053;45:2025" + ], + "absoluteBoundingBox": { + "height": 47, + "width": 318, + "x": 29245, + "y": 3564 + }, + "absoluteRenderBounds": { + "height": 47, + "width": 318, + "x": 29245, + "y": 3564 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 47, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DropdownOption", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "45:2053", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I45:2053;45:2025", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "default" + }, + "visible": true, + "width": 318, + "x": 1, + "y": 95, + "mainComponentId": "45:2033", + "mainComponentName": "속성 1=default", + "mainComponentSetId": "45:2034" + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2054": { + "id": "45:2054", + "type": "INSTANCE", + "fields": { + "parentId": "45:2050", + "childrenIds": [ + "I45:2054;45:2025" + ], + "absoluteBoundingBox": { + "height": 47, + "width": 318, + "x": 29245, + "y": 3611 + }, + "absoluteRenderBounds": { + "height": 47, + "width": 318, + "x": 29245, + "y": 3611 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 47, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DropdownOption", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "45:2054", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I45:2054;45:2025", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "default" + }, + "visible": true, + "width": 318, + "x": 1, + "y": 142, + "mainComponentId": "45:2033", + "mainComponentName": "속성 1=default", + "mainComponentSetId": "45:2034" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:5664;44:5509": { + "id": "I44:5664;44:5509", + "type": "INSTANCE", + "fields": { + "parentId": "44:5664", + "childrenIds": [ + "I44:5664;44:5509;81:12721", + "I44:5664;44:5509;44:5487", + "I44:5664;44:5509;44:5488" + ], + "absoluteBoundingBox": { + "height": 52, + "width": 540, + "x": 28206, + "y": 3555 + }, + "absoluteRenderBounds": { + "height": 52, + "width": 540, + "x": 28206, + "y": 3555 + }, + "bottomLeftRadius": 8, + "bottomRightRadius": 8, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + ], + "strokes": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "LeftIcon#81:17": { + "type": "BOOLEAN", + "value": false + }, + "Righticon#44:12": { + "type": "BOOLEAN", + "value": true + }, + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "focus" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 8, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 52, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "input", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I44:5664;44:5509", + "overriddenFields": [ + "componentProperties" + ] + }, + { + "id": "I44:5664;44:5509;44:5487", + "overriddenFields": [ + "characters", + "opacity", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "strokeBottomWeight": 2, + "strokeLeftWeight": 2, + "strokeRightWeight": 2, + "strokeTopWeight": 2, + "strokeWeight": 2, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "topLeftRadius": 8, + "topRightRadius": 8, + "variantProperties": { + "effect": "focus" + }, + "visible": true, + "width": 540, + "x": 0, + "y": 29, + "mainComponentId": "44:5492", + "mainComponentName": "effect=focus", + "mainComponentSetId": "44:5494" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:5664;44:5509;44:5487": { + "id": "I44:5664;44:5509;44:5487", + "type": "TEXT", + "fields": { + "parentId": "I44:5664;44:5509", + "absoluteBoundingBox": { + "height": 24, + "width": 478, + "x": 28224, + "y": 3569 + }, + "absoluteRenderBounds": { + "height": 14.14404296875, + "width": 156.083984375, + "x": 28224.623046875, + "y": 3574.0400390625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "문의 유형을 선택해주세요", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "placeholder", + "opacity": 0.4000000059604645, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 478, + "x": 18, + "y": 14, + "styledTextSegments": [ + { + "characters": "문의 유형을 선택해주세요", + "end": 13, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:5664;44:5509;44:5488": { + "id": "I44:5664;44:5509;44:5488", + "type": "INSTANCE", + "fields": { + "parentId": "I44:5664;44:5509", + "childrenIds": [ + "I44:5664;44:5509;44:5488;40:447" + ], + "absoluteBoundingBox": { + "height": 16, + "width": 16, + "x": 28712, + "y": 3573 + }, + "absoluteRenderBounds": { + "height": 16, + "width": 16, + "x": 28712, + "y": 3573 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "CaretDown" + } + }, + "componentPropertyReferences": { + "visible": "Righticon#44:12" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 16, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 4.499607086181641, + "paddingLeft": 2.4997220039367676, + "paddingRight": 2.499721050262451, + "paddingTop": 5.499722003936768, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Caret", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 24, + "y": 24 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "CaretDown" + }, + "visible": true, + "width": 16, + "x": 506, + "y": 18, + "mainComponentId": "40:457", + "mainComponentName": "속성 1=CaretDown", + "mainComponentSetId": "40:459" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:5664;44:5509;44:5488;40:447": { + "id": "I44:5664;44:5509;44:5488;40:447", + "type": "VECTOR", + "fields": { + "parentId": "I44:5664;44:5509;44:5488", + "absoluteBoundingBox": { + "height": 6.000670909881592, + "width": 11.00055694580078, + "x": 28714.5, + "y": 3578.499755859375 + }, + "absoluteRenderBounds": { + "height": 6.000732421875, + "width": 11, + "x": 28714.5, + "y": 3578.499755859375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 6.000670909881592, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 0.09375, + "visible": true, + "width": 11.00055694580078, + "x": 2.4997220039367676, + "y": 5.499722003936768 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:5664;44:5509;81:12721": { + "id": "I44:5664;44:5509;81:12721", + "type": "INSTANCE", + "fields": { + "parentId": "I44:5664;44:5509", + "childrenIds": [ + "I44:5664;44:5509;81:12721;40:437" + ], + "absoluteBoundingBox": { + "height": 24, + "width": 24, + "x": 28224, + "y": 3569 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "componentPropertyReferences": { + "visible": "LeftIcon#81:17" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 24, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 2.2495806217193604, + "paddingLeft": 2.229278326034546, + "paddingRight": 2.2495806217193604, + "paddingTop": 2.229278326034546, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "MagnifyingGlass", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 24, + "y": 24 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": false, + "width": 24, + "x": 18, + "y": 14, + "mainComponentId": "40:438", + "mainComponentName": "MagnifyingGlass", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:5664;44:5509;81:12721;40:437": { + "id": "I44:5664;44:5509;81:12721;40:437", + "type": "VECTOR", + "fields": { + "parentId": "I44:5664;44:5509;81:12721", + "absoluteBoundingBox": { + "height": 19.521141052246097, + "width": 19.521141052246097, + "x": 28226.228515625, + "y": 3571.229248046875 + }, + "absoluteRenderBounds": null, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 19.521141052246097, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 0.09375, + "visible": true, + "width": 19.521141052246097, + "x": 2.229278326034546, + "y": 2.229278326034546 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:6167;44:5509": { + "id": "I44:6167;44:5509", + "type": "INSTANCE", + "fields": { + "parentId": "44:6167", + "childrenIds": [ + "I44:6167;44:5509;81:12721", + "I44:6167;44:5509;44:5487", + "I44:6167;44:5509;44:5488" + ], + "absoluteBoundingBox": { + "height": 51, + "width": 320, + "x": 29244, + "y": 3418 + }, + "absoluteRenderBounds": { + "height": 51, + "width": 320, + "x": 29244, + "y": 3418 + }, + "bottomLeftRadius": 8, + "bottomRightRadius": 8, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + ], + "strokes": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "LeftIcon#81:17": { + "type": "BOOLEAN", + "value": false + }, + "Righticon#44:12": { + "type": "BOOLEAN", + "value": true + }, + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "focus" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 8, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:161", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 51, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "input", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I44:6167;44:5509", + "overriddenFields": [ + "componentProperties" + ] + }, + { + "id": "I44:6167;44:5509;44:5487", + "overriddenFields": [ + "characters", + "opacity", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + "strokeBottomWeight": 2, + "strokeLeftWeight": 2, + "strokeRightWeight": 2, + "strokeTopWeight": 2, + "strokeWeight": 2, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "topLeftRadius": 8, + "topRightRadius": 8, + "variantProperties": { + "effect": "focus" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 28, + "mainComponentId": "44:5492", + "mainComponentName": "effect=focus", + "mainComponentSetId": "44:5494" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:6167;44:5509;44:5487": { + "id": "I44:6167;44:5509;44:5487", + "type": "TEXT", + "fields": { + "parentId": "I44:6167;44:5509", + "absoluteBoundingBox": { + "height": 23, + "width": 258, + "x": 29262, + "y": 3432 + }, + "absoluteRenderBounds": { + "height": 13.260009765625, + "width": 146.326171875, + "x": 29262.5859375, + "y": 3436.85009765625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "문의 유형을 선택해주세요", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "placeholder", + "opacity": 0.4000000059604645, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 258, + "x": 18, + "y": 14, + "styledTextSegments": [ + { + "characters": "문의 유형을 선택해주세요", + "end": 13, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:6167;44:5509;44:5488": { + "id": "I44:6167;44:5509;44:5488", + "type": "INSTANCE", + "fields": { + "parentId": "I44:6167;44:5509", + "childrenIds": [ + "I44:6167;44:5509;44:5488;40:447" + ], + "absoluteBoundingBox": { + "height": 16, + "width": 16, + "x": 29530, + "y": 3435.5 + }, + "absoluteRenderBounds": { + "height": 16, + "width": 16, + "x": 29530, + "y": 3435.5 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "CaretDown" + } + }, + "componentPropertyReferences": { + "visible": "Righticon#44:12" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 16, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 4.499607086181641, + "paddingLeft": 2.4997220039367676, + "paddingRight": 2.499721050262451, + "paddingTop": 5.499722003936768, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Caret", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 24, + "y": 24 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "CaretDown" + }, + "visible": true, + "width": 16, + "x": 286, + "y": 17.5, + "mainComponentId": "40:457", + "mainComponentName": "속성 1=CaretDown", + "mainComponentSetId": "40:459" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:6167;44:5509;44:5488;40:447": { + "id": "I44:6167;44:5509;44:5488;40:447", + "type": "VECTOR", + "fields": { + "parentId": "I44:6167;44:5509;44:5488", + "absoluteBoundingBox": { + "height": 6.000670909881592, + "width": 11.00055694580078, + "x": 29532.5, + "y": 3440.999755859375 + }, + "absoluteRenderBounds": { + "height": 6.000732421875, + "width": 11, + "x": 29532.5, + "y": 3440.999755859375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 6.000670909881592, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 0.09375, + "visible": true, + "width": 11.00055694580078, + "x": 2.4997220039367676, + "y": 5.499722003936768 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:6167;44:5509;81:12721": { + "id": "I44:6167;44:5509;81:12721", + "type": "INSTANCE", + "fields": { + "parentId": "I44:6167;44:5509", + "childrenIds": [ + "I44:6167;44:5509;81:12721;40:437" + ], + "absoluteBoundingBox": { + "height": 24, + "width": 24, + "x": 29262, + "y": 3432 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "componentPropertyReferences": { + "visible": "LeftIcon#81:17" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 24, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 2.2495806217193604, + "paddingLeft": 2.229278326034546, + "paddingRight": 2.2495806217193604, + "paddingTop": 2.229278326034546, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "MagnifyingGlass", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 24, + "y": 24 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": false, + "width": 24, + "x": 18, + "y": 14, + "mainComponentId": "40:438", + "mainComponentName": "MagnifyingGlass", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:6167;44:5509;81:12721;40:437": { + "id": "I44:6167;44:5509;81:12721;40:437", + "type": "VECTOR", + "fields": { + "parentId": "I44:6167;44:5509;81:12721", + "absoluteBoundingBox": { + "height": 19.521141052246097, + "width": 19.521141052246097, + "x": 29264.228515625, + "y": 3434.229248046875 + }, + "absoluteRenderBounds": null, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 19.521141052246097, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 0.09375, + "visible": true, + "width": 19.521141052246097, + "x": 2.229278326034546, + "y": 2.229278326034546 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2035;45:2025": { + "id": "I45:2035;45:2025", + "type": "TEXT", + "fields": { + "parentId": "45:2035", + "absoluteBoundingBox": { + "height": 24, + "width": 57, + "x": 28223, + "y": 3620 + }, + "absoluteRenderBounds": { + "height": 14.01611328125, + "width": 54.005859375, + "x": 28223.623046875, + "y": 3625.16796875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "투자 · IR", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "옵션 1", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 57, + "x": 16, + "y": 12, + "styledTextSegments": [ + { + "characters": "투자 · IR", + "end": 7, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2037;45:2025": { + "id": "I45:2037;45:2025", + "type": "TEXT", + "fields": { + "parentId": "45:2037", + "absoluteBoundingBox": { + "height": 24, + "width": 124, + "x": 28223, + "y": 3668 + }, + "absoluteRenderBounds": { + "height": 14.176025390625, + "width": 121.287109375, + "x": 28223.65625, + "y": 3673.02392578125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "공동연구 · 기술협력", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "옵션 1", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 124, + "x": 16, + "y": 12, + "styledTextSegments": [ + { + "characters": "공동연구 · 기술협력", + "end": 11, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2040;45:2025": { + "id": "I45:2040;45:2025", + "type": "TEXT", + "fields": { + "parentId": "45:2040", + "absoluteBoundingBox": { + "height": 24, + "width": 189, + "x": 28223, + "y": 3716 + }, + "absoluteRenderBounds": { + "height": 14.031982421875, + "width": 185.498046875, + "x": 28223.65625, + "y": 3721.152099609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "골관절염 인체모델 시스템 도입", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "옵션 1", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 189, + "x": 16, + "y": 12, + "styledTextSegments": [ + { + "characters": "골관절염 인체모델 시스템 도입", + "end": 16, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2042;45:2025": { + "id": "I45:2042;45:2025", + "type": "TEXT", + "fields": { + "parentId": "45:2042", + "absoluteBoundingBox": { + "height": 24, + "width": 28, + "x": 28223, + "y": 3764 + }, + "absoluteRenderBounds": { + "height": 14, + "width": 26.560546875, + "x": 28223.81640625, + "y": 3769.16796875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "기타", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "옵션 1", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 28, + "x": 16, + "y": 12, + "styledTextSegments": [ + { + "characters": "기타", + "end": 2, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2051;45:2025": { + "id": "I45:2051;45:2025", + "type": "TEXT", + "fields": { + "parentId": "45:2051", + "absoluteBoundingBox": { + "height": 23, + "width": 53, + "x": 29261, + "y": 3482 + }, + "absoluteRenderBounds": { + "height": 13.14013671875, + "width": 50.62890625, + "x": 29261.5859375, + "y": 3486.969970703125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "투자 · IR", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "옵션 1", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 53, + "x": 16, + "y": 12, + "styledTextSegments": [ + { + "characters": "투자 · IR", + "end": 7, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2052;45:2025": { + "id": "I45:2052;45:2025", + "type": "TEXT", + "fields": { + "parentId": "45:2052", + "absoluteBoundingBox": { + "height": 23, + "width": 117, + "x": 29261, + "y": 3529 + }, + "absoluteRenderBounds": { + "height": 13.2900390625, + "width": 113.70703125, + "x": 29261.615234375, + "y": 3533.8349609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "공동연구 · 기술협력", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "옵션 1", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 117, + "x": 16, + "y": 12, + "styledTextSegments": [ + { + "characters": "공동연구 · 기술협력", + "end": 11, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2053;45:2025": { + "id": "I45:2053;45:2025", + "type": "TEXT", + "fields": { + "parentId": "45:2053", + "absoluteBoundingBox": { + "height": 23, + "width": 177, + "x": 29261, + "y": 3576 + }, + "absoluteRenderBounds": { + "height": 13.155029296875, + "width": 173.904296875, + "x": 29261.615234375, + "y": 3580.955078125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "골관절염 인체모델 시스템 도입", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "옵션 1", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 177, + "x": 16, + "y": 12, + "styledTextSegments": [ + { + "characters": "골관절염 인체모델 시스템 도입", + "end": 16, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2054;45:2025": { + "id": "I45:2054;45:2025", + "type": "TEXT", + "fields": { + "parentId": "45:2054", + "absoluteBoundingBox": { + "height": 23, + "width": 26, + "x": 29261, + "y": 3623 + }, + "absoluteRenderBounds": { + "height": 13.125, + "width": 24.900390625, + "x": 29261.765625, + "y": 3627.969970703125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "기타", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "옵션 1", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 26, + "x": 16, + "y": 12, + "styledTextSegments": [ + { + "characters": "기타", + "end": 2, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_footer_ascii_spacing.json b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_footer_ascii_spacing.json new file mode 100644 index 00000000..29e2aa48 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_footer_ascii_spacing.json @@ -0,0 +1,246 @@ +{ + "fileKey": "captured-footer-spacing", + "roots": [ + "41:908", + "43:1195" + ], + "nodes": { + "41:908": { + "id": "41:908", + "type": "TEXT", + "fields": { + "parentId": "41:926", + "absoluteBoundingBox": { + "height": 21, + "width": 1033, + "x": 9209, + "y": 3822 + }, + "absoluteRenderBounds": { + "height": 14.3359375, + "width": 701.615234375, + "x": 9209.5595703125, + "y": 3826.64599609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "주식회사 콘드로젠 · 대표자 임군일 · 경기 고양시 일산동구 동국로 32 동국대학교 산학협력관 733호 · contact@chondrogen.kr", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "주식회사 콘드로젠 · 대표자 임군일 · 경기 고양시 일산동구 동국로 32 동국대학교 산학협력관 733호 · contact@chondrogen.kr", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 1033, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "주식회사 콘드로젠 · 대표자 임군일 · 경기 고양시 일산동구 동국로 32 동국대학교 산학협력관 733호 · contact@chondrogen.kr", + "end": 87, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "43:1195": { + "id": "43:1195", + "type": "TEXT", + "fields": { + "parentId": "43:1194", + "absoluteBoundingBox": { + "height": 60, + "width": 320, + "x": 8822, + "y": 4103 + }, + "absoluteRenderBounds": { + "height": 53.2080078125, + "width": 310.5810546875, + "x": 8822.4814453125, + "y": 4107.56103515625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "주식회사 콘드로젠 · 대표자 임군일 · 경기 고양시 일산동구 동국로 32 동국대학교 산학협력관 733호 · contact@chondrogen.kr", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 13, + "height": 60, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "주식회사 콘드로젠 · 대표자 임군일 · 경기 고양시 일산동구 동국로 32 동국대학교 산학협력관 733호 · contact@chondrogen.kr", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 320, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "주식회사 콘드로젠 · 대표자 임군일 · 경기 고양시 일산동구 동국로 32 동국대학교 산학협력관 733호 · contact@chondrogen.kr", + "end": 87, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_mobile_header_single_logo.json b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_mobile_header_single_logo.json new file mode 100644 index 00000000..3b44437b --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_mobile_header_single_logo.json @@ -0,0 +1,1912 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "I51:6610;44:5194" + ], + "nodes": { + "I51:6610;44:5194": { + "id": "I51:6610;44:5194", + "type": "FRAME", + "fields": { + "parentId": "51:6610", + "childrenIds": [ + "I51:6610;44:5195" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 261, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 261, + "x": 12623, + "y": 1167 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignItems": "CENTER", + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "SPACE_BETWEEN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 450", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "SPACE_BETWEEN", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 261, + "x": 12, + "y": 18 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195": { + "id": "I51:6610;44:5195", + "type": "INSTANCE", + "fields": { + "parentId": "I51:6610;44:5194", + "childrenIds": [ + "I51:6610;44:5195;136:9027", + "I51:6610;44:5195;136:9028", + "I51:6610;44:5195;136:9041" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 173, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 173, + "x": 12623, + "y": 1167 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "기본" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "height": 28, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Logo", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 1102, + "y": 178 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "기본" + }, + "visible": true, + "width": 173, + "x": 0, + "y": 0, + "mainComponentId": "37:217", + "mainComponentName": "속성 1=기본", + "mainComponentSetId": "136:19763" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9027": { + "id": "I51:6610;44:5195;136:9027", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195", + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.943359375, + "x": 12623, + "y": 1167 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.7490196228027344, + "g": 0.5607843399047852, + "r": 0.03921568766236305 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.6823529601097107, + "g": 0.5058823823928833, + "r": 0.007843137718737125 + }, + "position": 0.30000001192092896 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.5607843399047852, + "g": 0.3764705955982208, + "r": 0.003921568859368563 + }, + "position": 0.44999998807907104 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.46666666865348816, + "g": 0.2823529541492462, + "r": 0 + }, + "position": 0.6100000143051147 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.40784314274787903, + "g": 0.22745098173618317, + "r": 0 + }, + "position": 0.7599999904632568 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.3843137323856354, + "g": 0.2078431397676468, + "r": 0.003921568859368563 + }, + "position": 0.9100000262260436 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.3843137323856354, + "g": 0.2117647081613541, + "r": 0.003921568859368563 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.6454866528511047, + 0, + 0.31524205207824707 + ], + [ + 0, + 0.6454866528511047, + 0.204462930560112 + ] + ], + "opacity": 1, + "type": "GRADIENT_RADIAL", + "visible": true + } + ], + "height": 28, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "sphere", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9028": { + "id": "I51:6610;44:5195;136:9028", + "type": "GROUP", + "fields": { + "parentId": "I51:6610;44:5195", + "childrenIds": [ + "I51:6610;44:5195;136:9029", + "I51:6610;44:5195;136:9031" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "height": 28, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Clip path group", + "opacity": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9029": { + "id": "I51:6610;44:5195;136:9029", + "type": "GROUP", + "fields": { + "parentId": "I51:6610;44:5195;136:9028", + "childrenIds": [ + "I51:6610;44:5195;136:9030" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": null, + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isMask": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "sphere-clip", + "opacity": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9030": { + "id": "I51:6610;44:5195;136:9030", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9029", + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.943359375, + "x": 12623, + "y": 1167 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0, + "g": 0, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9031": { + "id": "I51:6610;44:5195;136:9031", + "type": "GROUP", + "fields": { + "parentId": "I51:6610;44:5195;136:9028", + "childrenIds": [ + "I51:6610;44:5195;136:9032", + "I51:6610;44:5195;136:9033", + "I51:6610;44:5195;136:9034", + "I51:6610;44:5195;136:9035", + "I51:6610;44:5195;136:9036", + "I51:6610;44:5195;136:9037", + "I51:6610;44:5195;136:9038", + "I51:6610;44:5195;136:9039", + "I51:6610;44:5195;136:9040" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.943359375, + "x": 12623, + "y": 1167 + }, + "height": 28, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9032": { + "id": "I51:6610;44:5195;136:9032", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.943359375, + "x": 12623, + "y": 1167 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 0.41999998688697815, + "b": 0.3333333432674408, + "g": 0.16470588743686676, + "r": 0 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 0.20000000298023224, + "b": 0.3333333432674408, + "g": 0.16470588743686676, + "r": 0 + }, + "position": 0.550000011920929 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.3333333432674408, + "g": 0.16470588743686676, + "r": 0 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 1.252997875213623, + 0, + 0.08783221244812012 + ], + [ + 0, + 1.252997875213623, + -0.7154324054718018 + ] + ], + "opacity": 1, + "type": "GRADIENT_RADIAL", + "visible": true + } + ], + "height": 28, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "shade-outside-orbit", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9033": { + "id": "I51:6610;44:5195;136:9033", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.943359375, + "x": 12623, + "y": 1167 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 0.14000000059604645, + "b": 0.8470588326454163, + "g": 0.6509804129600525, + "r": 0.10588235408067705 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.8470588326454163, + "g": 0.6509804129600525, + "r": 0.10588235408067705 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 1.9364598989486697, + 0, + 0.468456894159317 + ], + [ + 0, + 1.9364598989486697, + -0.5502269268035889 + ] + ], + "opacity": 1, + "type": "GRADIENT_RADIAL", + "visible": true + } + ], + "height": 28, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "light-left-overlay", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9034": { + "id": "I51:6610;44:5195;136:9034", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 5.863326072692871, + "width": 11.39635181427002, + "x": 12638.0908203125, + "y": 1186.9595947265625 + }, + "absoluteRenderBounds": { + "height": 5.86328125, + "width": 11.396484375, + "x": 12638.0908203125, + "y": 1186.9595947265625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 0.550000011920929, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 0.30000001192092896 + }, + { + "boundVariables": {}, + "color": { + "a": 0.5, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 0.75 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.8151227831840515, + -0.2092042565345764, + 0.19614684581756592 + ], + [ + 0.4074420928955078, + 0.41853103041648865, + 0.08879981189966202 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 5.863326072692871, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "rim-highlight (Stroke)", + "opacity": 1, + "strokeAlign": "CENTER", + "strokeWeight": 3.849999904632568, + "visible": true, + "width": 11.39635181427002, + "x": 15.09042739868164, + "y": 19.95957946777344 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9035": { + "id": "I51:6610;44:5195;136:9035", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 15.99054718017578, + "width": 15.946592330932615, + "x": 12626.1982421875, + "y": 1172.7916259765625 + }, + "absoluteRenderBounds": { + "height": 15.9906005859375, + "width": 15.9462890625, + "x": 12626.1982421875, + "y": 1172.7916259765625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.8156862854957581, + "g": 0.7843137383460999, + "r": 0.03921568766236305 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.5882353186607361, + "g": 0.4313725531101227, + "r": 0 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.8613659739494324, + 0.45280781388282776, + -0.1629999577999115 + ], + [ + -0.4524722993373871, + 0.8620046973228455, + 0.31514695286750793 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 15.99054718017578, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "core", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 15.946592330932615, + "x": 3.1983132362365723, + "y": 5.791618347167969 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9036": { + "id": "I51:6610;44:5195;136:9036", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 9.51343822479248, + "width": 9.494321823120115, + "x": 12628.9990234375, + "y": 1176.053466796875 + }, + "absoluteRenderBounds": { + "height": 9.513427734375, + "width": 9.494140625, + "x": 12628.9990234375, + "y": 1176.053466796875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.8745098114013672, + "g": 0.9333333373069764, + "r": 0.6705882549285889 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.7607843279838562, + "g": 0.7215686440467834, + "r": 0.0235294122248888 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.8080573678016663, + 0.5874120593070984, + -0.19792257249355316 + ], + [ + -0.5874120593070984, + 0.8080573678016663, + 0.3895654380321502 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 9.51343822479248, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "ring", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 9.494321823120115, + "x": 5.998625755310059, + "y": 9.053515434265137 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9037": { + "id": "I51:6610;44:5195;136:9037", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 5.172343730926514, + "width": 5.161951065063477, + "x": 12631.072265625, + "y": 1178.131591796875 + }, + "absoluteRenderBounds": { + "height": 5.17236328125, + "width": 5.162109375, + "x": 12631.072265625, + "y": 1178.131591796875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.6196078658103943, + "g": 0.4745098054409027, + "r": 0.007843137718737125 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.545098066329956, + "g": 0.3764705955982208, + "r": 0.003921568859368563 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.9339293241500854, + 0.3536636531352997, + -0.14337609708309174 + ], + [ + -0.3536636531352997, + 0.9339293241500854, + 0.2099337726831436 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 5.172343730926514, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "ring-center", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 5.161951065063477, + "x": 8.072575569152832, + "y": 11.131641387939451 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9038": { + "id": "I51:6610;44:5195;136:9038", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 22.848167419433594, + "width": 23.82176399230957, + "x": 12623.0361328125, + "y": 1169.5443115234375 + }, + "absoluteRenderBounds": { + "height": 22.84814453125, + "width": 23.8212890625, + "x": 12623.0361328125, + "y": 1169.5443115234375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9843137264251708, + "g": 0.9764705896377563, + "r": 0.9607843160629272 + }, + "position": 0.1899999976158142 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9803921580314636, + "g": 0.91372549533844, + "r": 0.7764706015586853 + }, + "position": 0.3799999952316284 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9725490212440492, + "g": 0.8588235378265381, + "r": 0.6039215922355652 + }, + "position": 0.5 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.95686274766922, + "g": 0.7843137383460999, + "r": 0.3843137323856354 + }, + "position": 0.6600000262260437 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9333333373069764, + "g": 0.729411780834198, + "r": 0.21960784494876864 + }, + "position": 0.8399999737739563 + } + ], + "gradientTransform": [ + [ + -1.6364001035690308, + 1.5055831670761108, + -0.08484271168708801 + ], + [ + -1.5728986263275146, + -1.5663667917251587, + 2.0555288791656494 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 22.848167419433594, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "orbit-outer (Stroke)", + "opacity": 1, + "strokeAlign": "CENTER", + "strokeWeight": 5.050000190734863, + "visible": true, + "width": 23.82176399230957, + "x": 0.036175258457660675, + "y": 2.5443549156188965 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9039": { + "id": "I51:6610;44:5195;136:9039", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 13.113574981689451, + "width": 23.719501495361328, + "x": 12625.98828125, + "y": 1175.99658203125 + }, + "absoluteRenderBounds": { + "height": 13.113525390625, + "width": 23.7197265625, + "x": 12625.98828125, + "y": 1175.99658203125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9647058844566344 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 13.113574981689451, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "orbit-inner", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 23.719501495361328, + "x": 2.9880521297454834, + "y": 8.9966402053833 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9040": { + "id": "I51:6610;44:5195;136:9040", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 6.9661030769348145, + "width": 15.414355278015137, + "x": 12626.1689453125, + "y": 1172.810546875 + }, + "absoluteRenderBounds": { + "height": 6.966064453125, + "width": 15.4140625, + "x": 12626.1689453125, + "y": 1172.810546875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9647058844566344 + }, + "position": 0.029999999329447743 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9176470637321472, + "g": 0.9529411792755128, + "r": 0.7686274647712708 + }, + "position": 0.119999997317791 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.8549019694328308, + "g": 0.8941176533699036, + "r": 0.5607843399047852 + }, + "position": 0.3499999940395355 + }, + { + "boundVariables": {}, + "color": { + "a": 0.8999999761581421, + "b": 0.8274509906768799, + "g": 0.8156862854957581, + "r": 0.2705882489681244 + }, + "position": 0.6200000047683716 + }, + { + "boundVariables": {}, + "color": { + "a": 0.6000000238418579, + "b": 0.8039215803146362, + "g": 0.7607843279838562, + "r": 0.14901961386203766 + }, + "position": 0.8600000143051147 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.772549033164978, + "g": 0.7215686440467834, + "r": 0.11372549086809158 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.9895601272583008, + -0.06226864457130432, + 0.0533602200448513 + ], + [ + 0.13806335628032684, + 0.446306437253952, + 0.05430806800723076 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 6.9661030769348145, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "orbit-inner-tail", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 15.414355278015137, + "x": 3.168776512145996, + "y": 5.810537815093994 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9041": { + "id": "I51:6610;44:5195;136:9041", + "type": "GROUP", + "fields": { + "parentId": "I51:6610;44:5195", + "childrenIds": [ + "I51:6610;44:5195;136:9042", + "I51:6610;44:5195;136:9043", + "I51:6610;44:5195;136:9044", + "I51:6610;44:5195;136:9045", + "I51:6610;44:5195;136:9046", + "I51:6610;44:5195;136:9047", + "I51:6610;44:5195;136:9048", + "I51:6610;44:5195;136:9049", + "I51:6610;44:5195;136:9050", + "I51:6610;44:5195;136:9051" + ], + "absoluteBoundingBox": { + "height": 16.56101417541504, + "width": 137.43359375, + "x": 12658.50390625, + "y": 1172.5244140625 + }, + "absoluteRenderBounds": { + "height": 16.56103515625, + "width": 137.43359375, + "x": 12658.50390625, + "y": 1172.5244140625 + }, + "height": 16.56101417541504, + "inferredAutoLayout": { + "counterAxisAlignItems": "MAX", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 1.8411893844604492, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 1.9073486328125e-05, + "paddingLeft": 0.0003662109375, + "paddingRight": 0.00037384033203125, + "paddingTop": 9.918212890625e-05, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "ChondroGen", + "opacity": 1, + "visible": true, + "width": 137.43359375, + "x": 35.50390625, + "y": 5.5244140625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9042": { + "id": "I51:6610;44:5195;136:9042", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 11.892135620117188, + "width": 11.560546875, + "x": 12784.376953125, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 11.89208984375, + "width": 11.560546875, + "x": 12784.376953125, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.6980392336845398, + "g": 0.6117647290229797, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 11.892135620117188, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 11.560546875, + "x": 161.37657928466797, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9043": { + "id": "I51:6610;44:5195;136:9043", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 12.046292304992676, + "width": 12.000109672546388, + "x": 12770.1767578125, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 12.0462646484375, + "width": 12, + "x": 12770.1767578125, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.6980392336845398, + "g": 0.6117647290229797, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 12.046292304992676, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 12.000109672546388, + "x": 147.1771240234375, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9044": { + "id": "I51:6610;44:5195;136:9044", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 15.85618019104004, + "width": 14.505629539489746, + "x": 12753.771484375, + "y": 1173.229248046875 + }, + "absoluteRenderBounds": { + "height": 15.856201171875, + "width": 14.505859375, + "x": 12753.771484375, + "y": 1173.229248046875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.6980392336845398, + "g": 0.6117647290229797, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 15.85618019104004, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 14.505629539489746, + "x": 130.7712631225586, + "y": 6.229228973388672 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9045": { + "id": "I51:6610;44:5195;136:9045", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 12.046292304992676, + "width": 12.505603790283203, + "x": 12739.9130859375, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 12.0462646484375, + "width": 12.505859375, + "x": 12739.9130859375, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 12.046292304992676, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 12.505603790283203, + "x": 116.91329956054688, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9046": { + "id": "I51:6610;44:5195;136:9046", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 11.892135620117188, + "width": 6.791267395019531, + "x": 12732.3056640625, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 11.89208984375, + "width": 6.791015625, + "x": 12732.3056640625, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 11.892135620117188, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 6.791267395019531, + "x": 109.3053970336914, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9047": { + "id": "I51:6610;44:5195;136:9047", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 16.494831085205078, + "width": 12.439671516418455, + "x": 12716.689453125, + "y": 1172.5245361328125 + }, + "absoluteRenderBounds": { + "height": 16.494873046875, + "width": 12.439453125, + "x": 12716.689453125, + "y": 1172.5245361328125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 16.494831085205078, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 12.439671516418455, + "x": 93.68937683105467, + "y": 5.524513244628906 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9048": { + "id": "I51:6610;44:5195;136:9048", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 11.892135620117188, + "width": 11.560542106628418, + "x": 12703.029296875, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 11.89208984375, + "width": 11.560546875, + "x": 12703.029296875, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 11.892135620117188, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 11.560542106628418, + "x": 80.02886962890625, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9049": { + "id": "I51:6610;44:5195;136:9049", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 12.046292304992676, + "width": 12.505608558654783, + "x": 12688.3359375, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 12.0462646484375, + "width": 12.505859375, + "x": 12688.3359375, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 12.046292304992676, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 12.505608558654783, + "x": 65.33576965332031, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9050": { + "id": "I51:6610;44:5195;136:9050", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 16.340675354003906, + "width": 11.560545921325684, + "x": 12674.6748046875, + "y": 1172.5245361328125 + }, + "absoluteRenderBounds": { + "height": 16.3406982421875, + "width": 11.560546875, + "x": 12674.6748046875, + "y": 1172.5245361328125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 16.340675354003906, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 11.560545921325684, + "x": 51.675262451171875, + "y": 5.524513244628906 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9051": { + "id": "I51:6610;44:5195;136:9051", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 15.85618019104004, + "width": 14.329800605773926, + "x": 12658.50390625, + "y": 1173.229248046875 + }, + "absoluteRenderBounds": { + "height": 15.856201171875, + "width": 14.330078125, + "x": 12658.50390625, + "y": 1173.229248046875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 15.85618019104004, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 14.329800605773926, + "x": 35.5042724609375, + "y": 6.229228973388672 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_notice_tab_nodes.json b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_notice_tab_nodes.json new file mode 100644 index 00000000..1dfb7120 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_notice_tab_nodes.json @@ -0,0 +1,1657 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "73:7860", + "73:7830" + ], + "nodes": { + "73:7830": { + "id": "73:7830", + "type": "FRAME", + "fields": { + "parentId": "73:4837", + "parentType": "SECTION", + "parentName": "공지사항", + "childrenIds": [ + "73:7835" + ], + "absoluteBoundingBox": { + "height": 1500, + "width": 360, + "x": 36935, + "y": 1133 + }, + "absoluteRenderBounds": { + "height": 1500, + "width": 360, + "x": 36935, + "y": 1133 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1500, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Mobile", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 1627, + "y": 95 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:7835": { + "id": "73:7835", + "type": "INSTANCE", + "fields": { + "parentId": "73:7830", + "childrenIds": [ + "I73:7835;44:5412" + ], + "absoluteBoundingBox": { + "height": 47, + "width": 360, + "x": 36935, + "y": 1335 + }, + "absoluteRenderBounds": { + "height": 47, + "width": 360, + "x": 36935, + "y": 1335 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "Slot#44:11": { + "preferredValues": [], + "type": "SLOT" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Mobile" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 47, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageMenu", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:7835", + "overriddenFields": [ + "height", + "width" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "viewport": "Mobile" + }, + "visible": true, + "width": 360, + "x": 0, + "y": 202, + "mainComponentId": "44:5411", + "mainComponentName": "viewport=Mobile", + "mainComponentSetId": "44:5410" + }, + "extra": {}, + "fieldErrors": {} + }, + "73:7860": { + "id": "73:7860", + "type": "FRAME", + "fields": { + "parentId": "73:4837", + "parentType": "SECTION", + "parentName": "공지사항", + "childrenIds": [ + "73:7865" + ], + "absoluteBoundingBox": { + "height": 1460, + "width": 1440, + "x": 35428, + "y": 1133 + }, + "absoluteRenderBounds": { + "height": 1460, + "width": 1440, + "x": 35428, + "y": 1133 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1460, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Desktop", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 120, + "y": 95 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:7865": { + "id": "73:7865", + "type": "INSTANCE", + "fields": { + "parentId": "73:7860", + "childrenIds": [ + "I73:7865;40:643" + ], + "absoluteBoundingBox": { + "height": 67, + "width": 1440, + "x": 35428, + "y": 1449 + }, + "absoluteRenderBounds": { + "height": 67, + "width": 1440, + "x": 35428, + "y": 1449 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "Slot#44:11": { + "preferredValues": [], + "type": "SLOT" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Desktop" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 67, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageMenu", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "viewport": "Desktop" + }, + "visible": true, + "width": 1440, + "x": 0, + "y": 316, + "mainComponentId": "40:650", + "mainComponentName": "viewport=Desktop", + "mainComponentSetId": "44:5410" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:7835;44:5412": { + "id": "I73:7835;44:5412", + "type": "SLOT", + "fields": { + "parentId": "73:7835", + "childrenIds": [ + "I73:7835;44:5412;73:7833", + "I73:7835;44:5412;73:7834" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 360, + "x": 36935, + "y": 1335 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 360, + "x": 36935, + "y": 1335 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "slotContentId": "Slot#44:11" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9764705896377563 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 1280, + "name": "Slot", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:7835;44:5412;73:7833": { + "id": "I73:7835;44:5412;73:7833", + "type": "INSTANCE", + "fields": { + "parentId": "I73:7835;44:5412", + "childrenIds": [ + "I73:7835;44:5412;73:7833;40:608" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 180, + "x": 36935, + "y": 1335 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 180, + "x": 36935, + "y": 1335 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ], + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "selected" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 기업 뉴스", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I73:7835;44:5412;73:7833", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:7835;44:5412;73:7833;40:608", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 3, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "selected" + }, + "visible": true, + "width": 180, + "x": 0, + "y": 0, + "mainComponentId": "40:615", + "mainComponentName": "effect=selected", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:7835;44:5412;73:7833;40:608": { + "id": "I73:7835;44:5412;73:7833;40:608", + "type": "TEXT", + "fields": { + "parentId": "I73:7835;44:5412;73:7833", + "absoluteBoundingBox": { + "height": 14, + "width": 52, + "x": 36999, + "y": 1351 + }, + "absoluteRenderBounds": { + "height": 12.6280517578125, + "width": 50.21484375, + "x": 36999.90625, + "y": 1351.5899658203125 + }, + "boundVariables": { + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "기업 뉴스", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:4233490d967b21346e274bd97badac8375c1ed19,", + "textTruncation": "DISABLED", + "visible": true, + "width": 52, + "x": 64, + "y": 16, + "styledTextSegments": [ + { + "characters": "기업 뉴스", + "end": 5, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:4233490d967b21346e274bd97badac8375c1ed19," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:7835;44:5412;73:7834": { + "id": "I73:7835;44:5412;73:7834", + "type": "INSTANCE", + "fields": { + "parentId": "I73:7835;44:5412", + "childrenIds": [ + "I73:7835;44:5412;73:7834;40:610" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 180, + "x": 37115, + "y": 1335 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 180, + "x": 37115, + "y": 1335 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 공지사항", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I73:7835;44:5412;73:7834", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:7835;44:5412;73:7834;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 180, + "x": 180, + "y": 0, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:7835;44:5412;73:7834;40:610": { + "id": "I73:7835;44:5412;73:7834;40:610", + "type": "TEXT", + "fields": { + "parentId": "I73:7835;44:5412;73:7834", + "absoluteBoundingBox": { + "height": 14, + "width": 49, + "x": 37180.5, + "y": 1351 + }, + "absoluteRenderBounds": { + "height": 12.5579833984375, + "width": 47.3359375, + "x": 37181.4375, + "y": 1351.5760498046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "공지사항", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 49, + "x": 65.5, + "y": 16, + "styledTextSegments": [ + { + "characters": "공지사항", + "end": 4, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:7865;40:643": { + "id": "I73:7865;40:643", + "type": "SLOT", + "fields": { + "parentId": "73:7865", + "childrenIds": [ + "I73:7865;40:643;73:7863", + "I73:7865;40:643;73:7864" + ], + "absoluteBoundingBox": { + "height": 67, + "width": 426, + "x": 35935, + "y": 1449 + }, + "absoluteRenderBounds": { + "height": 67, + "width": 426, + "x": 35935, + "y": 1449 + }, + "bottomLeftRadius": 10000, + "bottomRightRadius": 10000, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + ], + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "slotContentId": "Slot#44:11" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 10000, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9764705896377563 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 67, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 8, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 1280, + "name": "Slot", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 8, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "topLeftRadius": 10000, + "topRightRadius": 10000, + "visible": true, + "width": 426, + "x": 507, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:7865;40:643;73:7863": { + "id": "I73:7865;40:643;73:7863", + "type": "INSTANCE", + "fields": { + "parentId": "I73:7865;40:643", + "childrenIds": [ + "I73:7865;40:643;73:7863;40:610" + ], + "absoluteBoundingBox": { + "height": 49, + "width": 200, + "x": 35944, + "y": 1458 + }, + "absoluteRenderBounds": { + "height": 49, + "width": 200, + "x": 35944, + "y": 1458 + }, + "bottomLeftRadius": 1000, + "bottomRightRadius": 1000, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 1000, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 49, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 기업 뉴스", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I73:7865;40:643;73:7863", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:7865;40:643;73:7863;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 1000, + "topRightRadius": 1000, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 200, + "x": 9, + "y": 9, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:7865;40:643;73:7863;40:610": { + "id": "I73:7865;40:643;73:7863;40:610", + "type": "TEXT", + "fields": { + "parentId": "I73:7865;40:643;73:7863", + "absoluteBoundingBox": { + "height": 17, + "width": 62, + "x": 36013, + "y": 1474 + }, + "absoluteRenderBounds": { + "height": 15.1640625, + "width": 60.75, + "x": 36013.66796875, + "y": 1475.22998046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "기업 뉴스", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 62, + "x": 69, + "y": 16, + "styledTextSegments": [ + { + "characters": "기업 뉴스", + "end": 5, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:7865;40:643;73:7864": { + "id": "I73:7865;40:643;73:7864", + "type": "INSTANCE", + "fields": { + "parentId": "I73:7865;40:643", + "childrenIds": [ + "I73:7865;40:643;73:7864;40:608" + ], + "absoluteBoundingBox": { + "height": 49, + "width": 200, + "x": 36152, + "y": 1458 + }, + "absoluteRenderBounds": { + "height": 49, + "width": 200, + "x": 36152, + "y": 1458 + }, + "bottomLeftRadius": 1000, + "bottomRightRadius": 1000, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ], + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "selected" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 1000, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 49, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 공지사항", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I73:7865;40:643;73:7864", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:7865;40:643;73:7864;40:608", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 3, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "topLeftRadius": 1000, + "topRightRadius": 1000, + "variantProperties": { + "effect": "selected" + }, + "visible": true, + "width": 200, + "x": 217, + "y": 9, + "mainComponentId": "40:615", + "mainComponentName": "effect=selected", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:7865;40:643;73:7864;40:608": { + "id": "I73:7865;40:643;73:7864;40:608", + "type": "TEXT", + "fields": { + "parentId": "I73:7865;40:643;73:7864", + "absoluteBoundingBox": { + "height": 17, + "width": 59, + "x": 36222.5, + "y": 1474 + }, + "absoluteRenderBounds": { + "height": 15.4190673828125, + "width": 57.58203125, + "x": 36223.3359375, + "y": 1475.04296875 + }, + "boundVariables": { + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "공지사항", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:4233490d967b21346e274bd97badac8375c1ed19,", + "textTruncation": "DISABLED", + "visible": true, + "width": 59, + "x": 70.5, + "y": 16, + "styledTextSegments": [ + { + "characters": "공지사항", + "end": 4, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:4233490d967b21346e274bd97badac8375c1ed19," + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_records_padding_tokens.json b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_records_padding_tokens.json new file mode 100644 index 00000000..b8f24429 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_records_padding_tokens.json @@ -0,0 +1,631 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "73:8740", + "73:8800" + ], + "nodes": { + "73:8740": { + "id": "73:8740", + "type": "FRAME", + "fields": { + "parentId": "73:8739", + "parentType": "SECTION", + "parentName": "연구성과/주요실적", + "childrenIds": [ + "73:8749" + ], + "absoluteBoundingBox": { + "height": 1752, + "width": 1440, + "x": 41240, + "y": 8485 + }, + "absoluteRenderBounds": { + "height": 1752, + "width": 1440, + "x": 41240, + "y": 8485 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1752, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "연구성과/주요실적 · Desktop 1440", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 60 + ], + [ + 0, + 1, + 70 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 60, + "y": 70 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8749": { + "id": "73:8749", + "type": "FRAME", + "fields": { + "parentId": "73:8740", + "childrenIds": [ + "73:8750" + ], + "absoluteBoundingBox": { + "height": 1188, + "width": 1440, + "x": 41240, + "y": 8868 + }, + "absoluteRenderBounds": { + "height": 1188, + "width": 1440, + "x": 41240, + "y": 8868 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingBottom": { + "id": "VariableID:30:632", + "type": "VARIABLE_ALIAS" + }, + "paddingLeft": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + }, + "paddingRight": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 1188, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 80, + "paddingLeft": 40, + "paddingRight": 40, + "paddingTop": 40, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Achievements", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 80, + "paddingLeft": 40, + "paddingRight": 40, + "paddingTop": 40, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 383 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 0, + "y": 383 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8750": { + "id": "73:8750", + "type": "FRAME", + "fields": { + "parentId": "73:8749", + "childrenIds": [], + "absoluteBoundingBox": { + "height": 1068, + "width": 1280, + "x": 41320, + "y": 8908 + }, + "absoluteRenderBounds": { + "height": 1068, + "width": 1280, + "x": 41320, + "y": 8908 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 1068, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 48, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 48, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 1280, + "name": "container", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 80 + ], + [ + 0, + 1, + 40 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1280, + "x": 80, + "y": 40 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8800": { + "id": "73:8800", + "type": "FRAME", + "fields": { + "parentId": "73:8739", + "parentType": "SECTION", + "parentName": "연구성과/주요실적", + "childrenIds": [ + "73:8809" + ], + "absoluteBoundingBox": { + "height": 1791, + "width": 360, + "x": 42800, + "y": 8485 + }, + "absoluteRenderBounds": { + "height": 1791, + "width": 360, + "x": 42800, + "y": 8485 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1791, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "연구성과/주요실적 · Mobile 360", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 1620 + ], + [ + 0, + 1, + 70 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 1620, + "y": 70 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8809": { + "id": "73:8809", + "type": "FRAME", + "fields": { + "parentId": "73:8800", + "childrenIds": [ + "73:8810" + ], + "absoluteBoundingBox": { + "height": 1295, + "width": 360, + "x": 42800, + "y": 8734 + }, + "absoluteRenderBounds": { + "height": 1295, + "width": 360, + "x": 42800, + "y": 8734 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingBottom": { + "id": "VariableID:30:632", + "type": "VARIABLE_ALIAS" + }, + "paddingLeft": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + }, + "paddingRight": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 1295, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 40, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 40, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Achievements", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 40, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 40, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 249 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 0, + "y": 249 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8810": { + "id": "73:8810", + "type": "FRAME", + "fields": { + "parentId": "73:8809", + "childrenIds": [], + "absoluteBoundingBox": { + "height": 1215, + "width": 320, + "x": 42820, + "y": 8774 + }, + "absoluteRenderBounds": { + "height": 1215, + "width": 320, + "x": 42820, + "y": 8774 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 1215, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 1280, + "name": "container", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 20 + ], + [ + 0, + 1, + 40 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 20, + "y": 40 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_transit_carriage_returns.json b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_transit_carriage_returns.json new file mode 100644 index 00000000..94e98941 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/chondrogen_transit_carriage_returns.json @@ -0,0 +1,436 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "roots": [ + "44:1640", + "44:1650", + "44:1716", + "44:1721" + ], + "nodes": { + "44:1640": { + "id": "44:1640", + "type": "TEXT", + "fields": { + "parentId": "44:1637", + "absoluteBoundingBox": { + "height": 24, + "width": 290, + "x": 24930, + "y": 2347 + }, + "absoluteRenderBounds": { + "height": 14.591796875, + "width": 287.201171875, + "x": 24931.568359375, + "y": 2352.152099609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": ": 097, 098, 099, 2, 567, 7733, 9703\r번 버스", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": ": 097, 098, 099, 2, 567, 7733, 9703 번 버스", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 290, + "x": 113, + "y": 4, + "styledTextSegments": [ + { + "characters": ": 097, 098, 099, 2, 567, 7733, 9703\r번 버스", + "end": 40, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "44:1650": { + "id": "44:1650", + "type": "TEXT", + "fields": { + "parentId": "44:1646", + "absoluteBoundingBox": { + "height": 24, + "width": 199, + "x": 24930, + "y": 2391 + }, + "absoluteRenderBounds": { + "height": 14.591796875, + "width": 196.009765625, + "x": 24931.568359375, + "y": 2396.152099609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": ": 95, 05, 080, 081, 871\r번 버스", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": ": 95, 05, 080, 081, 871 번 버스", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 199, + "x": 113, + "y": 4, + "styledTextSegments": [ + { + "characters": ": 95, 05, 080, 081, 871\r번 버스", + "end": 28, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "44:1716": { + "id": "44:1716", + "type": "TEXT", + "fields": { + "parentId": "44:1712", + "absoluteBoundingBox": { + "height": 23, + "width": 272, + "x": 26238, + "y": 2217 + }, + "absoluteRenderBounds": { + "height": 13.679931640625, + "width": 269.251953125, + "x": 26239.470703125, + "y": 2221.955078125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": ": 097, 098, 099, 2, 567, 7733, 9703\r번 버스", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": ": 097, 098, 099, 2, 567, 7733, 9703 번 버스", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 272, + "x": 0, + "y": 36, + "styledTextSegments": [ + { + "characters": ": 097, 098, 099, 2, 567, 7733, 9703\r번 버스", + "end": 40, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "44:1721": { + "id": "44:1721", + "type": "TEXT", + "fields": { + "parentId": "44:1717", + "absoluteBoundingBox": { + "height": 23, + "width": 186, + "x": 26346, + "y": 2256.5 + }, + "absoluteRenderBounds": { + "height": 13.679931640625, + "width": 183.759765625, + "x": 26347.470703125, + "y": 2261.455078125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": ": 95, 05, 080, 081, 871\r번 버스", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": ": 95, 05, 080, 081, 871 번 버스", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 186, + "x": 108, + "y": 4.5, + "styledTextSegments": [ + { + "characters": ": 95, 05, 080, 081, 871\r번 버스", + "end": 28, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/clipped-free-pagemenu.json b/crates/devup-mcp-devup-ui/tests/fixtures/clipped-free-pagemenu.json new file mode 100644 index 00000000..2f7a5e60 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/clipped-free-pagemenu.json @@ -0,0 +1,1393 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "73:8525" + ], + "nodes": { + "73:8525": { + "id": "73:8525", + "type": "FRAME", + "fields": { + "parentId": "73:8523", + "childrenIds": [ + "73:8526" + ], + "absoluteBoundingBox": { + "height": 47, + "width": 360, + "x": 38352, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 47, + "width": 360, + "x": 38352, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + ], + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9764705896377563 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 47, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 1, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "PageMenu", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 0, + "y": 202 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8526": { + "id": "73:8526", + "type": "FRAME", + "fields": { + "parentId": "73:8525", + "childrenIds": [ + "73:8527", + "73:8528", + "73:8529", + "73:8530", + "73:8531" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 582, + "x": 38201, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 360, + "x": 38352, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "tabs (가로 스크롤 · 현재 탭이 보이도록 스크롤된 상태)", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 582, + "x": -151, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8527": { + "id": "73:8527", + "type": "INSTANCE", + "fields": { + "parentId": "73:8526", + "childrenIds": [ + "I73:8527;40:610" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 84, + "x": 38201, + "y": 8777 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignItems": "CENTER", + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 핵심 기술", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:8527", + "overriddenFields": [ + "height", + "name", + "paddingLeft", + "paddingRight", + "primaryAxisSizingMode", + "width" + ] + }, + { + "id": "I73:8527;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 84, + "x": 0, + "y": 0, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8527;40:610": { + "id": "I73:8527;40:610", + "type": "TEXT", + "fields": { + "parentId": "73:8527", + "absoluteBoundingBox": { + "height": 14, + "width": 52, + "x": 38217, + "y": 8793 + }, + "absoluteRenderBounds": null, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "핵심 기술", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 52, + "x": 16, + "y": 16, + "styledTextSegments": [ + { + "characters": "핵심 기술", + "end": 5, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8528": { + "id": "73:8528", + "type": "INSTANCE", + "fields": { + "parentId": "73:8526", + "childrenIds": [ + "I73:8528;40:610" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 107, + "x": 38285, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 40, + "x": 38352, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignItems": "CENTER", + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 연구개발 분야", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:8528", + "overriddenFields": [ + "height", + "name", + "paddingLeft", + "paddingRight", + "primaryAxisSizingMode", + "width" + ] + }, + { + "id": "I73:8528;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 107, + "x": 84, + "y": 0, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8528;40:610": { + "id": "I73:8528;40:610", + "type": "TEXT", + "fields": { + "parentId": "73:8528", + "absoluteBoundingBox": { + "height": 14, + "width": 75, + "x": 38301, + "y": 8793 + }, + "absoluteRenderBounds": { + "height": 12.48828125, + "width": 23.73046875, + "x": 38352, + "y": 8793.66015625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연구개발 분야", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 75, + "x": 16, + "y": 16, + "styledTextSegments": [ + { + "characters": "연구개발 분야", + "end": 7, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8529": { + "id": "73:8529", + "type": "INSTANCE", + "fields": { + "parentId": "73:8526", + "childrenIds": [ + "I73:8529;40:608" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 135, + "x": 38392, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 135, + "x": 38392, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ], + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "selected" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignItems": "CENTER", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 골관절염/연골재생", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:8529", + "overriddenFields": [ + "height", + "name", + "paddingLeft", + "paddingRight", + "primaryAxisSizingMode", + "width" + ] + }, + { + "id": "I73:8529;40:608", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 3, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "selected" + }, + "visible": true, + "width": 135, + "x": 191, + "y": 0, + "mainComponentId": "40:615", + "mainComponentName": "effect=selected", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8529;40:608": { + "id": "I73:8529;40:608", + "type": "TEXT", + "fields": { + "parentId": "73:8529", + "absoluteBoundingBox": { + "height": 14, + "width": 103, + "x": 38408, + "y": 8793 + }, + "absoluteRenderBounds": { + "height": 12.6142578125, + "width": 100.58984375, + "x": 38408.91796875, + "y": 8793.58984375 + }, + "boundVariables": { + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "골관절염/연골재생", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:4233490d967b21346e274bd97badac8375c1ed19,", + "textTruncation": "DISABLED", + "visible": true, + "width": 103, + "x": 16, + "y": 16, + "styledTextSegments": [ + { + "characters": "골관절염/연골재생", + "end": 9, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:4233490d967b21346e274bd97badac8375c1ed19," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8530": { + "id": "73:8530", + "type": "INSTANCE", + "fields": { + "parentId": "73:8526", + "childrenIds": [ + "I73:8530;40:610" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 122, + "x": 38527, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 122, + "x": 38527, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignItems": "CENTER", + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 특허/지식재산권", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:8530", + "overriddenFields": [ + "height", + "name", + "paddingLeft", + "paddingRight", + "primaryAxisSizingMode", + "width" + ] + }, + { + "id": "I73:8530;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 122, + "x": 326, + "y": 0, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8530;40:610": { + "id": "I73:8530;40:610", + "type": "TEXT", + "fields": { + "parentId": "73:8530", + "absoluteBoundingBox": { + "height": 14, + "width": 90, + "x": 38543, + "y": 8793 + }, + "absoluteRenderBounds": { + "height": 12.4736328125, + "width": 88.23828125, + "x": 38543.59375, + "y": 8793.66015625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "특허/지식재산권", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 90, + "x": 16, + "y": 16, + "styledTextSegments": [ + { + "characters": "특허/지식재산권", + "end": 8, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8531": { + "id": "73:8531", + "type": "INSTANCE", + "fields": { + "parentId": "73:8526", + "childrenIds": [ + "I73:8531;40:610" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 134, + "x": 38649, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 63, + "x": 38649, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignItems": "CENTER", + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 연구성과/주요실적", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:8531", + "overriddenFields": [ + "height", + "name", + "paddingLeft", + "paddingRight", + "primaryAxisSizingMode", + "width" + ] + }, + { + "id": "I73:8531;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 134, + "x": 448, + "y": 0, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8531;40:610": { + "id": "I73:8531;40:610", + "type": "TEXT", + "fields": { + "parentId": "73:8531", + "absoluteBoundingBox": { + "height": 14, + "width": 102, + "x": 38665, + "y": 8793 + }, + "absoluteRenderBounds": { + "height": 12.48828125, + "width": 46.21875, + "x": 38665.78125, + "y": 8793.66015625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연구성과/주요실적", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 102, + "x": 16, + "y": 16, + "styledTextSegments": [ + { + "characters": "연구성과/주요실적", + "end": 9, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/dropdown-option-pure-state-axis.json b/crates/devup-mcp-devup-ui/tests/fixtures/dropdown-option-pure-state-axis.json new file mode 100644 index 00000000..1815d01b --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/dropdown-option-pure-state-axis.json @@ -0,0 +1 @@ +{"fileKey":"K2kUnxGgvooAJwCQiVTb7g","version":null,"roots":["45:2034"],"nodes":{"45:2025":{"id":"45:2025","type":"TEXT","fields":{"parentId":"45:2033","absoluteBoundingBox":{"height":24,"width":37,"x":8818,"y":4955},"absoluteRenderBounds":{"height":13.7919921875,"width":34.408203125,"x":8818.6396484375,"y":4960.18408203125},"boundVariables":{"fills":[{"id":"VariableID:9:348","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:18:955","type":"VARIABLE_ALIAS"}]},"characters":"옵션 1","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:348","type":"VARIABLE_ALIAS"}},"color":{"b":0.3176470696926117,"g":0.250980406999588,"r":0.21568627655506137},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Regular"},"fontSize":16,"height":24,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":150},"maxHeight":null,"maxWidth":null,"name":"옵션 1","opacity":1,"relativeTransform":[[1,0,16],[0,1,12]],"strokeAlign":"OUTSIDE","strokeWeight":1,"textAutoResize":"WIDTH_AND_HEIGHT","textStyleId":"S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,","textTruncation":"DISABLED","visible":true,"width":37,"x":16,"y":12,"styledTextSegments":[{"characters":"옵션 1","end":4,"fillStyleId":"","fontWeight":400,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,"}]},"extra":{},"fieldErrors":{}},"45:2028":{"id":"45:2028","type":"TEXT","fields":{"parentId":"45:2032","absoluteBoundingBox":{"height":24,"width":37,"x":8818,"y":5031},"absoluteRenderBounds":{"height":13.7919921875,"width":34.408203125,"x":8818.6396484375,"y":5036.18408203125},"boundVariables":{"fills":[{"id":"VariableID:9:348","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:18:955","type":"VARIABLE_ALIAS"}]},"characters":"옵션 1","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:348","type":"VARIABLE_ALIAS"}},"color":{"b":0.3176470696926117,"g":0.250980406999588,"r":0.21568627655506137},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Regular"},"fontSize":16,"height":24,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":150},"maxHeight":null,"maxWidth":null,"name":"옵션 1","opacity":1,"relativeTransform":[[1,0,16],[0,1,12]],"strokeAlign":"OUTSIDE","strokeWeight":1,"textAutoResize":"WIDTH_AND_HEIGHT","textStyleId":"S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,","textTruncation":"DISABLED","visible":true,"width":37,"x":16,"y":12,"styledTextSegments":[{"characters":"옵션 1","end":4,"fillStyleId":"","fontWeight":400,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,"}]},"extra":{},"fieldErrors":{}},"45:2030":{"id":"45:2030","type":"TEXT","fields":{"parentId":"45:2031","absoluteBoundingBox":{"height":24,"width":37,"x":8818,"y":5107},"absoluteRenderBounds":{"height":13.7919921875,"width":34.408203125,"x":8818.6396484375,"y":5112.18408203125},"boundVariables":{"fills":[{"id":"VariableID:9:348","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:18:955","type":"VARIABLE_ALIAS"}]},"characters":"옵션 1","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:348","type":"VARIABLE_ALIAS"}},"color":{"b":0.3176470696926117,"g":0.250980406999588,"r":0.21568627655506137},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Regular"},"fontSize":16,"height":24,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":150},"maxHeight":null,"maxWidth":null,"name":"옵션 1","opacity":1,"relativeTransform":[[1,0,16],[0,1,12]],"strokeAlign":"OUTSIDE","strokeWeight":1,"textAutoResize":"WIDTH_AND_HEIGHT","textStyleId":"S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,","textTruncation":"DISABLED","visible":true,"width":37,"x":16,"y":12,"styledTextSegments":[{"characters":"옵션 1","end":4,"fillStyleId":"","fontWeight":400,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,"}]},"extra":{},"fieldErrors":{}},"45:2031":{"id":"45:2031","type":"COMPONENT","fields":{"parentId":"45:2034","parentType":"COMPONENT_SET","childrenIds":["45:2030"],"absoluteBoundingBox":{"height":48,"width":540,"x":8802,"y":5095},"absoluteRenderBounds":{"height":48,"width":540,"x":8802,"y":5095},"bottomLeftRadius":0,"bottomRightRadius":0,"boundVariables":{"fills":[{"id":"VariableID:11:3","type":"VARIABLE_ALIAS"}]},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:11:3","type":"VARIABLE_ALIAS"}},"color":{"b":0.9839743375778198,"g":0.9189276099205016,"r":0.8625540137290955},"opacity":1,"type":"SOLID","visible":true}],"height":48,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":12,"paddingLeft":16,"paddingRight":16,"paddingTop":12,"primaryAxisAlignItems":"MIN","primaryAxisSizingMode":"FIXED"},"itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"속성 1=active","opacity":1,"overflowDirection":"NONE","paddingBottom":12,"paddingLeft":16,"paddingRight":16,"paddingTop":12,"relativeTransform":[[1,0,20],[0,1,172]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":true,"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"속성 1":"active"},"visible":true,"width":540,"x":20,"y":172},"extra":{},"fieldErrors":{}},"45:2032":{"id":"45:2032","type":"COMPONENT","fields":{"parentId":"45:2034","parentType":"COMPONENT_SET","childrenIds":["45:2028"],"absoluteBoundingBox":{"height":48,"width":540,"x":8802,"y":5019},"absoluteRenderBounds":{"height":48,"width":540,"x":8802,"y":5019},"bottomLeftRadius":0,"bottomRightRadius":0,"boundVariables":{"fills":[{"id":"VariableID:11:2","type":"VARIABLE_ALIAS"}]},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:11:2","type":"VARIABLE_ALIAS"}},"color":{"b":1,"g":0.9715099334716796,"r":0.9487178325653076},"opacity":1,"type":"SOLID","visible":true}],"height":48,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":12,"paddingLeft":16,"paddingRight":16,"paddingTop":12,"primaryAxisAlignItems":"MIN","primaryAxisSizingMode":"FIXED"},"itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"속성 1=hover","opacity":1,"overflowDirection":"NONE","paddingBottom":12,"paddingLeft":16,"paddingRight":16,"paddingTop":12,"relativeTransform":[[1,0,20],[0,1,96]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":true,"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"속성 1":"hover"},"visible":true,"width":540,"x":20,"y":96},"extra":{},"fieldErrors":{}},"45:2033":{"id":"45:2033","type":"COMPONENT","fields":{"parentId":"45:2034","parentType":"COMPONENT_SET","childrenIds":["45:2025"],"absoluteBoundingBox":{"height":48,"width":540,"x":8802,"y":4943},"absoluteRenderBounds":{"height":48,"width":540,"x":8802,"y":4943},"bottomLeftRadius":0,"bottomRightRadius":0,"boundVariables":{"fills":[{"id":"VariableID:13:161","type":"VARIABLE_ALIAS"}]},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:13:161","type":"VARIABLE_ALIAS"}},"color":{"b":1,"g":1,"r":1},"opacity":1,"type":"SOLID","visible":true}],"height":48,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":12,"paddingLeft":16,"paddingRight":16,"paddingTop":12,"primaryAxisAlignItems":"MIN","primaryAxisSizingMode":"FIXED"},"itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"속성 1=default","opacity":1,"overflowDirection":"NONE","paddingBottom":12,"paddingLeft":16,"paddingRight":16,"paddingTop":12,"relativeTransform":[[1,0,20],[0,1,20]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":true,"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"속성 1":"default"},"visible":true,"width":540,"x":20,"y":20},"extra":{},"fieldErrors":{}},"45:2034":{"id":"45:2034","type":"COMPONENT_SET","fields":{"parentId":"19:963","parentType":"SECTION","parentName":"Components","childrenIds":["45:2033","45:2032","45:2031"],"absoluteBoundingBox":{"height":240,"width":580,"x":8782,"y":4923},"absoluteRenderBounds":{"height":240,"width":580,"x":8782,"y":4923},"bottomLeftRadius":5,"bottomRightRadius":5,"clipsContent":true,"componentPropertyDefinitions":{"속성 1":{"defaultValue":"default","type":"VARIANT","variantOptions":["default","hover","active"]}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"cornerRadius":5,"counterAxisAlignContent":"AUTO","counterAxisSpacing":0,"dashPattern":[10,5],"defaultVariant":{"$nodeId":"45:2033","$nodeType":"COMPONENT"},"height":240,"itemSpacing":0,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"NONE","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED","maxHeight":null,"maxWidth":null,"name":"DropdownOption","opacity":1,"overflowDirection":"NONE","paddingBottom":0,"paddingLeft":0,"paddingRight":0,"paddingTop":0,"relativeTransform":[[1,0,112],[0,1,3885]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokes":[{"blendMode":"NORMAL","boundVariables":{},"color":{"b":1,"g":0.27843138575553894,"r":0.5921568870544434},"opacity":1,"type":"SOLID","visible":true}],"strokesIncludedInLayout":false,"topLeftRadius":5,"topRightRadius":5,"visible":true,"width":580,"x":112,"y":3885},"extra":{},"fieldErrors":{}}},"diagnostics":[]} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/header-padding-aliases.json b/crates/devup-mcp-devup-ui/tests/fixtures/header-padding-aliases.json new file mode 100644 index 00000000..d85e9a59 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/header-padding-aliases.json @@ -0,0 +1,6280 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "45:2080", + "51:6610" + ], + "nodes": { + "45:2080": { + "id": "45:2080", + "type": "INSTANCE", + "fields": { + "parentId": "45:2066", + "childrenIds": [ + "I45:2080;44:5182" + ], + "absoluteBoundingBox": { + "height": 80, + "width": 1440, + "x": 13021, + "y": 1149 + }, + "absoluteRenderBounds": { + "height": 80, + "width": 1440, + "x": 13021, + "y": 1149 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "pageTop" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Desktop" + } + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 80, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "ABSOLUTE", + "paddingBottom": 0, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "header", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "45:2080", + "overriddenFields": [ + "height", + "layoutPositioning", + "width" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "type": "pageTop", + "viewport": "Desktop" + }, + "visible": true, + "width": 1440, + "x": 0, + "y": 0, + "mainComponentId": "44:5181", + "mainComponentName": "type=pageTop, viewport=Desktop", + "mainComponentSetId": "23:1575" + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5182": { + "id": "I45:2080;44:5182", + "type": "FRAME", + "fields": { + "parentId": "45:2080", + "childrenIds": [ + "I45:2080;44:5183", + "I45:2080;44:5184", + "I45:2080;44:5190" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 1408, + "x": 13037, + "y": 1165 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 1408, + "x": 13037, + "y": 1165 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "SPACE_BETWEEN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 450", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "SPACE_BETWEEN", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1408, + "x": 16, + "y": 16 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183": { + "id": "I45:2080;44:5183", + "type": "INSTANCE", + "fields": { + "parentId": "I45:2080;44:5182", + "childrenIds": [ + "I45:2080;44:5183;136:9027", + "I45:2080;44:5183;136:9028", + "I45:2080;44:5183;136:9041" + ], + "absoluteBoundingBox": { + "height": 32, + "width": 200, + "x": 13037, + "y": 1173 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 200, + "x": 13037, + "y": 1173 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "기본" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 32, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Logo", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 1102, + "y": 178 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "기본" + }, + "visible": true, + "width": 200, + "x": 0, + "y": 8, + "mainComponentId": "37:217", + "mainComponentName": "속성 1=기본", + "mainComponentSetId": "136:19763" + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9027": { + "id": "I45:2080;44:5183;136:9027", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183", + "absoluteBoundingBox": { + "height": 32, + "width": 32.304901123046875, + "x": 13037, + "y": 1173 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32.3046875, + "x": 13037, + "y": 1173 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.7490196228027344, + "g": 0.5607843399047852, + "r": 0.03921568766236305 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.6823529601097107, + "g": 0.5058823823928833, + "r": 0.007843137718737125 + }, + "position": 0.30000001192092896 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.5607843399047852, + "g": 0.3764705955982208, + "r": 0.003921568859368563 + }, + "position": 0.44999998807907104 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.46666666865348816, + "g": 0.2823529541492462, + "r": 0 + }, + "position": 0.6100000143051147 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.40784314274787903, + "g": 0.22745098173618317, + "r": 0 + }, + "position": 0.7599999904632568 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.3843137323856354, + "g": 0.2078431397676468, + "r": 0.003921568859368563 + }, + "position": 0.9100000262260436 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.3843137323856354, + "g": 0.2117647081613541, + "r": 0.003921568859368563 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.6454866528511047, + 0, + 0.31524205207824707 + ], + [ + 0, + 0.6454866528511047, + 0.204462930560112 + ] + ], + "opacity": 1, + "type": "GRADIENT_RADIAL", + "visible": true + } + ], + "height": 32, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "sphere", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 32.304901123046875, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9028": { + "id": "I45:2080;44:5183;136:9028", + "type": "GROUP", + "fields": { + "parentId": "I45:2080;44:5183", + "childrenIds": [ + "I45:2080;44:5183;136:9029", + "I45:2080;44:5183;136:9031" + ], + "absoluteBoundingBox": { + "height": 32, + "width": 32.304901123046875, + "x": 13037, + "y": 1173 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32.304901123046875, + "x": 13037, + "y": 1173 + }, + "height": 32, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Clip path group", + "opacity": 1, + "visible": true, + "width": 32.304901123046875, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9029": { + "id": "I45:2080;44:5183;136:9029", + "type": "GROUP", + "fields": { + "parentId": "I45:2080;44:5183;136:9028", + "childrenIds": [ + "I45:2080;44:5183;136:9030" + ], + "absoluteBoundingBox": { + "height": 32, + "width": 32.304901123046875, + "x": 13037, + "y": 1173 + }, + "absoluteRenderBounds": null, + "height": 32, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isMask": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "sphere-clip", + "opacity": 1, + "visible": true, + "width": 32.304901123046875, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9030": { + "id": "I45:2080;44:5183;136:9030", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9029", + "absoluteBoundingBox": { + "height": 32, + "width": 32.304901123046875, + "x": 13037, + "y": 1173 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32.3046875, + "x": 13037, + "y": 1173 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0, + "g": 0, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 32, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 32.304901123046875, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9031": { + "id": "I45:2080;44:5183;136:9031", + "type": "GROUP", + "fields": { + "parentId": "I45:2080;44:5183;136:9028", + "childrenIds": [ + "I45:2080;44:5183;136:9032", + "I45:2080;44:5183;136:9033", + "I45:2080;44:5183;136:9034", + "I45:2080;44:5183;136:9035", + "I45:2080;44:5183;136:9036", + "I45:2080;44:5183;136:9037", + "I45:2080;44:5183;136:9038", + "I45:2080;44:5183;136:9039", + "I45:2080;44:5183;136:9040" + ], + "absoluteBoundingBox": { + "height": 32, + "width": 32.304901123046875, + "x": 13037, + "y": 1173 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32.3046875, + "x": 13037, + "y": 1173 + }, + "height": 32, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "visible": true, + "width": 32.304901123046875, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9032": { + "id": "I45:2080;44:5183;136:9032", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9031", + "absoluteBoundingBox": { + "height": 32, + "width": 32.304901123046875, + "x": 13037, + "y": 1173 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32.3046875, + "x": 13037, + "y": 1173 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 0.41999998688697815, + "b": 0.3333333432674408, + "g": 0.16470588743686676, + "r": 0 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 0.20000000298023224, + "b": 0.3333333432674408, + "g": 0.16470588743686676, + "r": 0 + }, + "position": 0.550000011920929 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.3333333432674408, + "g": 0.16470588743686676, + "r": 0 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 1.252997875213623, + 0, + 0.08783221244812012 + ], + [ + 0, + 1.252997875213623, + -0.7154324054718018 + ] + ], + "opacity": 1, + "type": "GRADIENT_RADIAL", + "visible": true + } + ], + "height": 32, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "shade-outside-orbit", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 32.304901123046875, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9033": { + "id": "I45:2080;44:5183;136:9033", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9031", + "absoluteBoundingBox": { + "height": 32, + "width": 32.304901123046875, + "x": 13037, + "y": 1173 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32.3046875, + "x": 13037, + "y": 1173 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 0.14000000059604645, + "b": 0.8470588326454163, + "g": 0.6509804129600525, + "r": 0.10588235408067705 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.8470588326454163, + "g": 0.6509804129600525, + "r": 0.10588235408067705 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 1.9364598989486697, + 0, + 0.468456894159317 + ], + [ + 0, + 1.9364598989486697, + -0.5502269268035889 + ] + ], + "opacity": 1, + "type": "GRADIENT_RADIAL", + "visible": true + } + ], + "height": 32, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "light-left-overlay", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 32.304901123046875, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9034": { + "id": "I45:2080;44:5183;136:9034", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9031", + "absoluteBoundingBox": { + "height": 6.700944423675537, + "width": 13.174972534179688, + "x": 13054.4453125, + "y": 1195.8109130859375 + }, + "absoluteRenderBounds": { + "height": 6.700927734375, + "width": 13.1748046875, + "x": 13054.4453125, + "y": 1195.8109130859375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 0.550000011920929, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 0.30000001192092896 + }, + { + "boundVariables": {}, + "color": { + "a": 0.5, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 0.75 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.8151227831840515, + -0.2092042565345764, + 0.19614684581756592 + ], + [ + 0.4074420928955078, + 0.41853103041648865, + 0.08879981189966202 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 6.700944423675537, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "rim-highlight (Stroke)", + "opacity": 1, + "strokeAlign": "CENTER", + "strokeWeight": 3.849999904632568, + "visible": true, + "width": 13.174972534179688, + "x": 17.445581436157227, + "y": 22.81094741821289 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9035": { + "id": "I45:2080;44:5183;136:9035", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9031", + "absoluteBoundingBox": { + "height": 18.27490997314453, + "width": 18.435367584228516, + "x": 13040.697265625, + "y": 1179.6190185546875 + }, + "absoluteRenderBounds": { + "height": 18.27490234375, + "width": 18.435546875, + "x": 13040.697265625, + "y": 1179.6190185546875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.8156862854957581, + "g": 0.7843137383460999, + "r": 0.03921568766236305 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.5882353186607361, + "g": 0.4313725531101227, + "r": 0 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.8613659739494324, + 0.45280781388282776, + -0.1629999577999115 + ], + [ + -0.4524722993373871, + 0.8620046973228455, + 0.31514695286750793 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 18.27490997314453, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "core", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 18.435367584228516, + "x": 3.697471857070923, + "y": 6.618992328643799 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9036": { + "id": "I45:2080;44:5183;136:9036", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9031", + "absoluteBoundingBox": { + "height": 10.8725004196167, + "width": 10.976094245910645, + "x": 13043.9345703125, + "y": 1183.346923828125 + }, + "absoluteRenderBounds": { + "height": 10.87255859375, + "width": 10.9765625, + "x": 13043.9345703125, + "y": 1183.346923828125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.8745098114013672, + "g": 0.9333333373069764, + "r": 0.6705882549285889 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.7607843279838562, + "g": 0.7215686440467834, + "r": 0.0235294122248888 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.8080573678016663, + 0.5874120593070984, + -0.19792257249355316 + ], + [ + -0.5874120593070984, + 0.8080573678016663, + 0.3895654380321502 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 10.8725004196167, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "ring", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 10.976094245910645, + "x": 6.9348273277282715, + "y": 10.346875190734863 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9037": { + "id": "I45:2080;44:5183;136:9037", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9031", + "absoluteBoundingBox": { + "height": 5.911250114440918, + "width": 5.967573165893555, + "x": 13046.33203125, + "y": 1185.721923828125 + }, + "absoluteRenderBounds": { + "height": 5.9112548828125, + "width": 5.9677734375, + "x": 13046.33203125, + "y": 1185.721923828125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.6196078658103943, + "g": 0.4745098054409027, + "r": 0.007843137718737125 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.545098066329956, + "g": 0.3764705955982208, + "r": 0.003921568859368563 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.9339293241500854, + 0.3536636531352997, + -0.14337609708309174 + ], + [ + -0.3536636531352997, + 0.9339293241500854, + 0.2099337726831436 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 5.911250114440918, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "ring-center", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 5.967573165893555, + "x": 9.332456588745115, + "y": 12.72187614440918 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9038": { + "id": "I45:2080;44:5183;136:9038", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9031", + "absoluteBoundingBox": { + "height": 26.112192153930664, + "width": 27.539609909057617, + "x": 13037.0419921875, + "y": 1175.9078369140625 + }, + "absoluteRenderBounds": { + "height": 26.1121826171875, + "width": 27.5400390625, + "x": 13037.0419921875, + "y": 1175.9078369140625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9843137264251708, + "g": 0.9764705896377563, + "r": 0.9607843160629272 + }, + "position": 0.1899999976158142 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9803921580314636, + "g": 0.91372549533844, + "r": 0.7764706015586853 + }, + "position": 0.3799999952316284 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9725490212440492, + "g": 0.8588235378265381, + "r": 0.6039215922355652 + }, + "position": 0.5 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.95686274766922, + "g": 0.7843137383460999, + "r": 0.3843137323856354 + }, + "position": 0.6600000262260437 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9333333373069764, + "g": 0.729411780834198, + "r": 0.21960784494876864 + }, + "position": 0.8399999737739563 + } + ], + "gradientTransform": [ + [ + -1.6364001035690308, + 1.5055831670761108, + -0.08484271168708801 + ], + [ + -1.5728986263275146, + -1.5663667917251587, + 2.0555288791656494 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 26.112192153930664, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "orbit-outer (Stroke)", + "opacity": 1, + "strokeAlign": "CENTER", + "strokeWeight": 5.050000190734863, + "visible": true, + "width": 27.539609909057617, + "x": 0.041821110993623734, + "y": 2.907834053039551 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9039": { + "id": "I45:2080;44:5183;136:9039", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9031", + "absoluteBoundingBox": { + "height": 14.986942291259766, + "width": 27.421390533447266, + "x": 13040.4541015625, + "y": 1183.2818603515625 + }, + "absoluteRenderBounds": { + "height": 14.9869384765625, + "width": 27.421875, + "x": 13040.4541015625, + "y": 1183.2818603515625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9647058844566344 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 14.986942291259766, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "orbit-inner", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 27.421390533447266, + "x": 3.454395532608032, + "y": 10.281874656677246 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9040": { + "id": "I45:2080;44:5183;136:9040", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9031", + "absoluteBoundingBox": { + "height": 7.961260318756104, + "width": 17.820064544677734, + "x": 13040.6630859375, + "y": 1179.640625 + }, + "absoluteRenderBounds": { + "height": 7.9613037109375, + "width": 17.8203125, + "x": 13040.6630859375, + "y": 1179.640625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9647058844566344 + }, + "position": 0.029999999329447743 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9176470637321472, + "g": 0.9529411792755128, + "r": 0.7686274647712708 + }, + "position": 0.119999997317791 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.8549019694328308, + "g": 0.8941176533699036, + "r": 0.5607843399047852 + }, + "position": 0.3499999940395355 + }, + { + "boundVariables": {}, + "color": { + "a": 0.8999999761581421, + "b": 0.8274509906768799, + "g": 0.8156862854957581, + "r": 0.2705882489681244 + }, + "position": 0.6200000047683716 + }, + { + "boundVariables": {}, + "color": { + "a": 0.6000000238418579, + "b": 0.8039215803146362, + "g": 0.7607843279838562, + "r": 0.14901961386203766 + }, + "position": 0.8600000143051147 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.772549033164978, + "g": 0.7215686440467834, + "r": 0.11372549086809158 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.9895601272583008, + -0.06226864457130432, + 0.0533602200448513 + ], + [ + 0.13806335628032684, + 0.446306437253952, + 0.05430806800723076 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 7.961260318756104, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "orbit-inner-tail", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 17.820064544677734, + "x": 3.663325548171997, + "y": 6.6406145095825195 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9041": { + "id": "I45:2080;44:5183;136:9041", + "type": "GROUP", + "fields": { + "parentId": "I45:2080;44:5183", + "childrenIds": [ + "I45:2080;44:5183;136:9042", + "I45:2080;44:5183;136:9043", + "I45:2080;44:5183;136:9044", + "I45:2080;44:5183;136:9045", + "I45:2080;44:5183;136:9046", + "I45:2080;44:5183;136:9047", + "I45:2080;44:5183;136:9048", + "I45:2080;44:5183;136:9049", + "I45:2080;44:5183;136:9050", + "I45:2080;44:5183;136:9051" + ], + "absoluteBoundingBox": { + "height": 18.926769256591797, + "width": 158.8823699951172, + "x": 13078.044921875, + "y": 1179.313720703125 + }, + "absoluteRenderBounds": { + "height": 18.926769256591797, + "width": 158.8828125, + "x": 13078.044921875, + "y": 1179.313720703125 + }, + "height": 18.926769256591797, + "inferredAutoLayout": { + "counterAxisAlignItems": "MAX", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 2.128551483154297, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 2.288818359375e-05, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 7.62939453125e-06, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "ChondroGen", + "opacity": 1, + "visible": true, + "width": 158.8823699951172, + "x": 41.044921875, + "y": 6.313720703125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9042": { + "id": "I45:2080;44:5183;136:9042", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9041", + "absoluteBoundingBox": { + "height": 13.591012001037598, + "width": 13.36479377746582, + "x": 13223.5625, + "y": 1184.397705078125 + }, + "absoluteRenderBounds": { + "height": 13.591064453125, + "width": 13.365234375, + "x": 13223.5625, + "y": 1184.397705078125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.6980392336845398, + "g": 0.6117647290229797, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 13.591012001037598, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 13.36479377746582, + "x": 186.56251525878903, + "y": 11.397735595703123 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9043": { + "id": "I45:2080;44:5183;136:9043", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9041", + "absoluteBoundingBox": { + "height": 13.76719093322754, + "width": 13.87295913696289, + "x": 13207.146484375, + "y": 1184.397705078125 + }, + "absoluteRenderBounds": { + "height": 13.7672119140625, + "width": 13.873046875, + "x": 13207.146484375, + "y": 1184.397705078125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.6980392336845398, + "g": 0.6117647290229797, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 13.76719093322754, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 13.87295913696289, + "x": 170.14695739746094, + "y": 11.397735595703123 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9044": { + "id": "I45:2080;44:5183;136:9044", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9041", + "absoluteBoundingBox": { + "height": 18.121349334716797, + "width": 16.769514083862305, + "x": 13188.1806640625, + "y": 1180.119140625 + }, + "absoluteRenderBounds": { + "height": 18.121337890625, + "width": 16.76953125, + "x": 13188.1806640625, + "y": 1180.119140625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.6980392336845398, + "g": 0.6117647290229797, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 18.121349334716797, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 16.769514083862305, + "x": 151.18064880371094, + "y": 7.119117736816406 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9045": { + "id": "I45:2080;44:5183;136:9045", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9041", + "absoluteBoundingBox": { + "height": 13.76719093322754, + "width": 14.457345008850098, + "x": 13172.16015625, + "y": 1184.397705078125 + }, + "absoluteRenderBounds": { + "height": 13.7672119140625, + "width": 14.45703125, + "x": 13172.16015625, + "y": 1184.397705078125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 13.76719093322754, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 14.457345008850098, + "x": 135.15988159179688, + "y": 11.397735595703123 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9046": { + "id": "I45:2080;44:5183;136:9046", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9041", + "absoluteBoundingBox": { + "height": 13.591012001037598, + "width": 7.851175785064697, + "x": 13163.3642578125, + "y": 1184.397705078125 + }, + "absoluteRenderBounds": { + "height": 13.591064453125, + "width": 7.8515625, + "x": 13163.3642578125, + "y": 1184.397705078125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 13.591012001037598, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 7.851175785064697, + "x": 126.36461639404295, + "y": 11.397735595703123 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9047": { + "id": "I45:2080;44:5183;136:9047", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9041", + "absoluteBoundingBox": { + "height": 18.85123634338379, + "width": 14.381123542785645, + "x": 13145.3115234375, + "y": 1179.313720703125 + }, + "absoluteRenderBounds": { + "height": 18.8511962890625, + "width": 14.380859375, + "x": 13145.3115234375, + "y": 1179.313720703125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 18.85123634338379, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 14.381123542785645, + "x": 108.31141662597656, + "y": 6.313728332519531 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9048": { + "id": "I45:2080;44:5183;136:9048", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9041", + "absoluteBoundingBox": { + "height": 13.591012001037598, + "width": 13.364788055419922, + "x": 13129.5185546875, + "y": 1184.397705078125 + }, + "absoluteRenderBounds": { + "height": 13.591064453125, + "width": 13.365234375, + "x": 13129.5185546875, + "y": 1184.397705078125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 13.591012001037598, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 13.364788055419922, + "x": 92.51893615722656, + "y": 11.397735595703123 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9049": { + "id": "I45:2080;44:5183;136:9049", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9041", + "absoluteBoundingBox": { + "height": 13.76719093322754, + "width": 14.457350730895996, + "x": 13112.5322265625, + "y": 1184.397705078125 + }, + "absoluteRenderBounds": { + "height": 13.7672119140625, + "width": 14.45703125, + "x": 13112.5322265625, + "y": 1184.397705078125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 13.76719093322754, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 14.457350730895996, + "x": 75.53268432617188, + "y": 11.397735595703123 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9050": { + "id": "I45:2080;44:5183;136:9050", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9041", + "absoluteBoundingBox": { + "height": 18.67505645751953, + "width": 13.364792823791504, + "x": 13096.740234375, + "y": 1179.313720703125 + }, + "absoluteRenderBounds": { + "height": 18.675048828125, + "width": 13.365234375, + "x": 13096.740234375, + "y": 1179.313720703125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 18.67505645751953, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 13.364792823791504, + "x": 59.74018859863281, + "y": 6.313728332519531 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5183;136:9051": { + "id": "I45:2080;44:5183;136:9051", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5183;136:9041", + "absoluteBoundingBox": { + "height": 18.121349334716797, + "width": 16.566242218017578, + "x": 13078.044921875, + "y": 1180.119140625 + }, + "absoluteRenderBounds": { + "height": 18.121337890625, + "width": 16.56640625, + "x": 13078.044921875, + "y": 1180.119140625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 18.121349334716797, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 16.566242218017578, + "x": 41.04539489746094, + "y": 7.119117736816406 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5184": { + "id": "I45:2080;44:5184", + "type": "FRAME", + "fields": { + "parentId": "I45:2080;44:5182", + "childrenIds": [ + "I45:2080;44:5185", + "I45:2080;44:5186", + "I45:2080;44:5187", + "I45:2080;44:5188", + "I45:2080;44:5189" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 462, + "x": 13525, + "y": 1165 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 462, + "x": 13525, + "y": 1165 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 40, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 40, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "FILL", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 446", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 462, + "x": 488, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5185": { + "id": "I45:2080;44:5185", + "type": "INSTANCE", + "fields": { + "parentId": "I45:2080;44:5184", + "childrenIds": [ + "I45:2080;44:5185;18:959" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 59, + "x": 13525, + "y": 1165 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 59, + "x": 13525, + "y": 1165 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "FILL", + "maxHeight": null, + "maxWidth": null, + "name": "menu", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 59, + "x": 0, + "y": 0, + "mainComponentId": "19:972", + "mainComponentName": "effect=default", + "mainComponentSetId": "19:973" + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5185;18:959": { + "id": "I45:2080;44:5185;18:959", + "type": "TEXT", + "fields": { + "parentId": "I45:2080;44:5185", + "absoluteBoundingBox": { + "height": 17, + "width": 59, + "x": 13525, + "y": 1180.5 + }, + "absoluteRenderBounds": { + "height": 15.1300048828125, + "width": 56.3564453125, + "x": 13525.6630859375, + "y": 1181.72998046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "기업소개", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "menu", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 59, + "x": 0, + "y": 15.5, + "styledTextSegments": [ + { + "characters": "기업소개", + "end": 4, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5186": { + "id": "I45:2080;44:5186", + "type": "INSTANCE", + "fields": { + "parentId": "I45:2080;44:5184", + "childrenIds": [ + "I45:2080;44:5186;18:959" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 95, + "x": 13624, + "y": 1165 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 95, + "x": 13624, + "y": 1165 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "FILL", + "maxHeight": null, + "maxWidth": null, + "name": "menu", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 95, + "x": 99, + "y": 0, + "mainComponentId": "19:972", + "mainComponentName": "effect=default", + "mainComponentSetId": "19:973" + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5186;18:959": { + "id": "I45:2080;44:5186;18:959", + "type": "TEXT", + "fields": { + "parentId": "I45:2080;44:5186", + "absoluteBoundingBox": { + "height": 17, + "width": 95, + "x": 13624, + "y": 1180.5 + }, + "absoluteRenderBounds": { + "height": 15.1640625, + "width": 93.427734375, + "x": 13624.6630859375, + "y": 1181.72998046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "기술/연구개발", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "menu", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 95, + "x": 0, + "y": 15.5, + "styledTextSegments": [ + { + "characters": "기술/연구개발", + "end": 7, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5187": { + "id": "I45:2080;44:5187", + "type": "INSTANCE", + "fields": { + "parentId": "I45:2080;44:5184", + "childrenIds": [ + "I45:2080;44:5187;18:959" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 59, + "x": 13759, + "y": 1165 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 59, + "x": 13759, + "y": 1165 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "FILL", + "maxHeight": null, + "maxWidth": null, + "name": "menu", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 59, + "x": 234, + "y": 0, + "mainComponentId": "19:972", + "mainComponentName": "effect=default", + "mainComponentSetId": "19:973" + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5187;18:959": { + "id": "I45:2080;44:5187;18:959", + "type": "TEXT", + "fields": { + "parentId": "I45:2080;44:5187", + "absoluteBoundingBox": { + "height": 17, + "width": 59, + "x": 13759, + "y": 1180.5 + }, + "absoluteRenderBounds": { + "height": 15.1300048828125, + "width": 56.7138671875, + "x": 13759.3056640625, + "y": 1181.72998046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "제품소개", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "menu", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 59, + "x": 0, + "y": 15.5, + "styledTextSegments": [ + { + "characters": "제품소개", + "end": 4, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5188": { + "id": "I45:2080;44:5188", + "type": "INSTANCE", + "fields": { + "parentId": "I45:2080;44:5184", + "childrenIds": [ + "I45:2080;44:5188;18:959" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 30, + "x": 13858, + "y": 1165 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 30, + "x": 13858, + "y": 1165 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "FILL", + "maxHeight": null, + "maxWidth": null, + "name": "menu", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 30, + "x": 333, + "y": 0, + "mainComponentId": "19:972", + "mainComponentName": "effect=default", + "mainComponentSetId": "19:973" + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5188;18:959": { + "id": "I45:2080;44:5188;18:959", + "type": "TEXT", + "fields": { + "parentId": "I45:2080;44:5188", + "absoluteBoundingBox": { + "height": 17, + "width": 30, + "x": 13858, + "y": 1180.5 + }, + "absoluteRenderBounds": { + "height": 15.1300048828125, + "width": 26.99609375, + "x": 13858.578125, + "y": 1181.7469482421875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "소식", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "menu", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 30, + "x": 0, + "y": 15.5, + "styledTextSegments": [ + { + "characters": "소식", + "end": 2, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5189": { + "id": "I45:2080;44:5189", + "type": "INSTANCE", + "fields": { + "parentId": "I45:2080;44:5184", + "childrenIds": [ + "I45:2080;44:5189;18:959" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 59, + "x": 13928, + "y": 1165 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 59, + "x": 13928, + "y": 1165 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "FILL", + "maxHeight": null, + "maxWidth": null, + "name": "menu", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 59, + "x": 403, + "y": 0, + "mainComponentId": "19:972", + "mainComponentName": "effect=default", + "mainComponentSetId": "19:973" + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5189;18:959": { + "id": "I45:2080;44:5189;18:959", + "type": "TEXT", + "fields": { + "parentId": "I45:2080;44:5189", + "absoluteBoundingBox": { + "height": 17, + "width": 59, + "x": 13928, + "y": 1180.5 + }, + "absoluteRenderBounds": { + "height": 15.1810302734375, + "width": 56.0849609375, + "x": 13928.560546875, + "y": 1181.7130126953125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "문의하기", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "menu", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 59, + "x": 0, + "y": 15.5, + "styledTextSegments": [ + { + "characters": "문의하기", + "end": 4, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5190": { + "id": "I45:2080;44:5190", + "type": "FRAME", + "fields": { + "parentId": "I45:2080;44:5182", + "childrenIds": [ + "I45:2080;44:5191", + "I45:2080;44:5192" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 170, + "x": 14275, + "y": 1165 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 170, + "x": 14275, + "y": 1165 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MAX", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 448", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MAX", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 170, + "x": 1238, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5191": { + "id": "I45:2080;44:5191", + "type": "INSTANCE", + "fields": { + "parentId": "I45:2080;44:5190", + "childrenIds": [ + "I45:2080;44:5191;20:1333", + "I45:2080;44:5191;20:1321" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 48, + "x": 14309, + "y": 1165 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "text#20:1": { + "type": "BOOLEAN", + "value": false + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 12, + "paddingRight": 12, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "iconButton", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 12, + "paddingLeft": 12, + "paddingRight": 12, + "paddingTop": 12, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 100, + "topRightRadius": 100, + "variantProperties": { + "effect": "default" + }, + "visible": false, + "width": 48, + "x": 34, + "y": 0, + "mainComponentId": "20:1348", + "mainComponentName": "effect=default", + "mainComponentSetId": "20:1349" + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5191;20:1333": { + "id": "I45:2080;44:5191;20:1333", + "type": "INSTANCE", + "fields": { + "parentId": "I45:2080;44:5191", + "childrenIds": [ + "I45:2080;44:5191;20:1333;40:437" + ], + "absoluteBoundingBox": { + "height": 24, + "width": 24, + "x": 14321, + "y": 1177 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 24, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 2.2495806217193604, + "paddingLeft": 2.229278326034546, + "paddingRight": 2.2495806217193604, + "paddingTop": 2.229278326034546, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "MagnifyingGlass", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 24, + "y": 24 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 24, + "x": 12, + "y": 12, + "mainComponentId": "40:438", + "mainComponentName": "MagnifyingGlass", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5191;20:1333;40:437": { + "id": "I45:2080;44:5191;20:1333;40:437", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5191;20:1333", + "absoluteBoundingBox": { + "height": 19.521141052246097, + "width": 19.521141052246097, + "x": 14323.2294921875, + "y": 1179.229248046875 + }, + "absoluteRenderBounds": null, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 19.521141052246097, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 0.09375, + "visible": true, + "width": 19.521141052246097, + "x": 2.229278326034546, + "y": 2.229278326034546 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5191;20:1321": { + "id": "I45:2080;44:5191;20:1321", + "type": "TEXT", + "fields": { + "parentId": "I45:2080;44:5191", + "absoluteBoundingBox": { + "height": 17, + "width": 37, + "x": 14353, + "y": 1177 + }, + "absoluteRenderBounds": null, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "KOR", + "componentPropertyReferences": { + "visible": "text#20:1" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "KOR", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": false, + "width": 37, + "x": 44, + "y": 12, + "styledTextSegments": [ + { + "characters": "KOR", + "end": 3, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5192": { + "id": "I45:2080;44:5192", + "type": "INSTANCE", + "fields": { + "parentId": "I45:2080;44:5190", + "childrenIds": [ + "I45:2080;44:5192;20:1333", + "I45:2080;44:5192;20:1321" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 88, + "x": 14357, + "y": 1165 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 88, + "x": 14357, + "y": 1165 + }, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "text#20:1": { + "type": "BOOLEAN", + "value": true + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 12, + "paddingRight": 12, + "paddingTop": 12, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "iconButton", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 12, + "paddingLeft": 12, + "paddingRight": 12, + "paddingTop": 12, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 100, + "topRightRadius": 100, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 88, + "x": 82, + "y": 0, + "mainComponentId": "20:1348", + "mainComponentName": "effect=default", + "mainComponentSetId": "20:1349" + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5192;20:1333": { + "id": "I45:2080;44:5192;20:1333", + "type": "INSTANCE", + "fields": { + "parentId": "I45:2080;44:5192", + "childrenIds": [ + "I45:2080;44:5192;20:1333;40:440" + ], + "absoluteBoundingBox": { + "height": 24, + "width": 24, + "x": 14369, + "y": 1177 + }, + "absoluteRenderBounds": { + "height": 24, + "width": 24, + "x": 14369, + "y": 1177 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 24, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Globe", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 24, + "y": 24 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 24, + "x": 12, + "y": 12, + "mainComponentId": "40:441", + "mainComponentName": "Globe", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5192;20:1333;40:440": { + "id": "I45:2080;44:5192;20:1333;40:440", + "type": "VECTOR", + "fields": { + "parentId": "I45:2080;44:5192;20:1333", + "absoluteBoundingBox": { + "height": 19.500003814697266, + "width": 19.500003814697266, + "x": 14371.25, + "y": 1179.25 + }, + "absoluteRenderBounds": { + "height": 19.5, + "width": 19.5, + "x": 14371.25, + "y": 1179.25 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 19.500003814697266, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 0.09375, + "visible": true, + "width": 19.500003814697266, + "x": 2.249995708465576, + "y": 2.25 + }, + "extra": {}, + "fieldErrors": {} + }, + "I45:2080;44:5192;20:1321": { + "id": "I45:2080;44:5192;20:1321", + "type": "TEXT", + "fields": { + "parentId": "I45:2080;44:5192", + "absoluteBoundingBox": { + "height": 23, + "width": 32, + "x": 14401, + "y": 1177.5 + }, + "absoluteRenderBounds": { + "height": 11.3099365234375, + "width": 29.6357421875, + "x": 14402.2001953125, + "y": 1183.4300537109375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "KOR", + "componentPropertyReferences": { + "visible": "text#20:1" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "KOR", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:01da56e122aa148e08cea720f74dbe1f2d759cdb,", + "textTruncation": "DISABLED", + "visible": true, + "width": 32, + "x": 44, + "y": 12.5, + "styledTextSegments": [ + { + "characters": "KOR", + "end": 3, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:01da56e122aa148e08cea720f74dbe1f2d759cdb," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "51:6610": { + "id": "51:6610", + "type": "INSTANCE", + "fields": { + "parentId": "51:6599", + "childrenIds": [ + "I51:6610;44:5194", + "I51:6610;44:5196" + ], + "absoluteBoundingBox": { + "height": 64, + "width": 360, + "x": 12611, + "y": 1149 + }, + "absoluteRenderBounds": { + "height": 64, + "width": 360, + "x": 12611, + "y": 1149 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "pageTop" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Mobile" + } + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 64, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "ABSOLUTE", + "paddingBottom": 0, + "paddingLeft": 12, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "header", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "51:6610", + "overriddenFields": [ + "layoutPositioning" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 12, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "type": "pageTop", + "viewport": "Mobile" + }, + "visible": true, + "width": 360, + "x": 0, + "y": 0, + "mainComponentId": "44:5193", + "mainComponentName": "type=pageTop, viewport=Mobile", + "mainComponentSetId": "23:1575" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5194": { + "id": "I51:6610;44:5194", + "type": "FRAME", + "fields": { + "parentId": "51:6610", + "childrenIds": [ + "I51:6610;44:5195" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 261, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 261, + "x": 12623, + "y": 1167 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "SPACE_BETWEEN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 450", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "SPACE_BETWEEN", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 261, + "x": 12, + "y": 18 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195": { + "id": "I51:6610;44:5195", + "type": "INSTANCE", + "fields": { + "parentId": "I51:6610;44:5194", + "childrenIds": [ + "I51:6610;44:5195;136:9027", + "I51:6610;44:5195;136:9028", + "I51:6610;44:5195;136:9041" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 173, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 173, + "x": 12623, + "y": 1167 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "기본" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 28, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Logo", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 1102, + "y": 178 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "기본" + }, + "visible": true, + "width": 173, + "x": 0, + "y": 0, + "mainComponentId": "37:217", + "mainComponentName": "속성 1=기본", + "mainComponentSetId": "136:19763" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9027": { + "id": "I51:6610;44:5195;136:9027", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195", + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.943359375, + "x": 12623, + "y": 1167 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.7490196228027344, + "g": 0.5607843399047852, + "r": 0.03921568766236305 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.6823529601097107, + "g": 0.5058823823928833, + "r": 0.007843137718737125 + }, + "position": 0.30000001192092896 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.5607843399047852, + "g": 0.3764705955982208, + "r": 0.003921568859368563 + }, + "position": 0.44999998807907104 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.46666666865348816, + "g": 0.2823529541492462, + "r": 0 + }, + "position": 0.6100000143051147 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.40784314274787903, + "g": 0.22745098173618317, + "r": 0 + }, + "position": 0.7599999904632568 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.3843137323856354, + "g": 0.2078431397676468, + "r": 0.003921568859368563 + }, + "position": 0.9100000262260436 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.3843137323856354, + "g": 0.2117647081613541, + "r": 0.003921568859368563 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.6454866528511047, + 0, + 0.31524205207824707 + ], + [ + 0, + 0.6454866528511047, + 0.204462930560112 + ] + ], + "opacity": 1, + "type": "GRADIENT_RADIAL", + "visible": true + } + ], + "height": 28, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "sphere", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9028": { + "id": "I51:6610;44:5195;136:9028", + "type": "GROUP", + "fields": { + "parentId": "I51:6610;44:5195", + "childrenIds": [ + "I51:6610;44:5195;136:9029", + "I51:6610;44:5195;136:9031" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "height": 28, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Clip path group", + "opacity": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9029": { + "id": "I51:6610;44:5195;136:9029", + "type": "GROUP", + "fields": { + "parentId": "I51:6610;44:5195;136:9028", + "childrenIds": [ + "I51:6610;44:5195;136:9030" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": null, + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isMask": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "sphere-clip", + "opacity": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9030": { + "id": "I51:6610;44:5195;136:9030", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9029", + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.943359375, + "x": 12623, + "y": 1167 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0, + "g": 0, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9031": { + "id": "I51:6610;44:5195;136:9031", + "type": "GROUP", + "fields": { + "parentId": "I51:6610;44:5195;136:9028", + "childrenIds": [ + "I51:6610;44:5195;136:9032", + "I51:6610;44:5195;136:9033", + "I51:6610;44:5195;136:9034", + "I51:6610;44:5195;136:9035", + "I51:6610;44:5195;136:9036", + "I51:6610;44:5195;136:9037", + "I51:6610;44:5195;136:9038", + "I51:6610;44:5195;136:9039", + "I51:6610;44:5195;136:9040" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.943359375, + "x": 12623, + "y": 1167 + }, + "height": 28, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9032": { + "id": "I51:6610;44:5195;136:9032", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.943359375, + "x": 12623, + "y": 1167 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 0.41999998688697815, + "b": 0.3333333432674408, + "g": 0.16470588743686676, + "r": 0 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 0.20000000298023224, + "b": 0.3333333432674408, + "g": 0.16470588743686676, + "r": 0 + }, + "position": 0.550000011920929 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.3333333432674408, + "g": 0.16470588743686676, + "r": 0 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 1.252997875213623, + 0, + 0.08783221244812012 + ], + [ + 0, + 1.252997875213623, + -0.7154324054718018 + ] + ], + "opacity": 1, + "type": "GRADIENT_RADIAL", + "visible": true + } + ], + "height": 28, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "shade-outside-orbit", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9033": { + "id": "I51:6610;44:5195;136:9033", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 28, + "width": 27.94373893737793, + "x": 12623, + "y": 1167 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 27.943359375, + "x": 12623, + "y": 1167 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 0.14000000059604645, + "b": 0.8470588326454163, + "g": 0.6509804129600525, + "r": 0.10588235408067705 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.8470588326454163, + "g": 0.6509804129600525, + "r": 0.10588235408067705 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 1.9364598989486697, + 0, + 0.468456894159317 + ], + [ + 0, + 1.9364598989486697, + -0.5502269268035889 + ] + ], + "opacity": 1, + "type": "GRADIENT_RADIAL", + "visible": true + } + ], + "height": 28, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "light-left-overlay", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 27.94373893737793, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9034": { + "id": "I51:6610;44:5195;136:9034", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 5.863326072692871, + "width": 11.39635181427002, + "x": 12638.0908203125, + "y": 1186.9595947265625 + }, + "absoluteRenderBounds": { + "height": 5.86328125, + "width": 11.396484375, + "x": 12638.0908203125, + "y": 1186.9595947265625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 0.550000011920929, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 0.30000001192092896 + }, + { + "boundVariables": {}, + "color": { + "a": 0.5, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 0.75 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.5764706134796143, + "g": 0.3529411852359772, + "r": 0.04313725605607033 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.8151227831840515, + -0.2092042565345764, + 0.19614684581756592 + ], + [ + 0.4074420928955078, + 0.41853103041648865, + 0.08879981189966202 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 5.863326072692871, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "rim-highlight (Stroke)", + "opacity": 1, + "strokeAlign": "CENTER", + "strokeWeight": 3.849999904632568, + "visible": true, + "width": 11.39635181427002, + "x": 15.09042739868164, + "y": 19.95957946777344 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9035": { + "id": "I51:6610;44:5195;136:9035", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 15.99054718017578, + "width": 15.946592330932615, + "x": 12626.1982421875, + "y": 1172.7916259765625 + }, + "absoluteRenderBounds": { + "height": 15.9906005859375, + "width": 15.9462890625, + "x": 12626.1982421875, + "y": 1172.7916259765625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.8156862854957581, + "g": 0.7843137383460999, + "r": 0.03921568766236305 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.5882353186607361, + "g": 0.4313725531101227, + "r": 0 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.8613659739494324, + 0.45280781388282776, + -0.1629999577999115 + ], + [ + -0.4524722993373871, + 0.8620046973228455, + 0.31514695286750793 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 15.99054718017578, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "core", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 15.946592330932615, + "x": 3.1983132362365723, + "y": 5.791618347167969 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9036": { + "id": "I51:6610;44:5195;136:9036", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 9.51343822479248, + "width": 9.494321823120115, + "x": 12628.9990234375, + "y": 1176.053466796875 + }, + "absoluteRenderBounds": { + "height": 9.513427734375, + "width": 9.494140625, + "x": 12628.9990234375, + "y": 1176.053466796875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.8745098114013672, + "g": 0.9333333373069764, + "r": 0.6705882549285889 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.7607843279838562, + "g": 0.7215686440467834, + "r": 0.0235294122248888 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.8080573678016663, + 0.5874120593070984, + -0.19792257249355316 + ], + [ + -0.5874120593070984, + 0.8080573678016663, + 0.3895654380321502 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 9.51343822479248, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "ring", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 9.494321823120115, + "x": 5.998625755310059, + "y": 9.053515434265137 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9037": { + "id": "I51:6610;44:5195;136:9037", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 5.172343730926514, + "width": 5.161951065063477, + "x": 12631.072265625, + "y": 1178.131591796875 + }, + "absoluteRenderBounds": { + "height": 5.17236328125, + "width": 5.162109375, + "x": 12631.072265625, + "y": 1178.131591796875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.6196078658103943, + "g": 0.4745098054409027, + "r": 0.007843137718737125 + }, + "position": 0 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.545098066329956, + "g": 0.3764705955982208, + "r": 0.003921568859368563 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.9339293241500854, + 0.3536636531352997, + -0.14337609708309174 + ], + [ + -0.3536636531352997, + 0.9339293241500854, + 0.2099337726831436 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 5.172343730926514, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "ring-center", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 5.161951065063477, + "x": 8.072575569152832, + "y": 11.131641387939451 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9038": { + "id": "I51:6610;44:5195;136:9038", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 22.848167419433594, + "width": 23.82176399230957, + "x": 12623.0361328125, + "y": 1169.5443115234375 + }, + "absoluteRenderBounds": { + "height": 22.84814453125, + "width": 23.8212890625, + "x": 12623.0361328125, + "y": 1169.5443115234375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9843137264251708, + "g": 0.9764705896377563, + "r": 0.9607843160629272 + }, + "position": 0.1899999976158142 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9803921580314636, + "g": 0.91372549533844, + "r": 0.7764706015586853 + }, + "position": 0.3799999952316284 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9725490212440492, + "g": 0.8588235378265381, + "r": 0.6039215922355652 + }, + "position": 0.5 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.95686274766922, + "g": 0.7843137383460999, + "r": 0.3843137323856354 + }, + "position": 0.6600000262260437 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9333333373069764, + "g": 0.729411780834198, + "r": 0.21960784494876864 + }, + "position": 0.8399999737739563 + } + ], + "gradientTransform": [ + [ + -1.6364001035690308, + 1.5055831670761108, + -0.08484271168708801 + ], + [ + -1.5728986263275146, + -1.5663667917251587, + 2.0555288791656494 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 22.848167419433594, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "orbit-outer (Stroke)", + "opacity": 1, + "strokeAlign": "CENTER", + "strokeWeight": 5.050000190734863, + "visible": true, + "width": 23.82176399230957, + "x": 0.036175258457660675, + "y": 2.5443549156188965 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9039": { + "id": "I51:6610;44:5195;136:9039", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 13.113574981689451, + "width": 23.719501495361328, + "x": 12625.98828125, + "y": 1175.99658203125 + }, + "absoluteRenderBounds": { + "height": 13.113525390625, + "width": 23.7197265625, + "x": 12625.98828125, + "y": 1175.99658203125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9647058844566344 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 13.113574981689451, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "orbit-inner", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 23.719501495361328, + "x": 2.9880521297454834, + "y": 8.9966402053833 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9040": { + "id": "I51:6610;44:5195;136:9040", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9031", + "absoluteBoundingBox": { + "height": 6.9661030769348145, + "width": 15.414355278015137, + "x": 12626.1689453125, + "y": 1172.810546875 + }, + "absoluteRenderBounds": { + "height": 6.966064453125, + "width": 15.4140625, + "x": 12626.1689453125, + "y": 1172.810546875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "gradientStops": [ + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9647058844566344 + }, + "position": 0.029999999329447743 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.9176470637321472, + "g": 0.9529411792755128, + "r": 0.7686274647712708 + }, + "position": 0.119999997317791 + }, + { + "boundVariables": {}, + "color": { + "a": 1, + "b": 0.8549019694328308, + "g": 0.8941176533699036, + "r": 0.5607843399047852 + }, + "position": 0.3499999940395355 + }, + { + "boundVariables": {}, + "color": { + "a": 0.8999999761581421, + "b": 0.8274509906768799, + "g": 0.8156862854957581, + "r": 0.2705882489681244 + }, + "position": 0.6200000047683716 + }, + { + "boundVariables": {}, + "color": { + "a": 0.6000000238418579, + "b": 0.8039215803146362, + "g": 0.7607843279838562, + "r": 0.14901961386203766 + }, + "position": 0.8600000143051147 + }, + { + "boundVariables": {}, + "color": { + "a": 0, + "b": 0.772549033164978, + "g": 0.7215686440467834, + "r": 0.11372549086809158 + }, + "position": 1 + } + ], + "gradientTransform": [ + [ + 0.9895601272583008, + -0.06226864457130432, + 0.0533602200448513 + ], + [ + 0.13806335628032684, + 0.446306437253952, + 0.05430806800723076 + ] + ], + "opacity": 1, + "type": "GRADIENT_LINEAR", + "visible": true + } + ], + "height": 6.9661030769348145, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "orbit-inner-tail", + "opacity": 1, + "strokeWeight": 1, + "visible": true, + "width": 15.414355278015137, + "x": 3.168776512145996, + "y": 5.810537815093994 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9041": { + "id": "I51:6610;44:5195;136:9041", + "type": "GROUP", + "fields": { + "parentId": "I51:6610;44:5195", + "childrenIds": [ + "I51:6610;44:5195;136:9042", + "I51:6610;44:5195;136:9043", + "I51:6610;44:5195;136:9044", + "I51:6610;44:5195;136:9045", + "I51:6610;44:5195;136:9046", + "I51:6610;44:5195;136:9047", + "I51:6610;44:5195;136:9048", + "I51:6610;44:5195;136:9049", + "I51:6610;44:5195;136:9050", + "I51:6610;44:5195;136:9051" + ], + "absoluteBoundingBox": { + "height": 16.56101417541504, + "width": 137.43359375, + "x": 12658.50390625, + "y": 1172.5244140625 + }, + "absoluteRenderBounds": { + "height": 16.56103515625, + "width": 137.43359375, + "x": 12658.50390625, + "y": 1172.5244140625 + }, + "height": 16.56101417541504, + "inferredAutoLayout": { + "counterAxisAlignItems": "MAX", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 1.8411893844604492, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 1.9073486328125e-05, + "paddingLeft": 0.0003662109375, + "paddingRight": 0.00037384033203125, + "paddingTop": 9.918212890625e-05, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "ChondroGen", + "opacity": 1, + "visible": true, + "width": 137.43359375, + "x": 35.50390625, + "y": 5.5244140625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9042": { + "id": "I51:6610;44:5195;136:9042", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 11.892135620117188, + "width": 11.560546875, + "x": 12784.376953125, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 11.89208984375, + "width": 11.560546875, + "x": 12784.376953125, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.6980392336845398, + "g": 0.6117647290229797, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 11.892135620117188, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 11.560546875, + "x": 161.37657928466797, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9043": { + "id": "I51:6610;44:5195;136:9043", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 12.046292304992676, + "width": 12.000109672546388, + "x": 12770.1767578125, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 12.0462646484375, + "width": 12, + "x": 12770.1767578125, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.6980392336845398, + "g": 0.6117647290229797, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 12.046292304992676, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 12.000109672546388, + "x": 147.1771240234375, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9044": { + "id": "I51:6610;44:5195;136:9044", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 15.85618019104004, + "width": 14.505629539489746, + "x": 12753.771484375, + "y": 1173.229248046875 + }, + "absoluteRenderBounds": { + "height": 15.856201171875, + "width": 14.505859375, + "x": 12753.771484375, + "y": 1173.229248046875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.6980392336845398, + "g": 0.6117647290229797, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 15.85618019104004, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 14.505629539489746, + "x": 130.7712631225586, + "y": 6.229228973388672 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9045": { + "id": "I51:6610;44:5195;136:9045", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 12.046292304992676, + "width": 12.505603790283203, + "x": 12739.9130859375, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 12.0462646484375, + "width": 12.505859375, + "x": 12739.9130859375, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 12.046292304992676, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 12.505603790283203, + "x": 116.91329956054688, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9046": { + "id": "I51:6610;44:5195;136:9046", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 11.892135620117188, + "width": 6.791267395019531, + "x": 12732.3056640625, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 11.89208984375, + "width": 6.791015625, + "x": 12732.3056640625, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 11.892135620117188, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 6.791267395019531, + "x": 109.3053970336914, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9047": { + "id": "I51:6610;44:5195;136:9047", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 16.494831085205078, + "width": 12.439671516418455, + "x": 12716.689453125, + "y": 1172.5245361328125 + }, + "absoluteRenderBounds": { + "height": 16.494873046875, + "width": 12.439453125, + "x": 12716.689453125, + "y": 1172.5245361328125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 16.494831085205078, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 12.439671516418455, + "x": 93.68937683105467, + "y": 5.524513244628906 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9048": { + "id": "I51:6610;44:5195;136:9048", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 11.892135620117188, + "width": 11.560542106628418, + "x": 12703.029296875, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 11.89208984375, + "width": 11.560546875, + "x": 12703.029296875, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 11.892135620117188, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 11.560542106628418, + "x": 80.02886962890625, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9049": { + "id": "I51:6610;44:5195;136:9049", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 12.046292304992676, + "width": 12.505608558654783, + "x": 12688.3359375, + "y": 1176.9730224609375 + }, + "absoluteRenderBounds": { + "height": 12.0462646484375, + "width": 12.505859375, + "x": 12688.3359375, + "y": 1176.9730224609375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 12.046292304992676, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 12.505608558654783, + "x": 65.33576965332031, + "y": 9.973018646240234 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9050": { + "id": "I51:6610;44:5195;136:9050", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 16.340675354003906, + "width": 11.560545921325684, + "x": 12674.6748046875, + "y": 1172.5245361328125 + }, + "absoluteRenderBounds": { + "height": 16.3406982421875, + "width": 11.560546875, + "x": 12674.6748046875, + "y": 1172.5245361328125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 16.340675354003906, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 11.560545921325684, + "x": 51.675262451171875, + "y": 5.524513244628906 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5195;136:9051": { + "id": "I51:6610;44:5195;136:9051", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5195;136:9041", + "absoluteBoundingBox": { + "height": 15.85618019104004, + "width": 14.329800605773926, + "x": 12658.50390625, + "y": 1173.229248046875 + }, + "absoluteRenderBounds": { + "height": 15.856201171875, + "width": 14.330078125, + "x": 12658.50390625, + "y": 1173.229248046875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.3803921639919281, + "g": 0.2078431397676468, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 15.85618019104004, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "visible": true, + "width": 14.329800605773926, + "x": 35.5042724609375, + "y": 6.229228973388672 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5196": { + "id": "I51:6610;44:5196", + "type": "FRAME", + "fields": { + "parentId": "51:6610", + "childrenIds": [ + "I51:6610;90:10324", + "I51:6610;44:5197" + ], + "absoluteBoundingBox": { + "height": 64, + "width": 58, + "x": 12913, + "y": 1149 + }, + "absoluteRenderBounds": { + "height": 64, + "width": 58, + "x": 12913, + "y": 1149 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 64, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 12, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 12, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "FILL", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 498", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 58, + "x": 302, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;90:10324": { + "id": "I51:6610;90:10324", + "type": "INSTANCE", + "fields": { + "parentId": "I51:6610;44:5196", + "childrenIds": [ + "I51:6610;90:10324;40:437" + ], + "absoluteBoundingBox": { + "height": 26, + "width": 26, + "x": 12929, + "y": 1168 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 26, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 2.437045097351074, + "paddingLeft": 2.4150514602661133, + "paddingRight": 2.437045097351074, + "paddingTop": 2.4150514602661133, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "MagnifyingGlass", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 24, + "y": 24 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": false, + "width": 26, + "x": 16, + "y": 19, + "mainComponentId": "40:438", + "mainComponentName": "MagnifyingGlass", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;90:10324;40:437": { + "id": "I51:6610;90:10324;40:437", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;90:10324", + "absoluteBoundingBox": { + "height": 21.147903442382812, + "width": 21.147903442382812, + "x": 12931.4150390625, + "y": 1170.4150390625 + }, + "absoluteRenderBounds": null, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 21.147903442382812, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 0.09375, + "visible": true, + "width": 21.147903442382812, + "x": 2.4150514602661133, + "y": 2.4150514602661133 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5197": { + "id": "I51:6610;44:5197", + "type": "INSTANCE", + "fields": { + "parentId": "I51:6610;44:5196", + "childrenIds": [ + "I51:6610;44:5197;43:1138" + ], + "absoluteBoundingBox": { + "height": 26, + "width": 26, + "x": 12929, + "y": 1168 + }, + "absoluteRenderBounds": { + "height": 26, + "width": 26, + "x": 12929, + "y": 1168 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 26, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 5.6875, + "paddingLeft": 3.25, + "paddingRight": 3.25, + "paddingTop": 5.6875, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "List", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "targetAspectRatio": { + "x": 24, + "y": 24 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 26, + "x": 16, + "y": 19, + "mainComponentId": "43:1139", + "mainComponentName": "List", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6610;44:5197;43:1138": { + "id": "I51:6610;44:5197;43:1138", + "type": "VECTOR", + "fields": { + "parentId": "I51:6610;44:5197", + "absoluteBoundingBox": { + "height": 14.625, + "width": 19.5, + "x": 12932.25, + "y": 1173.6875 + }, + "absoluteRenderBounds": { + "height": 14.625, + "width": 19.5, + "x": 12932.25, + "y": 1173.6875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0, + "g": 0, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 14.625, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "strokeWeight": 0.09375, + "visible": true, + "width": 19.5, + "x": 3.25, + "y": 5.6875 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/home-news-mixed-strokes.json b/crates/devup-mcp-devup-ui/tests/fixtures/home-news-mixed-strokes.json new file mode 100644 index 00000000..d5c2dbd4 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/home-news-mixed-strokes.json @@ -0,0 +1,3673 @@ +{ + "fileKey": "captured-home-news-mixed-stroke-regression", + "roots": [ + "desktop", + "mobile" + ], + "nodes": { + "desktop": { + "id": "desktop", + "type": "FRAME", + "fields": { + "name": "desktop", + "width": 1440, + "height": 800, + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "paddingTop": 0, + "paddingBottom": 0, + "paddingLeft": 80, + "paddingRight": 80 + }, + "childrenIds": [ + "desktop-column" + ] + } + }, + "desktop-column": { + "id": "desktop-column", + "type": "FRAME", + "fields": { + "name": "news rows", + "parentId": "desktop", + "width": 600, + "height": 200, + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "itemSpacing": 0, + "paddingTop": 0, + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0 + }, + "childrenIds": [ + "51:5838" + ] + } + }, + "51:5838": { + "id": "51:5838", + "type": "INSTANCE", + "fields": { + "parentId": "desktop-column", + "childrenIds": [ + "I51:5838;32:369", + "I51:5838;32:220", + "I51:5838;32:221" + ], + "absoluteBoundingBox": { + "height": 77, + "width": 600, + "x": 13101, + "y": 6121.74072265625 + }, + "absoluteRenderBounds": { + "height": 77, + "width": 600, + "x": 13101, + "y": 6121.74072265625 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "itemSpacing": { + "id": "VariableID:30:615", + "type": "VARIABLE_ALIAS" + }, + "paddingBottom": { + "id": "VariableID:30:608", + "type": "VARIABLE_ALIAS" + }, + "paddingTop": { + "id": "VariableID:30:608", + "type": "VARIABLE_ALIAS" + }, + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "notice#32:1": { + "type": "BOOLEAN", + "value": true + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 77, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 12, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 24, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 24, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 12, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "newsCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "51:5838", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I51:5838;32:220", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I51:5838;32:221", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I51:5838;32:369", + "overriddenFields": [ + "fills" + ] + } + ], + "paddingBottom": 24, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 24, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 64 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": false, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 600, + "x": 0, + "y": 64, + "mainComponentId": "32:249", + "mainComponentName": "effect=default", + "mainComponentSetId": "32:339" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:369": { + "id": "I51:5838;32:369", + "type": "INSTANCE", + "fields": { + "parentId": "51:5838", + "childrenIds": [ + "I51:5838;32:369;73:6826", + "I51:5838;32:369;32:362" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 59, + "x": 13109, + "y": 6146.24072265625 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 59, + "x": 13109, + "y": 6146.24072265625 + }, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:346", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "visible": "notice#32:1" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:346", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5686274766921997, + "g": 0.5137255191802979, + "r": 0.0235294122248888 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "noticeTag", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I51:5838;32:369", + "overriddenFields": [ + "fills" + ] + } + ], + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 24.5 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 100, + "topRightRadius": 100, + "visible": true, + "width": 59, + "x": 8, + "y": 24.5, + "mainComponentId": "32:368", + "mainComponentName": "noticeTag", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:369;73:6826": { + "id": "I51:5838;32:369;73:6826", + "type": "INSTANCE", + "fields": { + "parentId": "I51:5838;32:369", + "childrenIds": [ + "I51:5838;32:369;73:6826;73:6818" + ], + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 13117, + "y": 6154.24072265625 + }, + "absoluteRenderBounds": { + "height": 12, + "width": 12, + "x": 13117, + "y": 6154.24072265625 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 12, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "megaphone", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 512, + "y": 512 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 8, + "y": 8, + "mainComponentId": "73:6825", + "mainComponentName": "megaphone", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:369;73:6826;73:6818": { + "id": "I51:5838;32:369;73:6826;73:6818", + "type": "GROUP", + "fields": { + "parentId": "I51:5838;32:369;73:6826", + "childrenIds": [ + "I51:5838;32:369;73:6826;73:6819", + "I51:5838;32:369;73:6826;73:6821", + "I51:5838;32:369;73:6826;73:6823" + ], + "absoluteBoundingBox": { + "height": 10.999811172485352, + "width": 11.000475883483888, + "x": 13117.5, + "y": 6154.74072265625 + }, + "absoluteRenderBounds": { + "height": 11, + "width": 11.000475883483888, + "x": 13117.5, + "y": 6154.74072265625 + }, + "height": 10.999811172485352, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 11.000475883483888, + "x": 0.5, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:369;73:6826;73:6819": { + "id": "I51:5838;32:369;73:6826;73:6819", + "type": "GROUP", + "fields": { + "parentId": "I51:5838;32:369;73:6826;73:6818", + "childrenIds": [ + "I51:5838;32:369;73:6826;73:6820" + ], + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 13117.5, + "y": 6155.39990234375 + }, + "absoluteRenderBounds": { + "height": 7.18310546875, + "width": 8.283529281616211, + "x": 13117.5, + "y": 6155.39990234375 + }, + "height": 7.182965278625488, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:369;73:6826;73:6820": { + "id": "I51:5838;32:369;73:6826;73:6820", + "type": "VECTOR", + "fields": { + "parentId": "I51:5838;32:369;73:6826;73:6819", + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 13117.5, + "y": 6155.39990234375 + }, + "absoluteRenderBounds": { + "height": 7.18310546875, + "width": 8.283203125, + "x": 13117.5, + "y": 6155.39990234375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.182965278625488, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:369;73:6826;73:6821": { + "id": "I51:5838;32:369;73:6826;73:6821", + "type": "GROUP", + "fields": { + "parentId": "I51:5838;32:369;73:6826;73:6818", + "childrenIds": [ + "I51:5838;32:369;73:6826;73:6822" + ], + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 13118.4052734375, + "y": 6161.2294921875 + }, + "absoluteRenderBounds": { + "height": 4.51123046875, + "width": 3.78228759765625, + "x": 13118.4052734375, + "y": 6161.2294921875 + }, + "height": 4.511041164398193, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:369;73:6826;73:6822": { + "id": "I51:5838;32:369;73:6826;73:6822", + "type": "VECTOR", + "fields": { + "parentId": "I51:5838;32:369;73:6826;73:6821", + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 13118.4052734375, + "y": 6161.2294921875 + }, + "absoluteRenderBounds": { + "height": 4.51123046875, + "width": 3.7822265625, + "x": 13118.4052734375, + "y": 6161.2294921875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4.511041164398193, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:369;73:6826;73:6823": { + "id": "I51:5838;32:369;73:6826;73:6823", + "type": "GROUP", + "fields": { + "parentId": "I51:5838;32:369;73:6826;73:6818", + "childrenIds": [ + "I51:5838;32:369;73:6826;73:6824" + ], + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 13126.48828125, + "y": 6154.74072265625 + }, + "absoluteRenderBounds": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 13126.48828125, + "y": 6154.74072265625 + }, + "height": 8.501578330993652, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:369;73:6826;73:6824": { + "id": "I51:5838;32:369;73:6826;73:6824", + "type": "VECTOR", + "fields": { + "parentId": "I51:5838;32:369;73:6826;73:6823", + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 13126.48828125, + "y": 6154.74072265625 + }, + "absoluteRenderBounds": { + "height": 8.50146484375, + "width": 2.01171875, + "x": 13126.48828125, + "y": 6154.74072265625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 8.501578330993652, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:369;32:362": { + "id": "I51:5838;32:369;32:362", + "type": "TEXT", + "fields": { + "parentId": "I51:5838;32:369", + "absoluteBoundingBox": { + "height": 20, + "width": 23, + "x": 13135, + "y": 6150.24072265625 + }, + "absoluteRenderBounds": { + "height": 11.21923828125, + "width": 21.697265625, + "x": 13135.3896484375, + "y": 6155.1396484375 + }, + "characters": "\uc911\uc694", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "\uc911\uc694", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 26 + ], + [ + 0, + 1, + 4 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259,", + "textTruncation": "DISABLED", + "visible": true, + "width": 23, + "x": 26, + "y": 4, + "styledTextSegments": [ + { + "characters": "\uc911\uc694", + "end": 2, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:220": { + "id": "I51:5838;32:220", + "type": "TEXT", + "fields": { + "parentId": "51:5838", + "absoluteBoundingBox": { + "height": 29, + "width": 424, + "x": 13180, + "y": 6145.74072265625 + }, + "absoluteRenderBounds": { + "height": 15.7861328125, + "width": 245.1279296875, + "x": 13180.666015625, + "y": 6152.28662109375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:951", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "\uc0c1\ub2e8 \uace0\uc815\ub41c \ub274\uc2a4 \uc81c\ubaa9\uc774 \ub4e4\uc5b4\uac11\ub2c8\ub2e4", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 18, + "height": 29, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 160.0000023841858 + }, + "maxHeight": null, + "maxLines": 1, + "maxWidth": null, + "name": "\uc81c\ubaa9 1\uc904", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 79 + ], + [ + 0, + 1, + 24 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:cfcbc71ecd9536404e6fe4dfeb971ce6261df4c3,", + "textTruncation": "ENDING", + "visible": true, + "width": 424, + "x": 79, + "y": 24, + "styledTextSegments": [ + { + "characters": "\uc0c1\ub2e8 \uace0\uc815\ub41c \ub274\uc2a4 \uc81c\ubaa9\uc774 \ub4e4\uc5b4\uac11\ub2c8\ub2e4", + "end": 19, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:cfcbc71ecd9536404e6fe4dfeb971ce6261df4c3," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5838;32:221": { + "id": "I51:5838;32:221", + "type": "TEXT", + "fields": { + "parentId": "51:5838", + "absoluteBoundingBox": { + "height": 21, + "width": 77, + "x": 13616, + "y": 6149.74072265625 + }, + "absoluteRenderBounds": { + "height": 10.416015625, + "width": 75.181640625, + "x": 13616.7841796875, + "y": 6155.5068359375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2026. 09. 20", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "2026. 10. 08", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 515 + ], + [ + 0, + 1, + 28 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 77, + "x": 515, + "y": 28, + "styledTextSegments": [ + { + "characters": "2026. 09. 20", + "end": 12, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "mobile": { + "id": "mobile", + "type": "FRAME", + "fields": { + "name": "mobile", + "width": 360, + "height": 800, + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "paddingTop": 0, + "paddingBottom": 0, + "paddingLeft": 20, + "paddingRight": 20 + }, + "childrenIds": [ + "mobile-column" + ] + } + }, + "mobile-column": { + "id": "mobile-column", + "type": "FRAME", + "fields": { + "name": "news rows", + "parentId": "mobile", + "width": 320, + "height": 200, + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "itemSpacing": 0, + "paddingTop": 0, + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0 + }, + "childrenIds": [ + "51:6703", + "51:6704" + ] + } + }, + "51:6703": { + "id": "51:6703", + "type": "INSTANCE", + "fields": { + "parentId": "mobile-column", + "childrenIds": [ + "I51:6703;32:369", + "I51:6703;32:220", + "I51:6703;32:221" + ], + "absoluteBoundingBox": { + "height": 68, + "width": 320, + "x": 12631, + "y": 6859.037109375 + }, + "absoluteRenderBounds": { + "height": 68, + "width": 320, + "x": 12631, + "y": 6859.037109375 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "itemSpacing": { + "id": "VariableID:30:615", + "type": "VARIABLE_ALIAS" + }, + "paddingBottom": { + "id": "VariableID:30:608", + "type": "VARIABLE_ALIAS" + }, + "paddingTop": { + "id": "VariableID:30:608", + "type": "VARIABLE_ALIAS" + }, + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "notice#32:1": { + "type": "BOOLEAN", + "value": true + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 68, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 20, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 20, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "newsCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "51:6703", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I51:6703;32:220", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I51:6703;32:221", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I51:6703;32:369", + "overriddenFields": [ + "fills" + ] + } + ], + "paddingBottom": 20, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 20, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 54 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": false, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 54, + "mainComponentId": "32:249", + "mainComponentName": "effect=default", + "mainComponentSetId": "32:339" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:369": { + "id": "I51:6703;32:369", + "type": "INSTANCE", + "fields": { + "parentId": "51:6703", + "childrenIds": [ + "I51:6703;32:369;73:6826", + "I51:6703;32:369;32:362" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 59, + "x": 12639, + "y": 6879.037109375 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 59, + "x": 12639, + "y": 6879.037109375 + }, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:346", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "visible": "notice#32:1" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:346", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5686274766921997, + "g": 0.5137255191802979, + "r": 0.0235294122248888 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "noticeTag", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I51:6703;32:369", + "overriddenFields": [ + "fills" + ] + } + ], + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 20 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 100, + "topRightRadius": 100, + "visible": true, + "width": 59, + "x": 8, + "y": 20, + "mainComponentId": "32:368", + "mainComponentName": "noticeTag", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:369;73:6826": { + "id": "I51:6703;32:369;73:6826", + "type": "INSTANCE", + "fields": { + "parentId": "I51:6703;32:369", + "childrenIds": [ + "I51:6703;32:369;73:6826;73:6818" + ], + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 12647, + "y": 6887.037109375 + }, + "absoluteRenderBounds": { + "height": 12, + "width": 12, + "x": 12647, + "y": 6887.037109375 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 12, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0.5001888275146484, + "paddingLeft": 0.5, + "paddingRight": 0.4995241165161133, + "paddingTop": 0.5, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "megaphone", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 512, + "y": 512 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 8, + "y": 8, + "mainComponentId": "73:6825", + "mainComponentName": "megaphone", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:369;73:6826;73:6818": { + "id": "I51:6703;32:369;73:6826;73:6818", + "type": "GROUP", + "fields": { + "parentId": "I51:6703;32:369;73:6826", + "childrenIds": [ + "I51:6703;32:369;73:6826;73:6819", + "I51:6703;32:369;73:6826;73:6821", + "I51:6703;32:369;73:6826;73:6823" + ], + "absoluteBoundingBox": { + "height": 10.999811172485352, + "width": 11.000475883483888, + "x": 12647.5, + "y": 6887.537109375 + }, + "absoluteRenderBounds": { + "height": 11, + "width": 11.000475883483888, + "x": 12647.5, + "y": 6887.537109375 + }, + "height": 10.999811172485352, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 11.000475883483888, + "x": 0.5, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:369;73:6826;73:6819": { + "id": "I51:6703;32:369;73:6826;73:6819", + "type": "GROUP", + "fields": { + "parentId": "I51:6703;32:369;73:6826;73:6818", + "childrenIds": [ + "I51:6703;32:369;73:6826;73:6820" + ], + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 12647.5, + "y": 6888.1962890625 + }, + "absoluteRenderBounds": { + "height": 7.18310546875, + "width": 8.283529281616211, + "x": 12647.5, + "y": 6888.1962890625 + }, + "height": 7.182965278625488, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:369;73:6826;73:6820": { + "id": "I51:6703;32:369;73:6826;73:6820", + "type": "VECTOR", + "fields": { + "parentId": "I51:6703;32:369;73:6826;73:6819", + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 12647.5, + "y": 6888.1962890625 + }, + "absoluteRenderBounds": { + "height": 7.18310546875, + "width": 8.283203125, + "x": 12647.5, + "y": 6888.1962890625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.182965278625488, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:369;73:6826;73:6821": { + "id": "I51:6703;32:369;73:6826;73:6821", + "type": "GROUP", + "fields": { + "parentId": "I51:6703;32:369;73:6826;73:6818", + "childrenIds": [ + "I51:6703;32:369;73:6826;73:6822" + ], + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 12648.4052734375, + "y": 6894.02587890625 + }, + "absoluteRenderBounds": { + "height": 4.51123046875, + "width": 3.78228759765625, + "x": 12648.4052734375, + "y": 6894.02587890625 + }, + "height": 4.511041164398193, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:369;73:6826;73:6822": { + "id": "I51:6703;32:369;73:6826;73:6822", + "type": "VECTOR", + "fields": { + "parentId": "I51:6703;32:369;73:6826;73:6821", + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 12648.4052734375, + "y": 6894.02587890625 + }, + "absoluteRenderBounds": { + "height": 4.51123046875, + "width": 3.7822265625, + "x": 12648.4052734375, + "y": 6894.02587890625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4.511041164398193, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:369;73:6826;73:6823": { + "id": "I51:6703;32:369;73:6826;73:6823", + "type": "GROUP", + "fields": { + "parentId": "I51:6703;32:369;73:6826;73:6818", + "childrenIds": [ + "I51:6703;32:369;73:6826;73:6824" + ], + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 12656.48828125, + "y": 6887.537109375 + }, + "absoluteRenderBounds": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 12656.48828125, + "y": 6887.537109375 + }, + "height": 8.501578330993652, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:369;73:6826;73:6824": { + "id": "I51:6703;32:369;73:6826;73:6824", + "type": "VECTOR", + "fields": { + "parentId": "I51:6703;32:369;73:6826;73:6823", + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 12656.48828125, + "y": 6887.537109375 + }, + "absoluteRenderBounds": { + "height": 8.50146484375, + "width": 2.01171875, + "x": 12656.48828125, + "y": 6887.537109375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 8.501578330993652, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:369;32:362": { + "id": "I51:6703;32:369;32:362", + "type": "TEXT", + "fields": { + "parentId": "I51:6703;32:369", + "absoluteBoundingBox": { + "height": 20, + "width": 23, + "x": 12665, + "y": 6883.037109375 + }, + "absoluteRenderBounds": { + "height": 11.21923828125, + "width": 21.697265625, + "x": 12665.3896484375, + "y": 6887.93603515625 + }, + "characters": "\uc911\uc694", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "\uc911\uc694", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 26 + ], + [ + 0, + 1, + 4 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259,", + "textTruncation": "DISABLED", + "visible": true, + "width": 23, + "x": 26, + "y": 4, + "styledTextSegments": [ + { + "characters": "\uc911\uc694", + "end": 2, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:220": { + "id": "I51:6703;32:220", + "type": "TEXT", + "fields": { + "parentId": "51:6703", + "absoluteBoundingBox": { + "height": 26, + "width": 157, + "x": 12706, + "y": 6880.037109375 + }, + "absoluteRenderBounds": { + "height": 14.0322265625, + "width": 143.578125, + "x": 12706.591796875, + "y": 6886.18896484375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:951", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "\uc0c1\ub2e8 \uace0\uc815\ub41c \ub274\uc2a4 \uc81c\ubaa9\uc774 \ub4e4\uc5b4\uac11\ub2c8\ub2e4", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 26, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 160.0000023841858 + }, + "maxHeight": null, + "maxLines": 1, + "maxWidth": null, + "name": "\uc81c\ubaa9 1\uc904", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 75 + ], + [ + 0, + 1, + 21 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:cfcbc71ecd9536404e6fe4dfeb971ce6261df4c3,", + "textTruncation": "ENDING", + "visible": true, + "width": 157, + "x": 75, + "y": 21, + "styledTextSegments": [ + { + "characters": "\uc0c1\ub2e8 \uace0\uc815\ub41c \ub274\uc2a4 \uc81c\ubaa9\uc774 \ub4e4\uc5b4\uac11\ub2c8\ub2e4", + "end": 19, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:cfcbc71ecd9536404e6fe4dfeb971ce6261df4c3," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6703;32:221": { + "id": "I51:6703;32:221", + "type": "TEXT", + "fields": { + "parentId": "51:6703", + "absoluteBoundingBox": { + "height": 20, + "width": 72, + "x": 12871, + "y": 6883.037109375 + }, + "absoluteRenderBounds": { + "height": 9.671875, + "width": 69.8115234375, + "x": 12871.7275390625, + "y": 6888.5341796875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2026. 09. 20", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "2026. 10. 08", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 240 + ], + [ + 0, + 1, + 24 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 72, + "x": 240, + "y": 24, + "styledTextSegments": [ + { + "characters": "2026. 09. 20", + "end": 12, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "51:6704": { + "id": "51:6704", + "type": "INSTANCE", + "fields": { + "parentId": "mobile-column", + "childrenIds": [ + "I51:6704;32:369", + "I51:6704;32:220", + "I51:6704;32:221" + ], + "absoluteBoundingBox": { + "height": 66, + "width": 320, + "x": 12631, + "y": 6927.037109375 + }, + "absoluteRenderBounds": { + "height": 66, + "width": 320, + "x": 12631, + "y": 6927.037109375 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "itemSpacing": { + "id": "VariableID:30:615", + "type": "VARIABLE_ALIAS" + }, + "paddingBottom": { + "id": "VariableID:30:608", + "type": "VARIABLE_ALIAS" + }, + "paddingTop": { + "id": "VariableID:30:608", + "type": "VARIABLE_ALIAS" + }, + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "notice#32:1": { + "type": "BOOLEAN", + "value": false + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 66, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 20, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 20, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "newsCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "51:6704", + "overriddenFields": [ + "height", + "width" + ] + }, + { + "id": "I51:6704;32:220", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I51:6704;32:221", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 20, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 20, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 122 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": false, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 122, + "mainComponentId": "32:249", + "mainComponentName": "effect=default", + "mainComponentSetId": "32:339" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:369": { + "id": "I51:6704;32:369", + "type": "INSTANCE", + "fields": { + "parentId": "51:6704", + "childrenIds": [ + "I51:6704;32:369;73:6826", + "I51:6704;32:369;32:362" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 59, + "x": 12639, + "y": 6951.537109375 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "visible": "notice#32:1" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "noticeTag", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 24.5 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 100, + "topRightRadius": 100, + "visible": false, + "width": 59, + "x": 8, + "y": 24.5, + "mainComponentId": "32:368", + "mainComponentName": "noticeTag", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:369;73:6826": { + "id": "I51:6704;32:369;73:6826", + "type": "INSTANCE", + "fields": { + "parentId": "I51:6704;32:369", + "childrenIds": [ + "I51:6704;32:369;73:6826;73:6818" + ], + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 12647, + "y": 6959.537109375 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 12, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0.5001888275146484, + "paddingLeft": 0.5, + "paddingRight": 0.4995241165161133, + "paddingTop": 0.5, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "megaphone", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 512, + "y": 512 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 8, + "y": 8, + "mainComponentId": "73:6825", + "mainComponentName": "megaphone", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:369;73:6826;73:6818": { + "id": "I51:6704;32:369;73:6826;73:6818", + "type": "GROUP", + "fields": { + "parentId": "I51:6704;32:369;73:6826", + "childrenIds": [ + "I51:6704;32:369;73:6826;73:6819", + "I51:6704;32:369;73:6826;73:6821", + "I51:6704;32:369;73:6826;73:6823" + ], + "absoluteBoundingBox": { + "height": 10.999811172485352, + "width": 11.000475883483888, + "x": 12647.5, + "y": 6960.037109375 + }, + "absoluteRenderBounds": null, + "height": 10.999811172485352, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 11.000475883483888, + "x": 0.5, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:369;73:6826;73:6819": { + "id": "I51:6704;32:369;73:6826;73:6819", + "type": "GROUP", + "fields": { + "parentId": "I51:6704;32:369;73:6826;73:6818", + "childrenIds": [ + "I51:6704;32:369;73:6826;73:6820" + ], + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 12647.5, + "y": 6960.6962890625 + }, + "absoluteRenderBounds": null, + "height": 7.182965278625488, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:369;73:6826;73:6820": { + "id": "I51:6704;32:369;73:6826;73:6820", + "type": "VECTOR", + "fields": { + "parentId": "I51:6704;32:369;73:6826;73:6819", + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 12647.5, + "y": 6960.6962890625 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.182965278625488, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:369;73:6826;73:6821": { + "id": "I51:6704;32:369;73:6826;73:6821", + "type": "GROUP", + "fields": { + "parentId": "I51:6704;32:369;73:6826;73:6818", + "childrenIds": [ + "I51:6704;32:369;73:6826;73:6822" + ], + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 12648.4052734375, + "y": 6966.52587890625 + }, + "absoluteRenderBounds": null, + "height": 4.511041164398193, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:369;73:6826;73:6822": { + "id": "I51:6704;32:369;73:6826;73:6822", + "type": "VECTOR", + "fields": { + "parentId": "I51:6704;32:369;73:6826;73:6821", + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 12648.4052734375, + "y": 6966.52587890625 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4.511041164398193, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:369;73:6826;73:6823": { + "id": "I51:6704;32:369;73:6826;73:6823", + "type": "GROUP", + "fields": { + "parentId": "I51:6704;32:369;73:6826;73:6818", + "childrenIds": [ + "I51:6704;32:369;73:6826;73:6824" + ], + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 12656.48828125, + "y": 6960.037109375 + }, + "absoluteRenderBounds": null, + "height": 8.501578330993652, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:369;73:6826;73:6824": { + "id": "I51:6704;32:369;73:6826;73:6824", + "type": "VECTOR", + "fields": { + "parentId": "I51:6704;32:369;73:6826;73:6823", + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 12656.48828125, + "y": 6960.037109375 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 8.501578330993652, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:369;32:362": { + "id": "I51:6704;32:369;32:362", + "type": "TEXT", + "fields": { + "parentId": "I51:6704;32:369", + "absoluteBoundingBox": { + "height": 20, + "width": 23, + "x": 12665, + "y": 6955.537109375 + }, + "absoluteRenderBounds": null, + "characters": "\uc911\uc694", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "\uc911\uc694", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 26 + ], + [ + 0, + 1, + 4 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259,", + "textTruncation": "DISABLED", + "visible": true, + "width": 23, + "x": 26, + "y": 4, + "styledTextSegments": [ + { + "characters": "\uc911\uc694", + "end": 2, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:220": { + "id": "I51:6704;32:220", + "type": "TEXT", + "fields": { + "parentId": "51:6704", + "absoluteBoundingBox": { + "height": 26, + "width": 227, + "x": 12639, + "y": 6947.037109375 + }, + "absoluteRenderBounds": { + "height": 14.0322265625, + "width": 173.4189453125, + "x": 12639.7041015625, + "y": 6953.18896484375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:951", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "\ucd5c\uc2e0 \ub274\uc2a4 \uc81c\ubaa9\uc774 \ub4e4\uc5b4\uac11\ub2c8\ub2e4", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 26, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 160.0000023841858 + }, + "maxHeight": null, + "maxLines": 1, + "maxWidth": null, + "name": "\uc81c\ubaa9 1\uc904", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 20 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:cfcbc71ecd9536404e6fe4dfeb971ce6261df4c3,", + "textTruncation": "ENDING", + "visible": true, + "width": 227, + "x": 8, + "y": 20, + "styledTextSegments": [ + { + "characters": "\ucd5c\uc2e0 \ub274\uc2a4 \uc81c\ubaa9\uc774 \ub4e4\uc5b4\uac11\ub2c8\ub2e4", + "end": 15, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:cfcbc71ecd9536404e6fe4dfeb971ce6261df4c3," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6704;32:221": { + "id": "I51:6704;32:221", + "type": "TEXT", + "fields": { + "parentId": "51:6704", + "absoluteBoundingBox": { + "height": 20, + "width": 69, + "x": 12874, + "y": 6950.037109375 + }, + "absoluteRenderBounds": { + "height": 9.671875, + "width": 67.412109375, + "x": 12874.7275390625, + "y": 6955.5341796875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2026. 09. 10", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "2026. 10. 08", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 243 + ], + [ + 0, + 1, + 23 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 69, + "x": 243, + "y": 23, + "styledTextSegments": [ + { + "characters": "2026. 09. 10", + "end": 12, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/home-product-cta-defaults.json b/crates/devup-mcp-devup-ui/tests/fixtures/home-product-cta-defaults.json new file mode 100644 index 00000000..7d930699 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/home-product-cta-defaults.json @@ -0,0 +1,2447 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "45:2066", + "51:6599" + ], + "nodes": { + "51:5685": { + "id": "51:5685", + "type": "FRAME", + "fields": { + "parentId": "45:2066", + "childrenIds": [ + "51:5686" + ], + "absoluteBoundingBox": { + "height": 64, + "width": 257, + "x": 13765, + "y": 4455.74072265625 + }, + "absoluteRenderBounds": { + "height": 64, + "width": 257, + "x": 13765, + "y": 4455.74072265625 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 64, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 16, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "cta", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 16, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 435 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 257, + "x": 0, + "y": 435 + }, + "extra": {}, + "fieldErrors": {} + }, + "51:5686": { + "id": "51:5686", + "type": "INSTANCE", + "fields": { + "parentId": "51:5685", + "childrenIds": [ + "I51:5686;24:1854", + "I51:5686;24:1855" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 257, + "x": 13765, + "y": 4471.74072265625 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 257, + "x": 13765, + "y": 4471.74072265625 + }, + "bottomLeftRadius": 12, + "bottomRightRadius": 12, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "rightIcon#23:2": { + "type": "BOOLEAN", + "value": true + }, + "size": { + "boundVariables": {}, + "type": "VARIANT", + "value": "md" + }, + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "primary" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 12, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 12, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "button", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "51:5686", + "overriddenFields": [ + "counterAxisSizingMode", + "height", + "width" + ] + }, + { + "id": "I51:5686;24:1854", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 12, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 12, + "topRightRadius": 12, + "variantProperties": { + "effect": "default", + "size": "md", + "type": "primary" + }, + "visible": true, + "width": 257, + "x": 0, + "y": 16, + "mainComponentId": "24:1853", + "mainComponentName": "effect=default, type=primary, size=md", + "mainComponentSetId": "23:1613" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5686;24:1854": { + "id": "I51:5686;24:1854", + "type": "TEXT", + "fields": { + "parentId": "51:5686", + "absoluteBoundingBox": { + "height": 24, + "width": 185, + "x": 13789, + "y": 4483.74072265625 + }, + "absoluteRenderBounds": { + "height": 14.2880859375, + "width": 182.4677734375, + "x": 13789.5595703125, + "y": 4488.7646484375 + }, + "boundVariables": { + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "콘드로스페로이드 자세히 보기", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 16, + "height": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "버튼명", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 24 + ], + [ + 0, + 1, + 12 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:cbc02b7831f02c3b00e92d3114823bdda982464e,", + "textTruncation": "DISABLED", + "visible": true, + "width": 185, + "x": 24, + "y": 12, + "styledTextSegments": [ + { + "characters": "콘드로스페로이드 자세히 보기", + "end": 15, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:cbc02b7831f02c3b00e92d3114823bdda982464e," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5686;24:1855": { + "id": "I51:5686;24:1855", + "type": "INSTANCE", + "fields": { + "parentId": "51:5686", + "childrenIds": [ + "I51:5686;24:1855;23:1618" + ], + "absoluteBoundingBox": { + "height": 16, + "width": 16, + "x": 13982, + "y": 4487.74072265625 + }, + "absoluteRenderBounds": { + "height": 16, + "width": 16, + "x": 13982, + "y": 4487.74072265625 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Right" + } + }, + "componentPropertyReferences": { + "visible": "rightIcon#23:2" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 16, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "icon", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 217 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeBottomWeight": 0.125, + "strokeLeftWeight": 0.125, + "strokeRightWeight": 0.125, + "strokeTopWeight": 0.125, + "strokeWeight": 0.125, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 32, + "y": 32 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "Right" + }, + "visible": true, + "width": 16, + "x": 217, + "y": 16, + "mainComponentId": "23:1619", + "mainComponentName": "속성 1=Right", + "mainComponentSetId": "20:1312" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5686;24:1855;23:1618": { + "id": "I51:5686;24:1855;23:1618", + "type": "VECTOR", + "fields": { + "parentId": "I51:5686;24:1855", + "absoluteBoundingBox": { + "height": 10.666666984558104, + "width": 5.333333492279053, + "x": 13988, + "y": 4490.4072265625 + }, + "absoluteRenderBounds": { + "height": 12.2666015625, + "width": 6.93359375, + "x": 13987.2001953125, + "y": 4489.607421875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "height": 10.666666984558104, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 6 + ], + [ + 0, + 1, + 2.6666667461395264 + ] + ], + "strokeAlign": "CENTER", + "strokeWeight": 1.600000023841858, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "visible": true, + "width": 5.333333492279053, + "x": 6, + "y": 2.6666667461395264 + }, + "extra": {}, + "fieldErrors": {} + }, + "51:5704": { + "id": "51:5704", + "type": "FRAME", + "fields": { + "parentId": "45:2066", + "childrenIds": [ + "51:5705" + ], + "absoluteBoundingBox": { + "height": 64, + "width": 247, + "x": 13101, + "y": 5026.74072265625 + }, + "absoluteRenderBounds": { + "height": 64, + "width": 247, + "x": 13101, + "y": 5026.74072265625 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 64, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 16, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "cta", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 16, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 406 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 247, + "x": 0, + "y": 406 + }, + "extra": {}, + "fieldErrors": {} + }, + "51:5705": { + "id": "51:5705", + "type": "INSTANCE", + "fields": { + "parentId": "51:5704", + "childrenIds": [ + "I51:5705;24:1854", + "I51:5705;24:1855" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 247, + "x": 13101, + "y": 5042.74072265625 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 247, + "x": 13101, + "y": 5042.74072265625 + }, + "bottomLeftRadius": 12, + "bottomRightRadius": 12, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:346", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "rightIcon#23:2": { + "type": "BOOLEAN", + "value": true + }, + "size": { + "boundVariables": {}, + "type": "VARIANT", + "value": "md" + }, + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "primary" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 12, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:346", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5686274766921997, + "g": 0.5137255191802979, + "r": 0.0235294122248888 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 12, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "button", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "51:5705", + "overriddenFields": [ + "counterAxisSizingMode", + "fills", + "height", + "width" + ] + }, + { + "id": "I51:5705;24:1854", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 12, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 12, + "topRightRadius": 12, + "variantProperties": { + "effect": "default", + "size": "md", + "type": "primary" + }, + "visible": true, + "width": 247, + "x": 0, + "y": 16, + "mainComponentId": "24:1853", + "mainComponentName": "effect=default, type=primary, size=md", + "mainComponentSetId": "23:1613" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5705;24:1854": { + "id": "I51:5705;24:1854", + "type": "TEXT", + "fields": { + "parentId": "51:5705", + "absoluteBoundingBox": { + "height": 24, + "width": 175, + "x": 13125, + "y": 5054.74072265625 + }, + "absoluteRenderBounds": { + "height": 14.2880859375, + "width": 171.9462890625, + "x": 13125.7841796875, + "y": 5059.7646484375 + }, + "boundVariables": { + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "인체모델 시스템 자세히 보기", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 16, + "height": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "버튼명", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 24 + ], + [ + 0, + 1, + 12 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:cbc02b7831f02c3b00e92d3114823bdda982464e,", + "textTruncation": "DISABLED", + "visible": true, + "width": 175, + "x": 24, + "y": 12, + "styledTextSegments": [ + { + "characters": "인체모델 시스템 자세히 보기", + "end": 15, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:cbc02b7831f02c3b00e92d3114823bdda982464e," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5705;24:1855": { + "id": "I51:5705;24:1855", + "type": "INSTANCE", + "fields": { + "parentId": "51:5705", + "childrenIds": [ + "I51:5705;24:1855;23:1618" + ], + "absoluteBoundingBox": { + "height": 16, + "width": 16, + "x": 13308, + "y": 5058.74072265625 + }, + "absoluteRenderBounds": { + "height": 16, + "width": 16, + "x": 13308, + "y": 5058.74072265625 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Right" + } + }, + "componentPropertyReferences": { + "visible": "rightIcon#23:2" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 16, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "icon", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 207 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeBottomWeight": 0.125, + "strokeLeftWeight": 0.125, + "strokeRightWeight": 0.125, + "strokeTopWeight": 0.125, + "strokeWeight": 0.125, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 32, + "y": 32 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "Right" + }, + "visible": true, + "width": 16, + "x": 207, + "y": 16, + "mainComponentId": "23:1619", + "mainComponentName": "속성 1=Right", + "mainComponentSetId": "20:1312" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:5705;24:1855;23:1618": { + "id": "I51:5705;24:1855;23:1618", + "type": "VECTOR", + "fields": { + "parentId": "I51:5705;24:1855", + "absoluteBoundingBox": { + "height": 10.666666984558104, + "width": 5.333333492279053, + "x": 13314, + "y": 5061.4072265625 + }, + "absoluteRenderBounds": { + "height": 12.2666015625, + "width": 6.93359375, + "x": 13313.2001953125, + "y": 5060.607421875 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "height": 10.666666984558104, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 6 + ], + [ + 0, + 1, + 2.6666667461395264 + ] + ], + "strokeAlign": "CENTER", + "strokeWeight": 1.600000023841858, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "visible": true, + "width": 5.333333492279053, + "x": 6, + "y": 2.6666667461395264 + }, + "extra": {}, + "fieldErrors": {} + }, + "45:2066": { + "id": "45:2066", + "type": "FRAME", + "fields": { + "parentId": "51:5892", + "parentType": "SECTION", + "parentName": "Main", + "childrenIds": [ + "51:5685", + "51:5704" + ], + "absoluteBoundingBox": { + "height": 5952.74072265625, + "width": 1440, + "x": 13021, + "y": 1149 + }, + "absoluteRenderBounds": { + "height": 5952.74072265625, + "width": 1440, + "x": 13021, + "y": 1149 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5952.74072265625, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Desktop", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 511 + ], + [ + 0, + 1, + 111 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 511, + "y": 111 + }, + "extra": {}, + "fieldErrors": {} + }, + "51:6663": { + "id": "51:6663", + "type": "FRAME", + "fields": { + "parentId": "51:6599", + "childrenIds": [ + "51:6664" + ], + "absoluteBoundingBox": { + "height": 63, + "width": 320, + "x": 12631, + "y": 4896.037109375 + }, + "absoluteRenderBounds": { + "height": 63, + "width": 320, + "x": 12631, + "y": 4896.037109375 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 63, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "cta", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 500 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 500 + }, + "extra": {}, + "fieldErrors": {} + }, + "51:6664": { + "id": "51:6664", + "type": "INSTANCE", + "fields": { + "parentId": "51:6663", + "childrenIds": [ + "I51:6664;24:1854", + "I51:6664;24:1855" + ], + "absoluteBoundingBox": { + "height": 47, + "width": 246, + "x": 12668, + "y": 4912.037109375 + }, + "absoluteRenderBounds": { + "height": 47, + "width": 246, + "x": 12668, + "y": 4912.037109375 + }, + "bottomLeftRadius": 12, + "bottomRightRadius": 12, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "rightIcon#23:2": { + "type": "BOOLEAN", + "value": true + }, + "size": { + "boundVariables": {}, + "type": "VARIANT", + "value": "md" + }, + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "primary" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 12, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 47, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 12, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "button", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "51:6664", + "overriddenFields": [ + "counterAxisSizingMode", + "height", + "width" + ] + }, + { + "id": "I51:6664;24:1854", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 12, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 37 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 12, + "topRightRadius": 12, + "variantProperties": { + "effect": "default", + "size": "md", + "type": "primary" + }, + "visible": true, + "width": 246, + "x": 37, + "y": 16, + "mainComponentId": "24:1853", + "mainComponentName": "effect=default, type=primary, size=md", + "mainComponentSetId": "23:1613" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6664;24:1854": { + "id": "I51:6664;24:1854", + "type": "TEXT", + "fields": { + "parentId": "51:6664", + "absoluteBoundingBox": { + "height": 23, + "width": 174, + "x": 12692, + "y": 4924.037109375 + }, + "absoluteRenderBounds": { + "height": 13.39501953125, + "width": 171.0625, + "x": 12692.525390625, + "y": 4928.8720703125 + }, + "boundVariables": { + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "콘드로스페로이드 자세히 보기", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "버튼명", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 24 + ], + [ + 0, + 1, + 12 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:cbc02b7831f02c3b00e92d3114823bdda982464e,", + "textTruncation": "DISABLED", + "visible": true, + "width": 174, + "x": 24, + "y": 12, + "styledTextSegments": [ + { + "characters": "콘드로스페로이드 자세히 보기", + "end": 15, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:cbc02b7831f02c3b00e92d3114823bdda982464e," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6664;24:1855": { + "id": "I51:6664;24:1855", + "type": "INSTANCE", + "fields": { + "parentId": "51:6664", + "childrenIds": [ + "I51:6664;24:1855;23:1618" + ], + "absoluteBoundingBox": { + "height": 16, + "width": 16, + "x": 12874, + "y": 4927.537109375 + }, + "absoluteRenderBounds": { + "height": 16, + "width": 16, + "x": 12874, + "y": 4927.537109375 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Right" + } + }, + "componentPropertyReferences": { + "visible": "rightIcon#23:2" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 16, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 2.666666269302368, + "paddingLeft": 6, + "paddingRight": 4.666666507720947, + "paddingTop": 2.6666667461395264, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "icon", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 206 + ], + [ + 0, + 1, + 15.5 + ] + ], + "strokeBottomWeight": 0.125, + "strokeLeftWeight": 0.125, + "strokeRightWeight": 0.125, + "strokeTopWeight": 0.125, + "strokeWeight": 0.125, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 32, + "y": 32 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "Right" + }, + "visible": true, + "width": 16, + "x": 206, + "y": 15.5, + "mainComponentId": "23:1619", + "mainComponentName": "속성 1=Right", + "mainComponentSetId": "20:1312" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6664;24:1855;23:1618": { + "id": "I51:6664;24:1855;23:1618", + "type": "VECTOR", + "fields": { + "parentId": "I51:6664;24:1855", + "absoluteBoundingBox": { + "height": 10.666666984558104, + "width": 5.333333492279053, + "x": 12880, + "y": 4930.20361328125 + }, + "absoluteRenderBounds": { + "height": 12.2666015625, + "width": 6.93359375, + "x": 12879.2001953125, + "y": 4929.40380859375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "height": 10.666666984558104, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 6 + ], + [ + 0, + 1, + 2.6666667461395264 + ] + ], + "strokeAlign": "CENTER", + "strokeWeight": 1.600000023841858, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "visible": true, + "width": 5.333333492279053, + "x": 6, + "y": 2.6666667461395264 + }, + "extra": {}, + "fieldErrors": {} + }, + "51:6684": { + "id": "51:6684", + "type": "FRAME", + "fields": { + "parentId": "51:6599", + "childrenIds": [ + "51:6685" + ], + "absoluteBoundingBox": { + "height": 63, + "width": 320, + "x": 12631, + "y": 5753.037109375 + }, + "absoluteRenderBounds": { + "height": 63, + "width": 320, + "x": 12631, + "y": 5753.037109375 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 63, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "cta", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 474 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 474 + }, + "extra": {}, + "fieldErrors": {} + }, + "51:6685": { + "id": "51:6685", + "type": "INSTANCE", + "fields": { + "parentId": "51:6684", + "childrenIds": [ + "I51:6685;24:1854", + "I51:6685;24:1855" + ], + "absoluteBoundingBox": { + "height": 47, + "width": 236, + "x": 12673, + "y": 5769.037109375 + }, + "absoluteRenderBounds": { + "height": 47, + "width": 236, + "x": 12673, + "y": 5769.037109375 + }, + "bottomLeftRadius": 12, + "bottomRightRadius": 12, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:346", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "rightIcon#23:2": { + "type": "BOOLEAN", + "value": true + }, + "size": { + "boundVariables": {}, + "type": "VARIANT", + "value": "md" + }, + "type": { + "boundVariables": {}, + "type": "VARIANT", + "value": "primary" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 12, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:346", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5686274766921997, + "g": 0.5137255191802979, + "r": 0.0235294122248888 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 47, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 12, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "button", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "51:6685", + "overriddenFields": [ + "counterAxisSizingMode", + "fills", + "height", + "width" + ] + }, + { + "id": "I51:6685;24:1854", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 12, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 12, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 42 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 12, + "topRightRadius": 12, + "variantProperties": { + "effect": "default", + "size": "md", + "type": "primary" + }, + "visible": true, + "width": 236, + "x": 42, + "y": 16, + "mainComponentId": "24:1853", + "mainComponentName": "effect=default, type=primary, size=md", + "mainComponentSetId": "23:1613" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6685;24:1854": { + "id": "I51:6685;24:1854", + "type": "TEXT", + "fields": { + "parentId": "51:6685", + "absoluteBoundingBox": { + "height": 23, + "width": 164, + "x": 12697, + "y": 5781.037109375 + }, + "absoluteRenderBounds": { + "height": 13.39501953125, + "width": 161.19921875, + "x": 12697.7353515625, + "y": 5785.8720703125 + }, + "boundVariables": { + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "인체모델 시스템 자세히 보기", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "버튼명", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 24 + ], + [ + 0, + 1, + 12 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:cbc02b7831f02c3b00e92d3114823bdda982464e,", + "textTruncation": "DISABLED", + "visible": true, + "width": 164, + "x": 24, + "y": 12, + "styledTextSegments": [ + { + "characters": "인체모델 시스템 자세히 보기", + "end": 15, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:cbc02b7831f02c3b00e92d3114823bdda982464e," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6685;24:1855": { + "id": "I51:6685;24:1855", + "type": "INSTANCE", + "fields": { + "parentId": "51:6685", + "childrenIds": [ + "I51:6685;24:1855;23:1618" + ], + "absoluteBoundingBox": { + "height": 16, + "width": 16, + "x": 12869, + "y": 5784.537109375 + }, + "absoluteRenderBounds": { + "height": 16, + "width": 16, + "x": 12869, + "y": 5784.537109375 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "componentProperties": { + "속성 1": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Right" + } + }, + "componentPropertyReferences": { + "visible": "rightIcon#23:2" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 16, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 2.666666269302368, + "paddingLeft": 6, + "paddingRight": 4.666666507720947, + "paddingTop": 2.6666667461395264, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "icon", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 196 + ], + [ + 0, + 1, + 15.5 + ] + ], + "strokeBottomWeight": 0.125, + "strokeLeftWeight": 0.125, + "strokeRightWeight": 0.125, + "strokeTopWeight": 0.125, + "strokeWeight": 0.125, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 32, + "y": 32 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "속성 1": "Right" + }, + "visible": true, + "width": 16, + "x": 196, + "y": 15.5, + "mainComponentId": "23:1619", + "mainComponentName": "속성 1=Right", + "mainComponentSetId": "20:1312" + }, + "extra": {}, + "fieldErrors": {} + }, + "I51:6685;24:1855;23:1618": { + "id": "I51:6685;24:1855;23:1618", + "type": "VECTOR", + "fields": { + "parentId": "I51:6685;24:1855", + "absoluteBoundingBox": { + "height": 10.666666984558104, + "width": 5.333333492279053, + "x": 12875, + "y": 5787.20361328125 + }, + "absoluteRenderBounds": { + "height": 12.2666015625, + "width": 6.93359375, + "x": 12874.2001953125, + "y": 5786.40380859375 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "height": 10.666666984558104, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 6 + ], + [ + 0, + 1, + 2.6666667461395264 + ] + ], + "strokeAlign": "CENTER", + "strokeWeight": 1.600000023841858, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "visible": true, + "width": 5.333333492279053, + "x": 6, + "y": 2.6666667461395264 + }, + "extra": {}, + "fieldErrors": {} + }, + "51:6599": { + "id": "51:6599", + "type": "FRAME", + "fields": { + "parentId": "51:5892", + "parentType": "SECTION", + "parentName": "Main", + "childrenIds": [ + "51:6663", + "51:6684" + ], + "absoluteBoundingBox": { + "height": 7164.037109375, + "width": 360, + "x": 12611, + "y": 1149 + }, + "absoluteRenderBounds": { + "height": 7164.037109375, + "width": 360, + "x": 12611, + "y": 1149 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7164.037109375, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Mobile", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 101 + ], + [ + 0, + 1, + 111 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 101, + "y": 111 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/list-filter-mixed-state-axis.json b/crates/devup-mcp-devup-ui/tests/fixtures/list-filter-mixed-state-axis.json new file mode 100644 index 00000000..3b0c619f --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/list-filter-mixed-state-axis.json @@ -0,0 +1 @@ +{"fileKey":"K2kUnxGgvooAJwCQiVTb7g","version":null,"roots":["73:6571"],"nodes":{"73:6520":{"id":"73:6520","type":"TEXT","fields":{"parentId":"73:6570","absoluteBoundingBox":{"height":24,"width":64,"x":8834,"y":8298},"absoluteRenderBounds":{"height":14.287109375,"width":40.16015625,"x":8845.935546875,"y":8303.0244140625},"boundVariables":{"fills":[{"id":"VariableID:9:348","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:18:955","type":"VARIABLE_ALIAS"}]},"characters":"최신순","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:348","type":"VARIABLE_ALIAS"}},"color":{"b":0.3176470696926117,"g":0.250980406999588,"r":0.21568627655506137},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Bold"},"fontSize":16,"height":24,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":150},"maxHeight":null,"maxWidth":null,"name":"최신순","opacity":1,"relativeTransform":[[1,0,12],[0,1,8]],"strokeAlign":"OUTSIDE","strokeWeight":1,"textAlignHorizontal":"CENTER","textAutoResize":"HEIGHT","textStyleId":"S:cbc02b7831f02c3b00e92d3114823bdda982464e,","textTruncation":"DISABLED","visible":true,"width":64,"x":12,"y":8,"styledTextSegments":[{"characters":"최신순","end":3,"fillStyleId":"","fontWeight":700,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:cbc02b7831f02c3b00e92d3114823bdda982464e,"}]},"extra":{},"fieldErrors":{}},"73:6521":{"id":"73:6521","type":"INSTANCE","fields":{"parentId":"73:6570","childrenIds":["I73:6521;40:472"],"absoluteBoundingBox":{"height":12,"width":12,"x":8906,"y":8304},"absoluteRenderBounds":{"height":12,"width":12,"x":8906,"y":8304},"bottomLeftRadius":0,"bottomRightRadius":0,"clipsContent":true,"componentProperties":{"속성 1":{"boundVariables":{},"type":"VARIANT","value":"CaretDown"}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{},"color":{"b":1,"g":1,"r":1},"opacity":1,"type":"SOLID","visible":false}],"height":12,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"FIXED","itemSpacing":0,"layoutAlign":"MIN","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":3.3747053146362305,"paddingLeft":1.8747045993804932,"paddingRight":1.874704122543335,"paddingTop":4.125,"primaryAxisAlignItems":"CENTER","primaryAxisSizingMode":"FIXED"},"isAsset":true,"itemSpacing":0,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"NONE","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED","maxHeight":null,"maxWidth":null,"name":"Caret-fill","opacity":1,"overflowDirection":"NONE","overrides":[{"id":"73:6521","overriddenFields":["height","width"]},{"id":"I73:6521;40:472","overriddenFields":["fills"]}],"paddingBottom":0,"paddingLeft":0,"paddingRight":0,"paddingTop":0,"relativeTransform":[[1,0,84],[0,1,14]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":false,"targetAspectRatio":{"x":24,"y":24},"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"속성 1":"CaretDown"},"visible":true,"width":12,"x":84,"y":14,"mainComponentId":"40:482","mainComponentName":"속성 1=CaretDown","mainComponentSetId":"40:483"},"extra":{},"fieldErrors":{}},"73:6558":{"id":"73:6558","type":"TEXT","fields":{"parentId":"73:6568","absoluteBoundingBox":{"height":24,"width":64,"x":9074,"y":8298},"absoluteRenderBounds":{"height":14.287109375,"width":40.16015625,"x":9085.935546875,"y":8303.0244140625},"boundVariables":{"fills":[{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:18:955","type":"VARIABLE_ALIAS"}]},"characters":"최신순","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}},"color":{"b":0.1529411822557449,"g":0.08627451211214066,"r":0.06666667014360428},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Bold"},"fontSize":16,"height":24,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":150},"maxHeight":null,"maxWidth":null,"name":"최신순","opacity":1,"relativeTransform":[[1,0,12],[0,1,8]],"strokeAlign":"OUTSIDE","strokeWeight":1,"textAlignHorizontal":"CENTER","textAutoResize":"HEIGHT","textStyleId":"S:cbc02b7831f02c3b00e92d3114823bdda982464e,","textTruncation":"DISABLED","visible":true,"width":64,"x":12,"y":8,"styledTextSegments":[{"characters":"최신순","end":3,"fillStyleId":"","fontWeight":700,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:cbc02b7831f02c3b00e92d3114823bdda982464e,"}]},"extra":{},"fieldErrors":{}},"73:6559":{"id":"73:6559","type":"INSTANCE","fields":{"parentId":"73:6568","childrenIds":["I73:6559;40:472"],"absoluteBoundingBox":{"height":12,"width":12,"x":9146,"y":8304},"absoluteRenderBounds":{"height":12,"width":12,"x":9146,"y":8304},"bottomLeftRadius":0,"bottomRightRadius":0,"clipsContent":true,"componentProperties":{"속성 1":{"boundVariables":{},"type":"VARIANT","value":"CaretDown"}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{},"color":{"b":1,"g":1,"r":1},"opacity":1,"type":"SOLID","visible":false}],"height":12,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"FIXED","itemSpacing":0,"layoutAlign":"MIN","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":3.3747053146362305,"paddingLeft":1.8747045993804932,"paddingRight":1.874704122543335,"paddingTop":4.125,"primaryAxisAlignItems":"CENTER","primaryAxisSizingMode":"FIXED"},"isAsset":true,"itemSpacing":0,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"NONE","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED","maxHeight":null,"maxWidth":null,"name":"Caret-fill","opacity":1,"overflowDirection":"NONE","overrides":[{"id":"73:6559","overriddenFields":["height","width"]},{"id":"I73:6559;40:472","overriddenFields":["fills"]}],"paddingBottom":0,"paddingLeft":0,"paddingRight":0,"paddingTop":0,"relativeTransform":[[1,0,84],[0,1,14]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":false,"targetAspectRatio":{"x":24,"y":24},"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"속성 1":"CaretDown"},"visible":true,"width":12,"x":84,"y":14,"mainComponentId":"40:482","mainComponentName":"속성 1=CaretDown","mainComponentSetId":"40:483"},"extra":{},"fieldErrors":{}},"73:6564":{"id":"73:6564","type":"TEXT","fields":{"parentId":"73:6569","absoluteBoundingBox":{"height":24,"width":64,"x":9206,"y":8298},"absoluteRenderBounds":{"height":14.287109375,"width":40.16015625,"x":9217.935546875,"y":8303.0244140625},"boundVariables":{"fills":[{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:18:955","type":"VARIABLE_ALIAS"}]},"characters":"최신순","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}},"color":{"b":0.1529411822557449,"g":0.08627451211214066,"r":0.06666667014360428},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Bold"},"fontSize":16,"height":24,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":150},"maxHeight":null,"maxWidth":null,"name":"최신순","opacity":1,"relativeTransform":[[1,0,12],[0,1,8]],"strokeAlign":"OUTSIDE","strokeWeight":1,"textAlignHorizontal":"CENTER","textAutoResize":"HEIGHT","textStyleId":"S:cbc02b7831f02c3b00e92d3114823bdda982464e,","textTruncation":"DISABLED","visible":true,"width":64,"x":12,"y":8,"styledTextSegments":[{"characters":"최신순","end":3,"fillStyleId":"","fontWeight":700,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:cbc02b7831f02c3b00e92d3114823bdda982464e,"}]},"extra":{},"fieldErrors":{}},"73:6565":{"id":"73:6565","type":"INSTANCE","fields":{"parentId":"73:6569","childrenIds":["I73:6565;40:472"],"absoluteBoundingBox":{"height":12,"width":12,"x":9278,"y":8304},"absoluteRenderBounds":{"height":12,"width":12,"x":9278,"y":8304},"bottomLeftRadius":0,"bottomRightRadius":0,"clipsContent":true,"componentProperties":{"속성 1":{"boundVariables":{},"type":"VARIANT","value":"CaretDown"}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{},"color":{"b":1,"g":1,"r":1},"opacity":1,"type":"SOLID","visible":false}],"height":12,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"FIXED","itemSpacing":0,"layoutAlign":"MIN","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":3.3747053146362305,"paddingLeft":1.8747045993804932,"paddingRight":1.874704122543335,"paddingTop":4.125,"primaryAxisAlignItems":"CENTER","primaryAxisSizingMode":"FIXED"},"isAsset":true,"itemSpacing":0,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"NONE","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED","maxHeight":null,"maxWidth":null,"name":"Caret-fill","opacity":1,"overflowDirection":"NONE","overrides":[{"id":"73:6565","overriddenFields":["height","width"]},{"id":"I73:6565;40:472","overriddenFields":["fills"]}],"paddingBottom":0,"paddingLeft":0,"paddingRight":0,"paddingTop":0,"relativeTransform":[[1,0,84],[0,1,14]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":false,"targetAspectRatio":{"x":24,"y":24},"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"속성 1":"CaretDown"},"visible":true,"width":12,"x":84,"y":14,"mainComponentId":"40:482","mainComponentName":"속성 1=CaretDown","mainComponentSetId":"40:483"},"extra":{},"fieldErrors":{}},"73:6568":{"id":"73:6568","type":"COMPONENT","fields":{"parentId":"73:6571","parentType":"COMPONENT_SET","childrenIds":["73:6558","73:6559"],"absoluteBoundingBox":{"height":40,"width":108,"x":9062,"y":8290},"absoluteRenderBounds":{"height":40,"width":108,"x":9062,"y":8290},"bottomLeftRadius":8,"bottomRightRadius":8,"boundVariables":{"fills":[{"id":"VariableID:11:2","type":"VARIABLE_ALIAS"}]},"constraints":{"horizontal":"MIN","vertical":"MIN"},"cornerRadius":8,"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:11:2","type":"VARIABLE_ALIAS"}},"color":{"b":1,"g":0.9715099334716796,"r":0.9487178325653076},"opacity":1,"type":"SOLID","visible":true}],"height":40,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":8,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":8,"paddingLeft":12,"paddingRight":12,"paddingTop":8,"primaryAxisAlignItems":"MIN","primaryAxisSizingMode":"AUTO"},"itemSpacing":8,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"effect=hover","opacity":1,"overflowDirection":"NONE","paddingBottom":8,"paddingLeft":12,"paddingRight":12,"paddingTop":8,"relativeTransform":[[1,0,261],[0,1,21]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":true,"topLeftRadius":8,"topRightRadius":8,"variantProperties":{"effect":"hover"},"visible":true,"width":108,"x":261,"y":21},"extra":{},"fieldErrors":{}},"73:6569":{"id":"73:6569","type":"COMPONENT","fields":{"parentId":"73:6571","parentType":"COMPONENT_SET","childrenIds":["73:6564","73:6565"],"absoluteBoundingBox":{"height":40,"width":108,"x":9194,"y":8290},"absoluteRenderBounds":{"height":40,"width":108,"x":9194,"y":8290},"bottomLeftRadius":8,"bottomRightRadius":8,"boundVariables":{"fills":[{"id":"VariableID:11:3","type":"VARIABLE_ALIAS"}]},"constraints":{"horizontal":"MIN","vertical":"MIN"},"cornerRadius":8,"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:11:3","type":"VARIABLE_ALIAS"}},"color":{"b":0.9839743375778198,"g":0.9189276099205016,"r":0.8625540137290955},"opacity":1,"type":"SOLID","visible":true}],"height":40,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":8,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":8,"paddingLeft":12,"paddingRight":12,"paddingTop":8,"primaryAxisAlignItems":"MIN","primaryAxisSizingMode":"AUTO"},"itemSpacing":8,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"effect=active","opacity":1,"overflowDirection":"NONE","paddingBottom":8,"paddingLeft":12,"paddingRight":12,"paddingTop":8,"relativeTransform":[[1,0,393],[0,1,21]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":true,"topLeftRadius":8,"topRightRadius":8,"variantProperties":{"effect":"active"},"visible":true,"width":108,"x":393,"y":21},"extra":{},"fieldErrors":{}},"73:6570":{"id":"73:6570","type":"COMPONENT","fields":{"parentId":"73:6571","parentType":"COMPONENT_SET","childrenIds":["73:6520","73:6521"],"absoluteBoundingBox":{"height":40,"width":108,"x":8822,"y":8290},"absoluteRenderBounds":{"height":40,"width":108,"x":8822,"y":8290},"bottomLeftRadius":8,"bottomRightRadius":8,"constraints":{"horizontal":"MIN","vertical":"MIN"},"cornerRadius":8,"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"height":40,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":8,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":8,"paddingLeft":12,"paddingRight":12,"paddingTop":8,"primaryAxisAlignItems":"MIN","primaryAxisSizingMode":"AUTO"},"itemSpacing":8,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"effect=default","opacity":1,"overflowDirection":"NONE","paddingBottom":8,"paddingLeft":12,"paddingRight":12,"paddingTop":8,"relativeTransform":[[1,0,21],[0,1,21]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":true,"topLeftRadius":8,"topRightRadius":8,"variantProperties":{"effect":"default"},"visible":true,"width":108,"x":21,"y":21},"extra":{},"fieldErrors":{}},"73:6571":{"id":"73:6571","type":"COMPONENT_SET","fields":{"parentId":"19:963","parentType":"SECTION","parentName":"Components","childrenIds":["73:6570","73:6699","73:6568","73:6569"],"absoluteBoundingBox":{"height":82,"width":522,"x":8801,"y":8269},"absoluteRenderBounds":{"height":82,"width":522,"x":8801,"y":8269},"bottomLeftRadius":5,"bottomRightRadius":5,"clipsContent":true,"componentPropertyDefinitions":{"effect":{"defaultValue":"default","type":"VARIANT","variantOptions":["default","hover","active","mobile"]}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"cornerRadius":5,"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"dashPattern":[10,5],"defaultVariant":{"$nodeId":"73:6570","$nodeType":"COMPONENT"},"height":82,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":24,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":20,"paddingLeft":20,"paddingRight":20,"paddingTop":20,"primaryAxisAlignItems":"MIN","primaryAxisSizingMode":"AUTO"},"itemSpacing":24,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"ListFilterButton","opacity":1,"overflowDirection":"NONE","paddingBottom":20,"paddingLeft":20,"paddingRight":20,"paddingTop":20,"relativeTransform":[[1,0,131],[0,1,7231]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokes":[{"blendMode":"NORMAL","boundVariables":{},"color":{"b":1,"g":0.27843138575553894,"r":0.5921568870544434},"opacity":1,"type":"SOLID","visible":true}],"strokesIncludedInLayout":true,"topLeftRadius":5,"topRightRadius":5,"visible":true,"width":522,"x":131,"y":7231},"extra":{},"fieldErrors":{}},"73:6699":{"id":"73:6699","type":"COMPONENT","fields":{"parentId":"73:6571","parentType":"COMPONENT_SET","childrenIds":["73:6700","73:6701"],"absoluteBoundingBox":{"height":40,"width":84,"x":8954,"y":8290},"absoluteRenderBounds":{"height":40,"width":84,"x":8954,"y":8290},"bottomLeftRadius":8,"bottomRightRadius":8,"constraints":{"horizontal":"MIN","vertical":"MIN"},"cornerRadius":8,"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"height":40,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":8,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":8,"paddingLeft":0,"paddingRight":0,"paddingTop":8,"primaryAxisAlignItems":"MIN","primaryAxisSizingMode":"AUTO"},"itemSpacing":8,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"effect=mobile","opacity":1,"overflowDirection":"NONE","paddingBottom":8,"paddingLeft":0,"paddingRight":0,"paddingTop":8,"relativeTransform":[[1,0,153],[0,1,21]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":true,"topLeftRadius":8,"topRightRadius":8,"variantProperties":{"effect":"mobile"},"visible":true,"width":84,"x":153,"y":21},"extra":{},"fieldErrors":{}},"73:6700":{"id":"73:6700","type":"TEXT","fields":{"parentId":"73:6699","absoluteBoundingBox":{"height":24,"width":64,"x":8954,"y":8298},"absoluteRenderBounds":{"height":14.287109375,"width":40.1611328125,"x":8954.591796875,"y":8303.0244140625},"boundVariables":{"fills":[{"id":"VariableID:9:348","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:18:955","type":"VARIABLE_ALIAS"}]},"characters":"최신순","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:348","type":"VARIABLE_ALIAS"}},"color":{"b":0.3176470696926117,"g":0.250980406999588,"r":0.21568627655506137},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Bold"},"fontSize":16,"height":24,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":150},"maxHeight":null,"maxWidth":null,"name":"최신순","opacity":1,"relativeTransform":[[1,0,0],[0,1,8]],"strokeAlign":"OUTSIDE","strokeWeight":1,"textAutoResize":"HEIGHT","textStyleId":"S:cbc02b7831f02c3b00e92d3114823bdda982464e,","textTruncation":"DISABLED","visible":true,"width":64,"x":0,"y":8,"styledTextSegments":[{"characters":"최신순","end":3,"fillStyleId":"","fontWeight":700,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:cbc02b7831f02c3b00e92d3114823bdda982464e,"}]},"extra":{},"fieldErrors":{}},"73:6701":{"id":"73:6701","type":"INSTANCE","fields":{"parentId":"73:6699","childrenIds":["I73:6701;40:472"],"absoluteBoundingBox":{"height":12,"width":12,"x":9026,"y":8304},"absoluteRenderBounds":{"height":12,"width":12,"x":9026,"y":8304},"bottomLeftRadius":0,"bottomRightRadius":0,"clipsContent":true,"componentProperties":{"속성 1":{"boundVariables":{},"type":"VARIANT","value":"CaretDown"}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{},"color":{"b":1,"g":1,"r":1},"opacity":1,"type":"SOLID","visible":false}],"height":12,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"FIXED","itemSpacing":0,"layoutAlign":"MIN","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":3.3747053146362305,"paddingLeft":1.8747045993804932,"paddingRight":1.874704122543335,"paddingTop":4.125,"primaryAxisAlignItems":"CENTER","primaryAxisSizingMode":"FIXED"},"isAsset":true,"itemSpacing":0,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"NONE","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED","maxHeight":null,"maxWidth":null,"name":"Caret-fill","opacity":1,"overflowDirection":"NONE","overrides":[{"id":"73:6701","overriddenFields":["height","width"]},{"id":"I73:6701;40:472","overriddenFields":["fills"]}],"paddingBottom":0,"paddingLeft":0,"paddingRight":0,"paddingTop":0,"relativeTransform":[[1,0,72],[0,1,14]],"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"strokesIncludedInLayout":false,"targetAspectRatio":{"x":24,"y":24},"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"속성 1":"CaretDown"},"visible":true,"width":12,"x":72,"y":14,"mainComponentId":"40:482","mainComponentName":"속성 1=CaretDown","mainComponentSetId":"40:483"},"extra":{},"fieldErrors":{}},"I73:6521;40:472":{"id":"I73:6521;40:472","type":"VECTOR","fields":{"parentId":"73:6521","absoluteBoundingBox":{"height":4.5002946853637695,"width":8.250591278076172,"x":8907.875,"y":8308.125},"absoluteRenderBounds":{"height":4.5,"width":8.2509765625,"x":8907.875,"y":8308.125},"boundVariables":{"fills":[{"id":"VariableID:9:359","type":"VARIABLE_ALIAS"}]},"constraints":{"horizontal":"SCALE","vertical":"SCALE"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:359","type":"VARIABLE_ALIAS"}},"color":{"b":0.6682692170143127,"g":0.6104382276535034,"r":0.5815227627754211},"opacity":1,"type":"SOLID","visible":true}],"height":4.5002946853637695,"isAsset":true,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED","maxHeight":null,"maxWidth":null,"name":"Vector","opacity":1,"relativeTransform":[[1,0,1.8747045993804932],[0,1,4.125]],"strokeWeight":0.09375,"visible":true,"width":8.250591278076172,"x":1.8747045993804932,"y":4.125},"extra":{},"fieldErrors":{}},"I73:6559;40:472":{"id":"I73:6559;40:472","type":"VECTOR","fields":{"parentId":"73:6559","absoluteBoundingBox":{"height":4.5002946853637695,"width":8.250591278076172,"x":9147.875,"y":8308.125},"absoluteRenderBounds":{"height":4.5,"width":8.2509765625,"x":9147.875,"y":8308.125},"boundVariables":{"fills":[{"id":"VariableID:9:359","type":"VARIABLE_ALIAS"}]},"constraints":{"horizontal":"SCALE","vertical":"SCALE"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:359","type":"VARIABLE_ALIAS"}},"color":{"b":0.6682692170143127,"g":0.6104382276535034,"r":0.5815227627754211},"opacity":1,"type":"SOLID","visible":true}],"height":4.5002946853637695,"isAsset":true,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED","maxHeight":null,"maxWidth":null,"name":"Vector","opacity":1,"relativeTransform":[[1,0,1.8747045993804932],[0,1,4.125]],"strokeWeight":0.09375,"visible":true,"width":8.250591278076172,"x":1.8747045993804932,"y":4.125},"extra":{},"fieldErrors":{}},"I73:6565;40:472":{"id":"I73:6565;40:472","type":"VECTOR","fields":{"parentId":"73:6565","absoluteBoundingBox":{"height":4.5002946853637695,"width":8.250591278076172,"x":9279.875,"y":8308.125},"absoluteRenderBounds":{"height":4.5,"width":8.2509765625,"x":9279.875,"y":8308.125},"boundVariables":{"fills":[{"id":"VariableID:9:359","type":"VARIABLE_ALIAS"}]},"constraints":{"horizontal":"SCALE","vertical":"SCALE"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:359","type":"VARIABLE_ALIAS"}},"color":{"b":0.6682692170143127,"g":0.6104382276535034,"r":0.5815227627754211},"opacity":1,"type":"SOLID","visible":true}],"height":4.5002946853637695,"isAsset":true,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED","maxHeight":null,"maxWidth":null,"name":"Vector","opacity":1,"relativeTransform":[[1,0,1.8747045993804932],[0,1,4.125]],"strokeWeight":0.09375,"visible":true,"width":8.250591278076172,"x":1.8747045993804932,"y":4.125},"extra":{},"fieldErrors":{}},"I73:6701;40:472":{"id":"I73:6701;40:472","type":"VECTOR","fields":{"parentId":"73:6701","absoluteBoundingBox":{"height":4.5002946853637695,"width":8.250591278076172,"x":9027.875,"y":8308.125},"absoluteRenderBounds":{"height":4.5,"width":8.2509765625,"x":9027.875,"y":8308.125},"boundVariables":{"fills":[{"id":"VariableID:9:359","type":"VARIABLE_ALIAS"}]},"constraints":{"horizontal":"SCALE","vertical":"SCALE"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:359","type":"VARIABLE_ALIAS"}},"color":{"b":0.6682692170143127,"g":0.6104382276535034,"r":0.5815227627754211},"opacity":1,"type":"SOLID","visible":true}],"height":4.5002946853637695,"isAsset":true,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED","maxHeight":null,"maxWidth":null,"name":"Vector","opacity":1,"relativeTransform":[[1,0,1.8747045993804932],[0,1,4.125]],"strokeWeight":0.09375,"visible":true,"width":8.250591278076172,"x":1.8747045993804932,"y":4.125},"extra":{},"fieldErrors":{}}},"diagnostics":[]} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json b/crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json index 598ea8d0..ed121dfe 100644 --- a/crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json +++ b/crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json @@ -540,5 +540,5 @@ "fieldErrors": {} } ], - "expectedTsx": "import { Flex, Text } from \"@devup-ui/react\";\n\nexport function Fr0306체험하기() {\n return (\n \n \n [FR-03~06] 체험하기\n \n \n );\n}\n" + "expectedTsx": "import { Flex, Text } from \"@devup-ui/react\";\n\nexport function Fr0306체험하기() {\n return (\n \n \n [FR-03~06] 체험하기\n \n \n );\n}\n" } diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/news-detail-responsive-copy.json b/crates/devup-mcp-devup-ui/tests/fixtures/news-detail-responsive-copy.json new file mode 100644 index 00000000..81559f2e --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/news-detail-responsive-copy.json @@ -0,0 +1,1284 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "73:4958", + "73:4983" + ], + "diagnostics": [], + "nodes": { + "73:4972": { + "id": "73:4972", + "type": "TEXT", + "fields": { + "parentId": "73:4967", + "absoluteBoundingBox": { + "height": 42, + "width": 800, + "x": 38061, + "y": 1263 + }, + "absoluteRenderBounds": { + "height": 28.8319091796875, + "width": 112.17578125, + "x": 38061.3203125, + "y": 1269.9200439453125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:942", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "제목 영역", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 32, + "height": 42, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -3 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 132.00000524520874 + }, + "maxHeight": null, + "maxWidth": null, + "name": "[기업 뉴스] 뉴스 제목이 들어갑니다", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 40 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:8f74b830ee8360510db15b6145b2c30e5a240ce2,", + "textTruncation": "DISABLED", + "visible": true, + "width": 800, + "x": 0, + "y": 40, + "styledTextSegments": [ + { + "characters": "제목 영역", + "end": 5, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:8f74b830ee8360510db15b6145b2c30e5a240ce2," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4992": { + "id": "73:4992", + "type": "FRAME", + "fields": { + "parentId": "73:4991", + "childrenIds": [ + "73:4997" + ], + "absoluteBoundingBox": { + "height": 159, + "width": 320, + "x": 39228, + "y": 1187 + }, + "absoluteRenderBounds": { + "height": 159, + "width": 320, + "x": 39228, + "y": 1187 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "strokes": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 159, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 12, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 20, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 12, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "article head", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 20, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 2, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4967": { + "id": "73:4967", + "type": "FRAME", + "fields": { + "parentId": "73:4966", + "childrenIds": [ + "73:4972" + ], + "absoluteBoundingBox": { + "height": 147, + "width": 800, + "x": 38061, + "y": 1223 + }, + "absoluteRenderBounds": { + "height": 147, + "width": 800, + "x": 38061, + "y": 1223 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "strokes": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 147, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 12, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 28, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 12, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "article head", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 28, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 2, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 800, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4997": { + "id": "73:4997", + "type": "TEXT", + "fields": { + "parentId": "73:4992", + "absoluteBoundingBox": { + "height": 64, + "width": 320, + "x": 39228, + "y": 1227 + }, + "absoluteRenderBounds": { + "height": 53.6719970703125, + "width": 296.7734375, + "x": 39229.55859375, + "y": 1232.416015625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:942", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "[기업 뉴스] 뉴스 제목이 들어갑니다", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 24, + "height": 64, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -3 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 132.00000524520874 + }, + "maxHeight": null, + "maxWidth": null, + "name": "[기업 뉴스] 뉴스 제목이 들어갑니다", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 40 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:8f74b830ee8360510db15b6145b2c30e5a240ce2,", + "textTruncation": "DISABLED", + "visible": true, + "width": 320, + "x": 0, + "y": 40, + "styledTextSegments": [ + { + "characters": "[기업 뉴스] 뉴스 제목이 들어갑니다", + "end": 20, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:8f74b830ee8360510db15b6145b2c30e5a240ce2," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4991": { + "id": "73:4991", + "type": "FRAME", + "fields": { + "parentId": "73:4990", + "childrenIds": [ + "73:4992" + ], + "absoluteBoundingBox": { + "height": 537, + "width": 320, + "x": 39228, + "y": 1187 + }, + "absoluteRenderBounds": { + "height": 537, + "width": 320, + "x": 39228, + "y": 1187 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 537, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 960, + "name": "article", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4964": { + "id": "73:4964", + "type": "FRAME", + "fields": { + "parentId": "73:4958", + "childrenIds": [ + "73:4965" + ], + "absoluteBoundingBox": { + "height": 1245, + "width": 1440, + "x": 37741, + "y": 1103 + }, + "absoluteRenderBounds": { + "height": 1245, + "width": 1440, + "x": 37741, + "y": 1103 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingBottom": { + "id": "VariableID:42:1085", + "type": "VARIABLE_ALIAS" + }, + "paddingLeft": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + }, + "paddingRight": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 1245, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 120, + "paddingLeft": 40, + "paddingRight": 40, + "paddingTop": 120, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Article", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 120, + "paddingLeft": 40, + "paddingRight": 40, + "paddingTop": 120, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4966": { + "id": "73:4966", + "type": "FRAME", + "fields": { + "parentId": "73:4965", + "childrenIds": [ + "73:4967" + ], + "absoluteBoundingBox": { + "height": 1005, + "width": 800, + "x": 38061, + "y": 1223 + }, + "absoluteRenderBounds": { + "height": 1005, + "width": 800, + "x": 38061, + "y": 1223 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 1005, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 40, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 40, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 800, + "name": "article", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 240 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 800, + "x": 240, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4958": { + "id": "73:4958", + "type": "FRAME", + "fields": { + "parentId": "73:4957", + "parentType": "SECTION", + "parentName": "소식 · 상세", + "childrenIds": [ + "73:4964" + ], + "absoluteBoundingBox": { + "height": 1426, + "width": 1440, + "x": 37741, + "y": 1103 + }, + "absoluteRenderBounds": { + "height": 1426, + "width": 1440, + "x": 37741, + "y": 1103 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1426, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "기업 뉴스 상세 · Desktop 1440", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 73 + ], + [ + 0, + 1, + 65 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 73, + "y": 65 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4983": { + "id": "73:4983", + "type": "FRAME", + "fields": { + "parentId": "73:4957", + "parentType": "SECTION", + "parentName": "소식 · 상세", + "childrenIds": [ + "73:4989" + ], + "absoluteBoundingBox": { + "height": 1046, + "width": 360, + "x": 39208, + "y": 1103 + }, + "absoluteRenderBounds": { + "height": 1046, + "width": 360, + "x": 39208, + "y": 1103 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1046, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "기업 뉴스 상세 · Mobile 360", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 1540 + ], + [ + 0, + 1, + 65 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 1540, + "y": 65 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4965": { + "id": "73:4965", + "type": "FRAME", + "fields": { + "parentId": "73:4964", + "childrenIds": [ + "73:4966" + ], + "absoluteBoundingBox": { + "height": 1005, + "width": 1280, + "x": 37821, + "y": 1223 + }, + "absoluteRenderBounds": { + "height": 1005, + "width": 1280, + "x": 37821, + "y": 1223 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 1005, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 40, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 40, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 1280, + "name": "container", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 80 + ], + [ + 0, + 1, + 120 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1280, + "x": 80, + "y": 120 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4989": { + "id": "73:4989", + "type": "FRAME", + "fields": { + "parentId": "73:4983", + "childrenIds": [ + "73:4990" + ], + "absoluteBoundingBox": { + "height": 799, + "width": 360, + "x": 39208, + "y": 1103 + }, + "absoluteRenderBounds": { + "height": 799, + "width": 360, + "x": 39208, + "y": 1103 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingLeft": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + }, + "paddingRight": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 799, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 64, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 84, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Article", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 64, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 84, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4990": { + "id": "73:4990", + "type": "FRAME", + "fields": { + "parentId": "73:4989", + "childrenIds": [ + "73:4991" + ], + "absoluteBoundingBox": { + "height": 651, + "width": 320, + "x": 39228, + "y": 1187 + }, + "absoluteRenderBounds": { + "height": 651, + "width": 320, + "x": 39228, + "y": 1187 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 651, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 1280, + "name": "container", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 20 + ], + [ + 0, + 1, + 84 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 20, + "y": 84 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "captureScope": "Actual News detail title nodes and bounded original ancestors only; unrelated siblings excluded." +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/news-grid-gap-aliases.json b/crates/devup-mcp-devup-ui/tests/fixtures/news-grid-gap-aliases.json new file mode 100644 index 00000000..f1d30c33 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/news-grid-gap-aliases.json @@ -0,0 +1,9235 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "73:4662", + "73:4742" + ], + "nodes": { + "73:4686": { + "id": "73:4686", + "type": "FRAME", + "fields": { + "parentId": "73:4662", + "childrenIds": [ + "73:4687", + "73:4688", + "73:4689" + ], + "absoluteBoundingBox": { + "height": 993, + "width": 1280, + "x": 33148, + "y": 1693 + }, + "absoluteRenderBounds": { + "height": 993, + "width": 1280, + "x": 33148, + "y": 1693 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 48, + "height": 993, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 32, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 32, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "thumbnail grid (3열 · 9개)", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 72 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1280, + "x": 0, + "y": 72 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4687": { + "id": "73:4687", + "type": "INSTANCE", + "fields": { + "parentId": "73:4686", + "childrenIds": [ + "I73:4687;73:5570", + "I73:4687;73:5727" + ], + "absoluteBoundingBox": { + "height": 299, + "width": 405, + "x": 33148, + "y": 1693 + }, + "absoluteRenderBounds": { + "height": 299, + "width": 405, + "x": 33148, + "y": 1693 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "등록일#47:91": { + "type": "TEXT", + "value": "2026. 09. 20" + }, + "상단고정#47:94": { + "type": "BOOLEAN", + "value": true + }, + "제목#47:88": { + "type": "TEXT", + "value": "[기업 뉴스] 상단 고정된 뉴스 제목이 들어갑니다" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 299, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "NewsThumbCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:4687", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:4687;73:5570", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I73:4687;73:5571", + "overriddenFields": [ + "visible" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 405, + "x": 0, + "y": 0, + "mainComponentId": "73:5569", + "mainComponentName": "effect=default", + "mainComponentSetId": "73:5568" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5570": { + "id": "I73:4687;73:5570", + "type": "FRAME", + "fields": { + "parentId": "73:4687", + "childrenIds": [ + "I73:4687;73:5571", + "I73:4687;73:5573" + ], + "absoluteBoundingBox": { + "height": 228, + "width": 405, + "x": 33148, + "y": 1693 + }, + "absoluteRenderBounds": { + "height": 228, + "width": 405, + "x": 33148, + "y": 1693 + }, + "bottomLeftRadius": 16, + "bottomRightRadius": 16, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 16, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "filters": { + "contrast": 0, + "exposure": 0, + "highlights": 0, + "saturation": 0, + "shadows": 0, + "temperature": 0, + "tint": 0 + }, + "imageHash": "10f417552ba021428b7b527137167e596da11f27", + "imageTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "opacity": 1, + "rotation": 0, + "scaleMode": "FILL", + "scalingFactor": 1, + "type": "IMAGE", + "visible": true + } + ], + "height": 228, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "thumbnail", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 16, + "topRightRadius": 16, + "visible": true, + "width": 405, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5571": { + "id": "I73:4687;73:5571", + "type": "TEXT", + "fields": { + "parentId": "I73:4687;73:5570", + "absoluteBoundingBox": { + "height": 21, + "width": 91, + "x": 33305, + "y": 1796.5 + }, + "absoluteRenderBounds": null, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "대표 이미지 16:9", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7843137383460999, + "g": 0.4745098054409027, + "r": 0.20000000298023224 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "thumb label", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 157 + ], + [ + 0, + 1, + 103.5 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": false, + "width": 91, + "x": 157, + "y": 103.5, + "styledTextSegments": [ + { + "characters": "대표 이미지 16:9", + "end": 11, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5573": { + "id": "I73:4687;73:5573", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4687;73:5570", + "childrenIds": [ + "I73:4687;73:5573;73:6826", + "I73:4687;73:5573;32:362" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 59, + "x": 33158, + "y": 1703 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 59, + "x": 33158, + "y": 1703 + }, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "visible": "상단고정#47:94" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "ABSOLUTE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "pin tag", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 10 + ], + [ + 0, + 1, + 10 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 100, + "topRightRadius": 100, + "visible": true, + "width": 59, + "x": 10, + "y": 10, + "mainComponentId": "32:368", + "mainComponentName": "noticeTag", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5573;73:6826": { + "id": "I73:4687;73:5573;73:6826", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4687;73:5573", + "childrenIds": [ + "I73:4687;73:5573;73:6826;73:6818" + ], + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 33166, + "y": 1711 + }, + "absoluteRenderBounds": { + "height": 12, + "width": 12, + "x": 33166, + "y": 1711 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 12, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0.5001888275146484, + "paddingLeft": 0.5, + "paddingRight": 0.4995241165161133, + "paddingTop": 0.5, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "megaphone", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 512, + "y": 512 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 8, + "y": 8, + "mainComponentId": "73:6825", + "mainComponentName": "megaphone", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5573;73:6826;73:6818": { + "id": "I73:4687;73:5573;73:6826;73:6818", + "type": "GROUP", + "fields": { + "parentId": "I73:4687;73:5573;73:6826", + "childrenIds": [ + "I73:4687;73:5573;73:6826;73:6819", + "I73:4687;73:5573;73:6826;73:6821", + "I73:4687;73:5573;73:6826;73:6823" + ], + "absoluteBoundingBox": { + "height": 10.999811172485352, + "width": 11.000475883483888, + "x": 33166.5, + "y": 1711.5 + }, + "absoluteRenderBounds": { + "height": 10.9998779296875, + "width": 11.000475883483888, + "x": 33166.5, + "y": 1711.5 + }, + "height": 10.999811172485352, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 11.000475883483888, + "x": 0.5, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5573;73:6826;73:6819": { + "id": "I73:4687;73:5573;73:6826;73:6819", + "type": "GROUP", + "fields": { + "parentId": "I73:4687;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4687;73:5573;73:6826;73:6820" + ], + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 33166.5, + "y": 1712.1593017578125 + }, + "absoluteRenderBounds": { + "height": 7.1829833984375, + "width": 8.28515625, + "x": 33166.5, + "y": 1712.1593017578125 + }, + "height": 7.182965278625488, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5573;73:6826;73:6820": { + "id": "I73:4687;73:5573;73:6826;73:6820", + "type": "VECTOR", + "fields": { + "parentId": "I73:4687;73:5573;73:6826;73:6819", + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 33166.5, + "y": 1712.1593017578125 + }, + "absoluteRenderBounds": { + "height": 7.1829833984375, + "width": 8.28515625, + "x": 33166.5, + "y": 1712.1593017578125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.182965278625488, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5573;73:6826;73:6821": { + "id": "I73:4687;73:5573;73:6826;73:6821", + "type": "GROUP", + "fields": { + "parentId": "I73:4687;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4687;73:5573;73:6826;73:6822" + ], + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 33167.40625, + "y": 1717.9888916015625 + }, + "absoluteRenderBounds": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 33167.40625, + "y": 1717.9888916015625 + }, + "height": 4.511041164398193, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5573;73:6826;73:6822": { + "id": "I73:4687;73:5573;73:6826;73:6822", + "type": "VECTOR", + "fields": { + "parentId": "I73:4687;73:5573;73:6826;73:6821", + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 33167.40625, + "y": 1717.9888916015625 + }, + "absoluteRenderBounds": { + "height": 4.510986328125, + "width": 3.78125, + "x": 33167.40625, + "y": 1717.9888916015625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4.511041164398193, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5573;73:6826;73:6823": { + "id": "I73:4687;73:5573;73:6826;73:6823", + "type": "GROUP", + "fields": { + "parentId": "I73:4687;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4687;73:5573;73:6826;73:6824" + ], + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 33175.48828125, + "y": 1711.5 + }, + "absoluteRenderBounds": { + "height": 8.5015869140625, + "width": 2.0121943950653076, + "x": 33175.48828125, + "y": 1711.5 + }, + "height": 8.501578330993652, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5573;73:6826;73:6824": { + "id": "I73:4687;73:5573;73:6826;73:6824", + "type": "VECTOR", + "fields": { + "parentId": "I73:4687;73:5573;73:6826;73:6823", + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 33175.48828125, + "y": 1711.5 + }, + "absoluteRenderBounds": { + "height": 8.5015869140625, + "width": 2.01171875, + "x": 33175.48828125, + "y": 1711.5 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 8.501578330993652, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5573;32:362": { + "id": "I73:4687;73:5573;32:362", + "type": "TEXT", + "fields": { + "parentId": "I73:4687;73:5573", + "absoluteBoundingBox": { + "height": 20, + "width": 23, + "x": 33184, + "y": 1707 + }, + "absoluteRenderBounds": { + "height": 11.218994140625, + "width": 21.6953125, + "x": 33184.390625, + "y": 1711.8990478515625 + }, + "characters": "중요", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "중요", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 26 + ], + [ + 0, + 1, + 4 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259,", + "textTruncation": "DISABLED", + "visible": true, + "width": 23, + "x": 26, + "y": 4, + "styledTextSegments": [ + { + "characters": "중요", + "end": 2, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5727": { + "id": "I73:4687;73:5727", + "type": "FRAME", + "fields": { + "parentId": "73:4687", + "childrenIds": [ + "I73:4687;73:5575", + "I73:4687;73:5574" + ], + "absoluteBoundingBox": { + "height": 55, + "width": 405, + "x": 33148, + "y": 1937 + }, + "absoluteRenderBounds": { + "height": 55, + "width": 405, + "x": 33148, + "y": 1937 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 55, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 554", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 244 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 405, + "x": 0, + "y": 244 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5575": { + "id": "I73:4687;73:5575", + "type": "TEXT", + "fields": { + "parentId": "I73:4687;73:5727", + "absoluteBoundingBox": { + "height": 28, + "width": 405, + "x": 33148, + "y": 1937 + }, + "absoluteRenderBounds": { + "height": 18.0599365234375, + "width": 362.69140625, + "x": 33150.05859375, + "y": 1941.6800537109375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "[기업 뉴스] 상단 고정된 뉴스 제목이 들어갑니다", + "componentPropertyReferences": { + "characters": "제목#47:88" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 20, + "height": 28, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxLines": 1, + "maxWidth": null, + "name": "title", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "ENDING", + "visible": true, + "width": 405, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "[기업 뉴스] 상단 고정된 뉴스 제목이 들어갑니다", + "end": 27, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4687;73:5574": { + "id": "I73:4687;73:5574", + "type": "TEXT", + "fields": { + "parentId": "I73:4687;73:5727", + "absoluteBoundingBox": { + "height": 21, + "width": 77, + "x": 33148, + "y": 1971 + }, + "absoluteRenderBounds": { + "height": 10.416015625, + "width": 75.1796875, + "x": 33148.78515625, + "y": 1976.7659912109375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2026. 09. 20", + "componentPropertyReferences": { + "characters": "등록일#47:91" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "date", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 34 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 77, + "x": 0, + "y": 34, + "styledTextSegments": [ + { + "characters": "2026. 09. 20", + "end": 12, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4688": { + "id": "73:4688", + "type": "INSTANCE", + "fields": { + "parentId": "73:4686", + "childrenIds": [ + "I73:4688;73:5570", + "I73:4688;73:5727" + ], + "absoluteBoundingBox": { + "height": 299, + "width": 405, + "x": 33585, + "y": 1693 + }, + "absoluteRenderBounds": { + "height": 299, + "width": 405, + "x": 33585, + "y": 1693 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "등록일#47:91": { + "type": "TEXT", + "value": "2026. 09. 19" + }, + "상단고정#47:94": { + "type": "BOOLEAN", + "value": false + }, + "제목#47:88": { + "type": "TEXT", + "value": "[기업 뉴스] 뉴스 제목이 들어갑니다 2" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 299, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "NewsThumbCard (hover 예시)", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:4688", + "overriddenFields": [ + "height", + "name", + "width" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 437 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 405, + "x": 437, + "y": 0, + "mainComponentId": "73:5569", + "mainComponentName": "effect=default", + "mainComponentSetId": "73:5568" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5570": { + "id": "I73:4688;73:5570", + "type": "FRAME", + "fields": { + "parentId": "73:4688", + "childrenIds": [ + "I73:4688;73:5571", + "I73:4688;73:5573" + ], + "absoluteBoundingBox": { + "height": 228, + "width": 405, + "x": 33585, + "y": 1693 + }, + "absoluteRenderBounds": { + "height": 228, + "width": 405, + "x": 33585, + "y": 1693 + }, + "bottomLeftRadius": 16, + "bottomRightRadius": 16, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 16, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 228, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "thumbnail", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 16, + "topRightRadius": 16, + "visible": true, + "width": 405, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5571": { + "id": "I73:4688;73:5571", + "type": "TEXT", + "fields": { + "parentId": "I73:4688;73:5570", + "absoluteBoundingBox": { + "height": 21, + "width": 91, + "x": 33742, + "y": 1796.5 + }, + "absoluteRenderBounds": { + "height": 12.2779541015625, + "width": 88.6640625, + "x": 33743.0234375, + "y": 1801.258056640625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "대표 이미지 16:9", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7843137383460999, + "g": 0.4745098054409027, + "r": 0.20000000298023224 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "thumb label", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 157 + ], + [ + 0, + 1, + 103.5 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 91, + "x": 157, + "y": 103.5, + "styledTextSegments": [ + { + "characters": "대표 이미지 16:9", + "end": 11, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5573": { + "id": "I73:4688;73:5573", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4688;73:5570", + "childrenIds": [ + "I73:4688;73:5573;73:6826", + "I73:4688;73:5573;32:362" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 59, + "x": 33595, + "y": 1703 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "visible": "상단고정#47:94" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "ABSOLUTE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "pin tag", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 10 + ], + [ + 0, + 1, + 10 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 100, + "topRightRadius": 100, + "visible": false, + "width": 59, + "x": 10, + "y": 10, + "mainComponentId": "32:368", + "mainComponentName": "noticeTag", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5573;73:6826": { + "id": "I73:4688;73:5573;73:6826", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4688;73:5573", + "childrenIds": [ + "I73:4688;73:5573;73:6826;73:6818" + ], + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 33603, + "y": 1711 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 12, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0.5001888275146484, + "paddingLeft": 0.5, + "paddingRight": 0.4995241165161133, + "paddingTop": 0.5, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "megaphone", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 512, + "y": 512 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 8, + "y": 8, + "mainComponentId": "73:6825", + "mainComponentName": "megaphone", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5573;73:6826;73:6818": { + "id": "I73:4688;73:5573;73:6826;73:6818", + "type": "GROUP", + "fields": { + "parentId": "I73:4688;73:5573;73:6826", + "childrenIds": [ + "I73:4688;73:5573;73:6826;73:6819", + "I73:4688;73:5573;73:6826;73:6821", + "I73:4688;73:5573;73:6826;73:6823" + ], + "absoluteBoundingBox": { + "height": 10.999811172485352, + "width": 11.000475883483888, + "x": 33603.5, + "y": 1711.5 + }, + "absoluteRenderBounds": null, + "height": 10.999811172485352, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 11.000475883483888, + "x": 0.5, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5573;73:6826;73:6819": { + "id": "I73:4688;73:5573;73:6826;73:6819", + "type": "GROUP", + "fields": { + "parentId": "I73:4688;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4688;73:5573;73:6826;73:6820" + ], + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 33603.5, + "y": 1712.1593017578125 + }, + "absoluteRenderBounds": null, + "height": 7.182965278625488, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5573;73:6826;73:6820": { + "id": "I73:4688;73:5573;73:6826;73:6820", + "type": "VECTOR", + "fields": { + "parentId": "I73:4688;73:5573;73:6826;73:6819", + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 33603.5, + "y": 1712.1593017578125 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.182965278625488, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5573;73:6826;73:6821": { + "id": "I73:4688;73:5573;73:6826;73:6821", + "type": "GROUP", + "fields": { + "parentId": "I73:4688;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4688;73:5573;73:6826;73:6822" + ], + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 33604.40625, + "y": 1717.9888916015625 + }, + "absoluteRenderBounds": null, + "height": 4.511041164398193, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5573;73:6826;73:6822": { + "id": "I73:4688;73:5573;73:6826;73:6822", + "type": "VECTOR", + "fields": { + "parentId": "I73:4688;73:5573;73:6826;73:6821", + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 33604.40625, + "y": 1717.9888916015625 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4.511041164398193, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5573;73:6826;73:6823": { + "id": "I73:4688;73:5573;73:6826;73:6823", + "type": "GROUP", + "fields": { + "parentId": "I73:4688;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4688;73:5573;73:6826;73:6824" + ], + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 33612.48828125, + "y": 1711.5 + }, + "absoluteRenderBounds": null, + "height": 8.501578330993652, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5573;73:6826;73:6824": { + "id": "I73:4688;73:5573;73:6826;73:6824", + "type": "VECTOR", + "fields": { + "parentId": "I73:4688;73:5573;73:6826;73:6823", + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 33612.48828125, + "y": 1711.5 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 8.501578330993652, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5573;32:362": { + "id": "I73:4688;73:5573;32:362", + "type": "TEXT", + "fields": { + "parentId": "I73:4688;73:5573", + "absoluteBoundingBox": { + "height": 20, + "width": 23, + "x": 33621, + "y": 1707 + }, + "absoluteRenderBounds": null, + "characters": "중요", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "중요", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 26 + ], + [ + 0, + 1, + 4 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259,", + "textTruncation": "DISABLED", + "visible": true, + "width": 23, + "x": 26, + "y": 4, + "styledTextSegments": [ + { + "characters": "중요", + "end": 2, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5727": { + "id": "I73:4688;73:5727", + "type": "FRAME", + "fields": { + "parentId": "73:4688", + "childrenIds": [ + "I73:4688;73:5575", + "I73:4688;73:5574" + ], + "absoluteBoundingBox": { + "height": 55, + "width": 405, + "x": 33585, + "y": 1937 + }, + "absoluteRenderBounds": { + "height": 55, + "width": 405, + "x": 33585, + "y": 1937 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 55, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 554", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 244 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 405, + "x": 0, + "y": 244 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5575": { + "id": "I73:4688;73:5575", + "type": "TEXT", + "fields": { + "parentId": "I73:4688;73:5727", + "absoluteBoundingBox": { + "height": 28, + "width": 405, + "x": 33585, + "y": 1937 + }, + "absoluteRenderBounds": { + "height": 18.0599365234375, + "width": 283.72265625, + "x": 33587.05859375, + "y": 1941.6800537109375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "[기업 뉴스] 뉴스 제목이 들어갑니다 2", + "componentPropertyReferences": { + "characters": "제목#47:88" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 20, + "height": 28, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxLines": 1, + "maxWidth": null, + "name": "title", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "ENDING", + "visible": true, + "width": 405, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "[기업 뉴스] 뉴스 제목이 들어갑니다 2", + "end": 22, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4688;73:5574": { + "id": "I73:4688;73:5574", + "type": "TEXT", + "fields": { + "parentId": "I73:4688;73:5727", + "absoluteBoundingBox": { + "height": 21, + "width": 74, + "x": 33585, + "y": 1971 + }, + "absoluteRenderBounds": { + "height": 10.416015625, + "width": 72.09375, + "x": 33585.78515625, + "y": 1976.7659912109375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2026. 09. 19", + "componentPropertyReferences": { + "characters": "등록일#47:91" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "date", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 34 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 74, + "x": 0, + "y": 34, + "styledTextSegments": [ + { + "characters": "2026. 09. 19", + "end": 12, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4689": { + "id": "73:4689", + "type": "INSTANCE", + "fields": { + "parentId": "73:4686", + "childrenIds": [ + "I73:4689;73:5570", + "I73:4689;73:5727" + ], + "absoluteBoundingBox": { + "height": 299, + "width": 405, + "x": 34022, + "y": 1693 + }, + "absoluteRenderBounds": { + "height": 299, + "width": 405, + "x": 34022, + "y": 1693 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "등록일#47:91": { + "type": "TEXT", + "value": "2026. 09. 18" + }, + "상단고정#47:94": { + "type": "BOOLEAN", + "value": false + }, + "제목#47:88": { + "type": "TEXT", + "value": "[기업 뉴스] 뉴스 제목이 들어갑니다 3" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 299, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 16, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "NewsThumbCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:4689", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:4689;73:5571", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 874 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 405, + "x": 874, + "y": 0, + "mainComponentId": "73:5569", + "mainComponentName": "effect=default", + "mainComponentSetId": "73:5568" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5570": { + "id": "I73:4689;73:5570", + "type": "FRAME", + "fields": { + "parentId": "73:4689", + "childrenIds": [ + "I73:4689;73:5571", + "I73:4689;73:5573" + ], + "absoluteBoundingBox": { + "height": 228, + "width": 405, + "x": 34022, + "y": 1693 + }, + "absoluteRenderBounds": { + "height": 228, + "width": 405, + "x": 34022, + "y": 1693 + }, + "bottomLeftRadius": 16, + "bottomRightRadius": 16, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 16, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 228, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "thumbnail", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 16, + "topRightRadius": 16, + "visible": true, + "width": 405, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5571": { + "id": "I73:4689;73:5571", + "type": "TEXT", + "fields": { + "parentId": "I73:4689;73:5570", + "absoluteBoundingBox": { + "height": 21, + "width": 142, + "x": 34153.5, + "y": 1796.5 + }, + "absoluteRenderBounds": { + "height": 12.2779541015625, + "width": 139.10546875, + "x": 34154.07421875, + "y": 1801.258056640625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "등록된 이미지가 없습니다.", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7843137383460999, + "g": 0.4745098054409027, + "r": 0.20000000298023224 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "thumb label", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 131.5 + ], + [ + 0, + 1, + 103.5 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 142, + "x": 131.5, + "y": 103.5, + "styledTextSegments": [ + { + "characters": "등록된 이미지가 없습니다.", + "end": 14, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5573": { + "id": "I73:4689;73:5573", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4689;73:5570", + "childrenIds": [ + "I73:4689;73:5573;73:6826", + "I73:4689;73:5573;32:362" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 59, + "x": 34032, + "y": 1703 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "visible": "상단고정#47:94" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "ABSOLUTE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "pin tag", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 10 + ], + [ + 0, + 1, + 10 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 100, + "topRightRadius": 100, + "visible": false, + "width": 59, + "x": 10, + "y": 10, + "mainComponentId": "32:368", + "mainComponentName": "noticeTag", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5573;73:6826": { + "id": "I73:4689;73:5573;73:6826", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4689;73:5573", + "childrenIds": [ + "I73:4689;73:5573;73:6826;73:6818" + ], + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 34040, + "y": 1711 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 12, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0.5001888275146484, + "paddingLeft": 0.5, + "paddingRight": 0.4995241165161133, + "paddingTop": 0.5, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "megaphone", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 512, + "y": 512 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 8, + "y": 8, + "mainComponentId": "73:6825", + "mainComponentName": "megaphone", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5573;73:6826;73:6818": { + "id": "I73:4689;73:5573;73:6826;73:6818", + "type": "GROUP", + "fields": { + "parentId": "I73:4689;73:5573;73:6826", + "childrenIds": [ + "I73:4689;73:5573;73:6826;73:6819", + "I73:4689;73:5573;73:6826;73:6821", + "I73:4689;73:5573;73:6826;73:6823" + ], + "absoluteBoundingBox": { + "height": 10.999811172485352, + "width": 11.000475883483888, + "x": 34040.5, + "y": 1711.5 + }, + "absoluteRenderBounds": null, + "height": 10.999811172485352, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 11.000475883483888, + "x": 0.5, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5573;73:6826;73:6819": { + "id": "I73:4689;73:5573;73:6826;73:6819", + "type": "GROUP", + "fields": { + "parentId": "I73:4689;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4689;73:5573;73:6826;73:6820" + ], + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 34040.5, + "y": 1712.1593017578125 + }, + "absoluteRenderBounds": null, + "height": 7.182965278625488, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5573;73:6826;73:6820": { + "id": "I73:4689;73:5573;73:6826;73:6820", + "type": "VECTOR", + "fields": { + "parentId": "I73:4689;73:5573;73:6826;73:6819", + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 34040.5, + "y": 1712.1593017578125 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.182965278625488, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5573;73:6826;73:6821": { + "id": "I73:4689;73:5573;73:6826;73:6821", + "type": "GROUP", + "fields": { + "parentId": "I73:4689;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4689;73:5573;73:6826;73:6822" + ], + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 34041.40625, + "y": 1717.9888916015625 + }, + "absoluteRenderBounds": null, + "height": 4.511041164398193, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5573;73:6826;73:6822": { + "id": "I73:4689;73:5573;73:6826;73:6822", + "type": "VECTOR", + "fields": { + "parentId": "I73:4689;73:5573;73:6826;73:6821", + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 34041.40625, + "y": 1717.9888916015625 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4.511041164398193, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5573;73:6826;73:6823": { + "id": "I73:4689;73:5573;73:6826;73:6823", + "type": "GROUP", + "fields": { + "parentId": "I73:4689;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4689;73:5573;73:6826;73:6824" + ], + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 34049.48828125, + "y": 1711.5 + }, + "absoluteRenderBounds": null, + "height": 8.501578330993652, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5573;73:6826;73:6824": { + "id": "I73:4689;73:5573;73:6826;73:6824", + "type": "VECTOR", + "fields": { + "parentId": "I73:4689;73:5573;73:6826;73:6823", + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 34049.48828125, + "y": 1711.5 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 8.501578330993652, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5573;32:362": { + "id": "I73:4689;73:5573;32:362", + "type": "TEXT", + "fields": { + "parentId": "I73:4689;73:5573", + "absoluteBoundingBox": { + "height": 20, + "width": 23, + "x": 34058, + "y": 1707 + }, + "absoluteRenderBounds": null, + "characters": "중요", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "중요", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 26 + ], + [ + 0, + 1, + 4 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259,", + "textTruncation": "DISABLED", + "visible": true, + "width": 23, + "x": 26, + "y": 4, + "styledTextSegments": [ + { + "characters": "중요", + "end": 2, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5727": { + "id": "I73:4689;73:5727", + "type": "FRAME", + "fields": { + "parentId": "73:4689", + "childrenIds": [ + "I73:4689;73:5575", + "I73:4689;73:5574" + ], + "absoluteBoundingBox": { + "height": 55, + "width": 405, + "x": 34022, + "y": 1937 + }, + "absoluteRenderBounds": { + "height": 55, + "width": 405, + "x": 34022, + "y": 1937 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 55, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 554", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 244 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 405, + "x": 0, + "y": 244 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5575": { + "id": "I73:4689;73:5575", + "type": "TEXT", + "fields": { + "parentId": "I73:4689;73:5727", + "absoluteBoundingBox": { + "height": 28, + "width": 405, + "x": 34022, + "y": 1937 + }, + "absoluteRenderBounds": { + "height": 18.0599365234375, + "width": 284, + "x": 34024.05859375, + "y": 1941.6800537109375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "[기업 뉴스] 뉴스 제목이 들어갑니다 3", + "componentPropertyReferences": { + "characters": "제목#47:88" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 20, + "height": 28, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxLines": 1, + "maxWidth": null, + "name": "title", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "ENDING", + "visible": true, + "width": 405, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "[기업 뉴스] 뉴스 제목이 들어갑니다 3", + "end": 22, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4689;73:5574": { + "id": "I73:4689;73:5574", + "type": "TEXT", + "fields": { + "parentId": "I73:4689;73:5727", + "absoluteBoundingBox": { + "height": 21, + "width": 74, + "x": 34022, + "y": 1971 + }, + "absoluteRenderBounds": { + "height": 10.4720458984375, + "width": 72.0078125, + "x": 34022.78515625, + "y": 1976.7099609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2026. 09. 18", + "componentPropertyReferences": { + "characters": "등록일#47:91" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "date", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 34 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 74, + "x": 0, + "y": 34, + "styledTextSegments": [ + { + "characters": "2026. 09. 18", + "end": 12, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4662": { + "id": "73:4662", + "type": "FRAME", + "fields": { + "parentId": "73:4661", + "parentType": "SECTION", + "parentName": "기업 뉴스", + "childrenIds": [ + "73:4686" + ], + "absoluteBoundingBox": { + "height": 1877, + "width": 1440, + "x": 33068, + "y": 1198 + }, + "absoluteRenderBounds": { + "height": 1877, + "width": 1440, + "x": 33068, + "y": 1198 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1877, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "기업 뉴스 · Desktop 1440", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 120 + ], + [ + 0, + 1, + 160 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 120, + "y": 160 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4766": { + "id": "73:4766", + "type": "FRAME", + "fields": { + "parentId": "73:4742", + "childrenIds": [ + "73:4767", + "73:4768", + "73:4769" + ], + "absoluteBoundingBox": { + "height": 1276, + "width": 320, + "x": 34595, + "y": 1564 + }, + "absoluteRenderBounds": { + "height": 1276, + "width": 320, + "x": 34595, + "y": 1564 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 1276, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 32, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 32, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "thumbnail grid (3열 · 9개)", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 97 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 97 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4767": { + "id": "73:4767", + "type": "INSTANCE", + "fields": { + "parentId": "73:4766", + "childrenIds": [ + "I73:4767;73:5570", + "I73:4767;73:5727" + ], + "absoluteBoundingBox": { + "height": 295, + "width": 320, + "x": 34595, + "y": 1564 + }, + "absoluteRenderBounds": { + "height": 295, + "width": 320, + "x": 34595, + "y": 1564 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "등록일#47:91": { + "type": "TEXT", + "value": "2026. 09. 20" + }, + "상단고정#47:94": { + "type": "BOOLEAN", + "value": true + }, + "제목#47:88": { + "type": "TEXT", + "value": "[기업 뉴스] 상단 고정된 뉴스 제목이 들어갑니다" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 295, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "NewsThumbCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:4767", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:4767;73:5570", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I73:4767;73:5571", + "overriddenFields": [ + "visible" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 0, + "mainComponentId": "73:5569", + "mainComponentName": "effect=default", + "mainComponentSetId": "73:5568" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5570": { + "id": "I73:4767;73:5570", + "type": "FRAME", + "fields": { + "parentId": "73:4767", + "childrenIds": [ + "I73:4767;73:5571", + "I73:4767;73:5573" + ], + "absoluteBoundingBox": { + "height": 228, + "width": 320, + "x": 34595, + "y": 1564 + }, + "absoluteRenderBounds": { + "height": 228, + "width": 320, + "x": 34595, + "y": 1564 + }, + "bottomLeftRadius": 16, + "bottomRightRadius": 16, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 16, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "filters": { + "contrast": 0, + "exposure": 0, + "highlights": 0, + "saturation": 0, + "shadows": 0, + "temperature": 0, + "tint": 0 + }, + "imageHash": "10f417552ba021428b7b527137167e596da11f27", + "imageTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "opacity": 1, + "rotation": 0, + "scaleMode": "FILL", + "scalingFactor": 1, + "type": "IMAGE", + "visible": true + } + ], + "height": 228, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "thumbnail", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 16, + "topRightRadius": 16, + "visible": true, + "width": 320, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5571": { + "id": "I73:4767;73:5571", + "type": "TEXT", + "fields": { + "parentId": "I73:4767;73:5570", + "absoluteBoundingBox": { + "height": 20, + "width": 85, + "x": 34752, + "y": 1667.5 + }, + "absoluteRenderBounds": null, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "대표 이미지 16:9", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7843137383460999, + "g": 0.4745098054409027, + "r": 0.20000000298023224 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "thumb label", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 157 + ], + [ + 0, + 1, + 103.5 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": false, + "width": 85, + "x": 157, + "y": 103.5, + "styledTextSegments": [ + { + "characters": "대표 이미지 16:9", + "end": 11, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5573": { + "id": "I73:4767;73:5573", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4767;73:5570", + "childrenIds": [ + "I73:4767;73:5573;73:6826", + "I73:4767;73:5573;32:362" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 59, + "x": 34605, + "y": 1574 + }, + "absoluteRenderBounds": { + "height": 28, + "width": 59, + "x": 34605, + "y": 1574 + }, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "visible": "상단고정#47:94" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "ABSOLUTE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "pin tag", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 10 + ], + [ + 0, + 1, + 10 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 100, + "topRightRadius": 100, + "visible": true, + "width": 59, + "x": 10, + "y": 10, + "mainComponentId": "32:368", + "mainComponentName": "noticeTag", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5573;73:6826": { + "id": "I73:4767;73:5573;73:6826", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4767;73:5573", + "childrenIds": [ + "I73:4767;73:5573;73:6826;73:6818" + ], + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 34613, + "y": 1582 + }, + "absoluteRenderBounds": { + "height": 12, + "width": 12, + "x": 34613, + "y": 1582 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 12, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0.5001888275146484, + "paddingLeft": 0.5, + "paddingRight": 0.4995241165161133, + "paddingTop": 0.5, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "megaphone", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 512, + "y": 512 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 8, + "y": 8, + "mainComponentId": "73:6825", + "mainComponentName": "megaphone", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5573;73:6826;73:6818": { + "id": "I73:4767;73:5573;73:6826;73:6818", + "type": "GROUP", + "fields": { + "parentId": "I73:4767;73:5573;73:6826", + "childrenIds": [ + "I73:4767;73:5573;73:6826;73:6819", + "I73:4767;73:5573;73:6826;73:6821", + "I73:4767;73:5573;73:6826;73:6823" + ], + "absoluteBoundingBox": { + "height": 10.999811172485352, + "width": 11.000475883483888, + "x": 34613.5, + "y": 1582.5 + }, + "absoluteRenderBounds": { + "height": 10.9998779296875, + "width": 11.000475883483888, + "x": 34613.5, + "y": 1582.5 + }, + "height": 10.999811172485352, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 11.000475883483888, + "x": 0.5, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5573;73:6826;73:6819": { + "id": "I73:4767;73:5573;73:6826;73:6819", + "type": "GROUP", + "fields": { + "parentId": "I73:4767;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4767;73:5573;73:6826;73:6820" + ], + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 34613.5, + "y": 1583.1593017578125 + }, + "absoluteRenderBounds": { + "height": 7.1829833984375, + "width": 8.28515625, + "x": 34613.5, + "y": 1583.1593017578125 + }, + "height": 7.182965278625488, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5573;73:6826;73:6820": { + "id": "I73:4767;73:5573;73:6826;73:6820", + "type": "VECTOR", + "fields": { + "parentId": "I73:4767;73:5573;73:6826;73:6819", + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 34613.5, + "y": 1583.1593017578125 + }, + "absoluteRenderBounds": { + "height": 7.1829833984375, + "width": 8.28515625, + "x": 34613.5, + "y": 1583.1593017578125 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.182965278625488, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5573;73:6826;73:6821": { + "id": "I73:4767;73:5573;73:6826;73:6821", + "type": "GROUP", + "fields": { + "parentId": "I73:4767;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4767;73:5573;73:6826;73:6822" + ], + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 34614.40625, + "y": 1588.9888916015625 + }, + "absoluteRenderBounds": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 34614.40625, + "y": 1588.9888916015625 + }, + "height": 4.511041164398193, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5573;73:6826;73:6822": { + "id": "I73:4767;73:5573;73:6826;73:6822", + "type": "VECTOR", + "fields": { + "parentId": "I73:4767;73:5573;73:6826;73:6821", + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 34614.40625, + "y": 1588.9888916015625 + }, + "absoluteRenderBounds": { + "height": 4.510986328125, + "width": 3.78125, + "x": 34614.40625, + "y": 1588.9888916015625 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4.511041164398193, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5573;73:6826;73:6823": { + "id": "I73:4767;73:5573;73:6826;73:6823", + "type": "GROUP", + "fields": { + "parentId": "I73:4767;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4767;73:5573;73:6826;73:6824" + ], + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 34622.48828125, + "y": 1582.5 + }, + "absoluteRenderBounds": { + "height": 8.5015869140625, + "width": 2.0121943950653076, + "x": 34622.48828125, + "y": 1582.5 + }, + "height": 8.501578330993652, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5573;73:6826;73:6824": { + "id": "I73:4767;73:5573;73:6826;73:6824", + "type": "VECTOR", + "fields": { + "parentId": "I73:4767;73:5573;73:6826;73:6823", + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 34622.48828125, + "y": 1582.5 + }, + "absoluteRenderBounds": { + "height": 8.5015869140625, + "width": 2.01171875, + "x": 34622.48828125, + "y": 1582.5 + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 8.501578330993652, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5573;32:362": { + "id": "I73:4767;73:5573;32:362", + "type": "TEXT", + "fields": { + "parentId": "I73:4767;73:5573", + "absoluteBoundingBox": { + "height": 20, + "width": 23, + "x": 34631, + "y": 1578 + }, + "absoluteRenderBounds": { + "height": 11.218994140625, + "width": 21.6953125, + "x": 34631.390625, + "y": 1582.8990478515625 + }, + "characters": "중요", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "중요", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 26 + ], + [ + 0, + 1, + 4 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259,", + "textTruncation": "DISABLED", + "visible": true, + "width": 23, + "x": 26, + "y": 4, + "styledTextSegments": [ + { + "characters": "중요", + "end": 2, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5727": { + "id": "I73:4767;73:5727", + "type": "FRAME", + "fields": { + "parentId": "73:4767", + "childrenIds": [ + "I73:4767;73:5575", + "I73:4767;73:5574" + ], + "absoluteBoundingBox": { + "height": 51, + "width": 320, + "x": 34595, + "y": 1808 + }, + "absoluteRenderBounds": { + "height": 51, + "width": 320, + "x": 34595, + "y": 1808 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 51, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 554", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 244 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 244 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5575": { + "id": "I73:4767;73:5575", + "type": "TEXT", + "fields": { + "parentId": "I73:4767;73:5727", + "absoluteBoundingBox": { + "height": 25, + "width": 320, + "x": 34595, + "y": 1808 + }, + "absoluteRenderBounds": { + "height": 16.2540283203125, + "width": 308.90625, + "x": 34596.85546875, + "y": 1812.31201171875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "[기업 뉴스] 상단 고정된 뉴스 제목이 들어갑니다", + "componentPropertyReferences": { + "characters": "제목#47:88" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 18, + "height": 25, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxLines": 1, + "maxWidth": null, + "name": "title", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "ENDING", + "visible": true, + "width": 320, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "[기업 뉴스] 상단 고정된 뉴스 제목이 들어갑니다", + "end": 27, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4767;73:5574": { + "id": "I73:4767;73:5574", + "type": "TEXT", + "fields": { + "parentId": "I73:4767;73:5727", + "absoluteBoundingBox": { + "height": 20, + "width": 72, + "x": 34595, + "y": 1839 + }, + "absoluteRenderBounds": { + "height": 9.6719970703125, + "width": 69.8125, + "x": 34595.7265625, + "y": 1844.4969482421875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2026. 09. 20", + "componentPropertyReferences": { + "characters": "등록일#47:91" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "date", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 31 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 72, + "x": 0, + "y": 31, + "styledTextSegments": [ + { + "characters": "2026. 09. 20", + "end": 12, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4768": { + "id": "73:4768", + "type": "INSTANCE", + "fields": { + "parentId": "73:4766", + "childrenIds": [ + "I73:4768;73:5570", + "I73:4768;73:5727" + ], + "absoluteBoundingBox": { + "height": 295, + "width": 320, + "x": 34595, + "y": 1891 + }, + "absoluteRenderBounds": { + "height": 295, + "width": 320, + "x": 34595, + "y": 1891 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "등록일#47:91": { + "type": "TEXT", + "value": "2026. 09. 19" + }, + "상단고정#47:94": { + "type": "BOOLEAN", + "value": false + }, + "제목#47:88": { + "type": "TEXT", + "value": "[기업 뉴스] 뉴스 제목이 들어갑니다 2" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 295, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "NewsThumbCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:4768", + "overriddenFields": [ + "height", + "name", + "width" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 327 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 327, + "mainComponentId": "73:5569", + "mainComponentName": "effect=default", + "mainComponentSetId": "73:5568" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5570": { + "id": "I73:4768;73:5570", + "type": "FRAME", + "fields": { + "parentId": "73:4768", + "childrenIds": [ + "I73:4768;73:5571", + "I73:4768;73:5573" + ], + "absoluteBoundingBox": { + "height": 228, + "width": 320, + "x": 34595, + "y": 1891 + }, + "absoluteRenderBounds": { + "height": 228, + "width": 320, + "x": 34595, + "y": 1891 + }, + "bottomLeftRadius": 16, + "bottomRightRadius": 16, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 16, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 228, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "thumbnail", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 16, + "topRightRadius": 16, + "visible": true, + "width": 320, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5571": { + "id": "I73:4768;73:5571", + "type": "TEXT", + "fields": { + "parentId": "I73:4768;73:5570", + "absoluteBoundingBox": { + "height": 20, + "width": 85, + "x": 34712.5, + "y": 1995 + }, + "absoluteRenderBounds": { + "height": 11.4010009765625, + "width": 82.33203125, + "x": 34713.44921875, + "y": 1999.56103515625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "대표 이미지 16:9", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7843137383460999, + "g": 0.4745098054409027, + "r": 0.20000000298023224 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "thumb label", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 117.5 + ], + [ + 0, + 1, + 104 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 85, + "x": 117.5, + "y": 104, + "styledTextSegments": [ + { + "characters": "대표 이미지 16:9", + "end": 11, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5573": { + "id": "I73:4768;73:5573", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4768;73:5570", + "childrenIds": [ + "I73:4768;73:5573;73:6826", + "I73:4768;73:5573;32:362" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 59, + "x": 34605, + "y": 1901 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "visible": "상단고정#47:94" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "ABSOLUTE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "pin tag", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 10 + ], + [ + 0, + 1, + 10 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 100, + "topRightRadius": 100, + "visible": false, + "width": 59, + "x": 10, + "y": 10, + "mainComponentId": "32:368", + "mainComponentName": "noticeTag", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5573;73:6826": { + "id": "I73:4768;73:5573;73:6826", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4768;73:5573", + "childrenIds": [ + "I73:4768;73:5573;73:6826;73:6818" + ], + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 34613, + "y": 1909 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 12, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0.5001888275146484, + "paddingLeft": 0.5, + "paddingRight": 0.4995241165161133, + "paddingTop": 0.5, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "megaphone", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 512, + "y": 512 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 8, + "y": 8, + "mainComponentId": "73:6825", + "mainComponentName": "megaphone", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5573;73:6826;73:6818": { + "id": "I73:4768;73:5573;73:6826;73:6818", + "type": "GROUP", + "fields": { + "parentId": "I73:4768;73:5573;73:6826", + "childrenIds": [ + "I73:4768;73:5573;73:6826;73:6819", + "I73:4768;73:5573;73:6826;73:6821", + "I73:4768;73:5573;73:6826;73:6823" + ], + "absoluteBoundingBox": { + "height": 10.999811172485352, + "width": 11.000475883483888, + "x": 34613.5, + "y": 1909.5 + }, + "absoluteRenderBounds": null, + "height": 10.999811172485352, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 11.000475883483888, + "x": 0.5, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5573;73:6826;73:6819": { + "id": "I73:4768;73:5573;73:6826;73:6819", + "type": "GROUP", + "fields": { + "parentId": "I73:4768;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4768;73:5573;73:6826;73:6820" + ], + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 34613.5, + "y": 1910.1593017578125 + }, + "absoluteRenderBounds": null, + "height": 7.182965278625488, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5573;73:6826;73:6820": { + "id": "I73:4768;73:5573;73:6826;73:6820", + "type": "VECTOR", + "fields": { + "parentId": "I73:4768;73:5573;73:6826;73:6819", + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 34613.5, + "y": 1910.1593017578125 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.182965278625488, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5573;73:6826;73:6821": { + "id": "I73:4768;73:5573;73:6826;73:6821", + "type": "GROUP", + "fields": { + "parentId": "I73:4768;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4768;73:5573;73:6826;73:6822" + ], + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 34614.40625, + "y": 1915.9888916015625 + }, + "absoluteRenderBounds": null, + "height": 4.511041164398193, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5573;73:6826;73:6822": { + "id": "I73:4768;73:5573;73:6826;73:6822", + "type": "VECTOR", + "fields": { + "parentId": "I73:4768;73:5573;73:6826;73:6821", + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 34614.40625, + "y": 1915.9888916015625 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4.511041164398193, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5573;73:6826;73:6823": { + "id": "I73:4768;73:5573;73:6826;73:6823", + "type": "GROUP", + "fields": { + "parentId": "I73:4768;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4768;73:5573;73:6826;73:6824" + ], + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 34622.48828125, + "y": 1909.5 + }, + "absoluteRenderBounds": null, + "height": 8.501578330993652, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5573;73:6826;73:6824": { + "id": "I73:4768;73:5573;73:6826;73:6824", + "type": "VECTOR", + "fields": { + "parentId": "I73:4768;73:5573;73:6826;73:6823", + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 34622.48828125, + "y": 1909.5 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 8.501578330993652, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5573;32:362": { + "id": "I73:4768;73:5573;32:362", + "type": "TEXT", + "fields": { + "parentId": "I73:4768;73:5573", + "absoluteBoundingBox": { + "height": 20, + "width": 23, + "x": 34631, + "y": 1905 + }, + "absoluteRenderBounds": null, + "characters": "중요", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "중요", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 26 + ], + [ + 0, + 1, + 4 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259,", + "textTruncation": "DISABLED", + "visible": true, + "width": 23, + "x": 26, + "y": 4, + "styledTextSegments": [ + { + "characters": "중요", + "end": 2, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5727": { + "id": "I73:4768;73:5727", + "type": "FRAME", + "fields": { + "parentId": "73:4768", + "childrenIds": [ + "I73:4768;73:5575", + "I73:4768;73:5574" + ], + "absoluteBoundingBox": { + "height": 51, + "width": 320, + "x": 34595, + "y": 2135 + }, + "absoluteRenderBounds": { + "height": 51, + "width": 320, + "x": 34595, + "y": 2135 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 51, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 554", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 244 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 244 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5575": { + "id": "I73:4768;73:5575", + "type": "TEXT", + "fields": { + "parentId": "I73:4768;73:5727", + "absoluteBoundingBox": { + "height": 25, + "width": 320, + "x": 34595, + "y": 2135 + }, + "absoluteRenderBounds": { + "height": 16.25390625, + "width": 255.34765625, + "x": 34596.85546875, + "y": 2139.31201171875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "[기업 뉴스] 뉴스 제목이 들어갑니다 2", + "componentPropertyReferences": { + "characters": "제목#47:88" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 18, + "height": 25, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxLines": 1, + "maxWidth": null, + "name": "title", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "ENDING", + "visible": true, + "width": 320, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "[기업 뉴스] 뉴스 제목이 들어갑니다 2", + "end": 22, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4768;73:5574": { + "id": "I73:4768;73:5574", + "type": "TEXT", + "fields": { + "parentId": "I73:4768;73:5727", + "absoluteBoundingBox": { + "height": 20, + "width": 69, + "x": 34595, + "y": 2166 + }, + "absoluteRenderBounds": { + "height": 9.671875, + "width": 66.9453125, + "x": 34595.7265625, + "y": 2171.4970703125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2026. 09. 19", + "componentPropertyReferences": { + "characters": "등록일#47:91" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "date", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 31 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 69, + "x": 0, + "y": 31, + "styledTextSegments": [ + { + "characters": "2026. 09. 19", + "end": 12, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4769": { + "id": "73:4769", + "type": "INSTANCE", + "fields": { + "parentId": "73:4766", + "childrenIds": [ + "I73:4769;73:5570", + "I73:4769;73:5727" + ], + "absoluteBoundingBox": { + "height": 295, + "width": 320, + "x": 34595, + "y": 2218 + }, + "absoluteRenderBounds": { + "height": 295, + "width": 320, + "x": 34595, + "y": 2218 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + }, + "등록일#47:91": { + "type": "TEXT", + "value": "2026. 09. 18" + }, + "상단고정#47:94": { + "type": "BOOLEAN", + "value": false + }, + "제목#47:88": { + "type": "TEXT", + "value": "[기업 뉴스] 뉴스 제목이 들어갑니다 3" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 295, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "NewsThumbCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:4769", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:4769;73:5571", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 654 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 654, + "mainComponentId": "73:5569", + "mainComponentName": "effect=default", + "mainComponentSetId": "73:5568" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5570": { + "id": "I73:4769;73:5570", + "type": "FRAME", + "fields": { + "parentId": "73:4769", + "childrenIds": [ + "I73:4769;73:5571", + "I73:4769;73:5573" + ], + "absoluteBoundingBox": { + "height": 228, + "width": 320, + "x": 34595, + "y": 2218 + }, + "absoluteRenderBounds": { + "height": 228, + "width": 320, + "x": 34595, + "y": 2218 + }, + "bottomLeftRadius": 16, + "bottomRightRadius": 16, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 16, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 228, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "thumbnail", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 16, + "topRightRadius": 16, + "visible": true, + "width": 320, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5571": { + "id": "I73:4769;73:5571", + "type": "TEXT", + "fields": { + "parentId": "I73:4769;73:5570", + "absoluteBoundingBox": { + "height": 20, + "width": 132, + "x": 34689, + "y": 2322 + }, + "absoluteRenderBounds": { + "height": 11.40087890625, + "width": 129.171875, + "x": 34689.53125, + "y": 2326.56103515625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "등록된 이미지가 없습니다.", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7843137383460999, + "g": 0.4745098054409027, + "r": 0.20000000298023224 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "thumb label", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 94 + ], + [ + 0, + 1, + 104 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 132, + "x": 94, + "y": 104, + "styledTextSegments": [ + { + "characters": "등록된 이미지가 없습니다.", + "end": 14, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5573": { + "id": "I73:4769;73:5573", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4769;73:5570", + "childrenIds": [ + "I73:4769;73:5573;73:6826", + "I73:4769;73:5573;32:362" + ], + "absoluteBoundingBox": { + "height": 28, + "width": 59, + "x": 34605, + "y": 2228 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 100, + "bottomRightRadius": 100, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "visible": "상단고정#47:94" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 100, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 28, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "ABSOLUTE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "pin tag", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 4, + "paddingLeft": 8, + "paddingRight": 10, + "paddingTop": 4, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 10 + ], + [ + 0, + 1, + 10 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 100, + "topRightRadius": 100, + "visible": false, + "width": 59, + "x": 10, + "y": 10, + "mainComponentId": "32:368", + "mainComponentName": "noticeTag", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5573;73:6826": { + "id": "I73:4769;73:5573;73:6826", + "type": "INSTANCE", + "fields": { + "parentId": "I73:4769;73:5573", + "childrenIds": [ + "I73:4769;73:5573;73:6826;73:6818" + ], + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 34613, + "y": 2236 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 12, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0.5001888275146484, + "paddingLeft": 0.5, + "paddingRight": 0.4995241165161133, + "paddingTop": 0.5, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "megaphone", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 8 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 512, + "y": 512 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 8, + "y": 8, + "mainComponentId": "73:6825", + "mainComponentName": "megaphone", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5573;73:6826;73:6818": { + "id": "I73:4769;73:5573;73:6826;73:6818", + "type": "GROUP", + "fields": { + "parentId": "I73:4769;73:5573;73:6826", + "childrenIds": [ + "I73:4769;73:5573;73:6826;73:6819", + "I73:4769;73:5573;73:6826;73:6821", + "I73:4769;73:5573;73:6826;73:6823" + ], + "absoluteBoundingBox": { + "height": 10.999811172485352, + "width": 11.000475883483888, + "x": 34613.5, + "y": 2236.5 + }, + "absoluteRenderBounds": null, + "height": 10.999811172485352, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 11.000475883483888, + "x": 0.5, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5573;73:6826;73:6819": { + "id": "I73:4769;73:5573;73:6826;73:6819", + "type": "GROUP", + "fields": { + "parentId": "I73:4769;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4769;73:5573;73:6826;73:6820" + ], + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 34613.5, + "y": 2237.1591796875 + }, + "absoluteRenderBounds": null, + "height": 7.182965278625488, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5573;73:6826;73:6820": { + "id": "I73:4769;73:5573;73:6826;73:6820", + "type": "VECTOR", + "fields": { + "parentId": "I73:4769;73:5573;73:6826;73:6819", + "absoluteBoundingBox": { + "height": 7.182965278625488, + "width": 8.283529281616211, + "x": 34613.5, + "y": 2237.1591796875 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.182965278625488, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0.5 + ], + [ + 0, + 1, + 1.1593017578125 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 8.283529281616211, + "x": 0.5, + "y": 1.1593017578125 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5573;73:6826;73:6821": { + "id": "I73:4769;73:5573;73:6826;73:6821", + "type": "GROUP", + "fields": { + "parentId": "I73:4769;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4769;73:5573;73:6826;73:6822" + ], + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 34614.40625, + "y": 2242.98876953125 + }, + "absoluteRenderBounds": null, + "height": 4.511041164398193, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5573;73:6826;73:6822": { + "id": "I73:4769;73:5573;73:6826;73:6822", + "type": "VECTOR", + "fields": { + "parentId": "I73:4769;73:5573;73:6826;73:6821", + "absoluteBoundingBox": { + "height": 4.511041164398193, + "width": 3.78228759765625, + "x": 34614.40625, + "y": 2242.98876953125 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4.511041164398193, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 1.4054877758026123 + ], + [ + 0, + 1, + 6.9888916015625 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 3.78228759765625, + "x": 1.4054877758026123, + "y": 6.9888916015625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5573;73:6826;73:6823": { + "id": "I73:4769;73:5573;73:6826;73:6823", + "type": "GROUP", + "fields": { + "parentId": "I73:4769;73:5573;73:6826;73:6818", + "childrenIds": [ + "I73:4769;73:5573;73:6826;73:6824" + ], + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 34622.48828125, + "y": 2236.5 + }, + "absoluteRenderBounds": null, + "height": 8.501578330993652, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5573;73:6826;73:6824": { + "id": "I73:4769;73:5573;73:6826;73:6824", + "type": "VECTOR", + "fields": { + "parentId": "I73:4769;73:5573;73:6826;73:6823", + "absoluteBoundingBox": { + "height": 8.501578330993652, + "width": 2.0121943950653076, + "x": 34622.48828125, + "y": 2236.5 + }, + "absoluteRenderBounds": null, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 8.501578330993652, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 9.487805366516112 + ], + [ + 0, + 1, + 0.5 + ] + ], + "strokeWeight": 15.058823585510254, + "visible": true, + "width": 2.0121943950653076, + "x": 9.487805366516112, + "y": 0.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5573;32:362": { + "id": "I73:4769;73:5573;32:362", + "type": "TEXT", + "fields": { + "parentId": "I73:4769;73:5573", + "absoluteBoundingBox": { + "height": 20, + "width": 23, + "x": 34631, + "y": 2232 + }, + "absoluteRenderBounds": null, + "characters": "중요", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "중요", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 26 + ], + [ + 0, + 1, + 4 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259,", + "textTruncation": "DISABLED", + "visible": true, + "width": 23, + "x": 26, + "y": 4, + "styledTextSegments": [ + { + "characters": "중요", + "end": 2, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:6ff02d889b5b4f9b91a3306f15c43ad1d19ee259," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5727": { + "id": "I73:4769;73:5727", + "type": "FRAME", + "fields": { + "parentId": "73:4769", + "childrenIds": [ + "I73:4769;73:5575", + "I73:4769;73:5574" + ], + "absoluteBoundingBox": { + "height": 51, + "width": 320, + "x": 34595, + "y": 2462 + }, + "absoluteRenderBounds": { + "height": 51, + "width": 320, + "x": 34595, + "y": 2462 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 51, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 6, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 554", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 244 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 244 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5575": { + "id": "I73:4769;73:5575", + "type": "TEXT", + "fields": { + "parentId": "I73:4769;73:5727", + "absoluteBoundingBox": { + "height": 25, + "width": 320, + "x": 34595, + "y": 2462 + }, + "absoluteRenderBounds": { + "height": 16.25390625, + "width": 255.59765625, + "x": 34596.85546875, + "y": 2466.31201171875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:950", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "[기업 뉴스] 뉴스 제목이 들어갑니다 3", + "componentPropertyReferences": { + "characters": "제목#47:88" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 18, + "height": 25, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxLines": 1, + "maxWidth": null, + "name": "title", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a,", + "textTruncation": "ENDING", + "visible": true, + "width": 320, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "[기업 뉴스] 뉴스 제목이 들어갑니다 3", + "end": 22, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:0b5158f29530255590daed174bb87c3a635c314a," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:4769;73:5574": { + "id": "I73:4769;73:5574", + "type": "TEXT", + "fields": { + "parentId": "I73:4769;73:5727", + "absoluteBoundingBox": { + "height": 20, + "width": 69, + "x": 34595, + "y": 2493 + }, + "absoluteRenderBounds": { + "height": 9.723876953125, + "width": 66.8671875, + "x": 34595.7265625, + "y": 2498.445068359375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:956", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2026. 09. 18", + "componentPropertyReferences": { + "characters": "등록일#47:91" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 13, + "height": 20, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "date", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 31 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f,", + "textTruncation": "DISABLED", + "visible": true, + "width": 69, + "x": 0, + "y": 31, + "styledTextSegments": [ + { + "characters": "2026. 09. 18", + "end": 12, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:b24dbe253b56eab7caafdd82199d1cfb1188d02f," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:4742": { + "id": "73:4742", + "type": "FRAME", + "fields": { + "parentId": "73:4661", + "parentType": "SECTION", + "parentName": "기업 뉴스", + "childrenIds": [ + "73:4766" + ], + "absoluteBoundingBox": { + "height": 2029, + "width": 360, + "x": 34575, + "y": 1198 + }, + "absoluteRenderBounds": { + "height": 2029, + "width": 360, + "x": 34575, + "y": 1198 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 2029, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "기업 뉴스 · Mobile 360", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 1627 + ], + [ + 0, + 1, + 160 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 1627, + "y": 160 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/oa-media-size-aliases.json b/crates/devup-mcp-devup-ui/tests/fixtures/oa-media-size-aliases.json new file mode 100644 index 00000000..c9a66b90 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/oa-media-size-aliases.json @@ -0,0 +1,372 @@ +{ + "fileKey": "oa-captured-size-constraint-regression", + "roots": [ + "mobile", + "desktop" + ], + "nodes": { + "73:9310": { + "id": "73:9310", + "type": "FRAME", + "fields": { + "parentId": "desktop", + "absoluteBoundingBox": { + "height": 560, + "width": 560, + "x": 23286, + "y": 8982 + }, + "absoluteRenderBounds": { + "height": 560, + "width": 560, + "x": 23286, + "y": 8982 + }, + "bottomLeftRadius": 24, + "bottomRightRadius": 24, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 24, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "filters": { + "contrast": 0, + "exposure": 0, + "highlights": 0, + "saturation": 0, + "shadows": 0, + "temperature": 0, + "tint": 0 + }, + "imageHash": "a68b88adaa0b7b4858328d09295d8066747e2d2b", + "imageTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "opacity": 1, + "rotation": 0, + "scaleMode": "FILL", + "scalingFactor": 1, + "type": "IMAGE", + "visible": true + }, + { + "blendMode": "NORMAL", + "filters": { + "contrast": 0, + "exposure": 0, + "highlights": 0, + "saturation": 0, + "shadows": 0, + "temperature": 0, + "tint": 0 + }, + "imageHash": "4ad66daf3d3a965fc153b8b5e36cc4f679e6eae6", + "imageTransform": [ + [ + 0.8333333134651184, + 0, + 0.0833333358168602 + ], + [ + 0, + 1, + 0 + ] + ], + "opacity": 1, + "rotation": 0, + "scaleMode": "FILL", + "scalingFactor": 0.5, + "type": "IMAGE", + "visible": true + } + ], + "height": 560, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 20, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 20, + "primaryAxisAlignItems": "MAX", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "media", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 20, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 20, + "primaryAxisAlignItems": "MAX", + "relativeTransform": [ + [ + 1, + 0, + 720 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 24, + "topRightRadius": 24, + "visible": true, + "width": 560, + "x": 720, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "desktop": { + "id": "desktop", + "type": "FRAME", + "fields": { + "name": "desktop", + "width": 1440, + "height": 800, + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "paddingLeft": 80, + "paddingRight": 80, + "paddingTop": 0, + "paddingBottom": 0, + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "counterAxisAlignItems": "MIN", + "paddingTop": 0, + "paddingBottom": 0, + "paddingLeft": 80, + "paddingRight": 80 + }, + "childrenIds": [ + "73:9310" + ] + } + }, + "73:9401": { + "id": "73:9401", + "type": "FRAME", + "fields": { + "parentId": "mobile", + "absoluteBoundingBox": { + "height": 240, + "width": 320, + "x": 23979, + "y": 8768 + }, + "absoluteRenderBounds": { + "height": 240, + "width": 320, + "x": 23979, + "y": 8768 + }, + "bottomLeftRadius": 16, + "bottomRightRadius": 16, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 16, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "filters": { + "contrast": 0, + "exposure": 0, + "highlights": 0, + "saturation": 0, + "shadows": 0, + "temperature": 0, + "tint": 0 + }, + "imageHash": "a68b88adaa0b7b4858328d09295d8066747e2d2b", + "imageTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "opacity": 1, + "rotation": 0, + "scaleMode": "FILL", + "scalingFactor": 1, + "type": "IMAGE", + "visible": true + }, + { + "blendMode": "NORMAL", + "filters": { + "contrast": 0, + "exposure": 0, + "highlights": 0, + "saturation": 0, + "shadows": 0, + "temperature": 0, + "tint": 0 + }, + "imageHash": "4ad66daf3d3a965fc153b8b5e36cc4f679e6eae6", + "imageTransform": [ + [ + 0.8333333134651184, + 0, + 0.0833333358168602 + ], + [ + 0, + 1, + 0 + ] + ], + "opacity": 1, + "rotation": 0, + "scaleMode": "FILL", + "scalingFactor": 0.5, + "type": "IMAGE", + "visible": true + } + ], + "height": 240, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 12, + "paddingLeft": 12, + "paddingRight": 12, + "paddingTop": 12, + "primaryAxisAlignItems": "MAX", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "media", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 12, + "paddingLeft": 12, + "paddingRight": 12, + "paddingTop": 12, + "primaryAxisAlignItems": "MAX", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 16, + "topRightRadius": 16, + "visible": true, + "width": 320, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "mobile": { + "id": "mobile", + "type": "FRAME", + "fields": { + "name": "mobile", + "width": 360, + "height": 800, + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 0, + "paddingBottom": 0, + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "counterAxisAlignItems": "MIN", + "paddingTop": 0, + "paddingBottom": 0, + "paddingLeft": 20, + "paddingRight": 20 + }, + "childrenIds": [ + "73:9401" + ] + } + } + }, + "diagnostics": [] +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/oa-overview-child-order.json b/crates/devup-mcp-devup-ui/tests/fixtures/oa-overview-child-order.json new file mode 100644 index 00000000..399c7a88 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/oa-overview-child-order.json @@ -0,0 +1,180 @@ +{ + "roots": [ + "73:9282", + "73:9393" + ], + "nodes": { + "73:9289": { + "id": "73:9289", + "type": "FRAME", + "fields": { + "parentId": "73:9288", + "absoluteBoundingBox": { + "height": 560, + "width": 1280, + "x": 22566, + "y": 8982 + }, + "height": 560, + "itemSpacing": 80, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "name": "container", + "visible": true, + "width": 1280, + "childrenIds": [ + "73:9290", + "73:9310" + ] + } + }, + "73:9290": { + "id": "73:9290", + "type": "FRAME", + "fields": { + "parentId": "73:9289", + "absoluteBoundingBox": { + "height": 466, + "width": 640, + "x": 22566, + "y": 8982 + }, + "height": 466, + "itemSpacing": 40, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "name": "text", + "visible": true, + "width": 640 + } + }, + "73:9310": { + "id": "73:9310", + "type": "FRAME", + "fields": { + "parentId": "73:9289", + "absoluteBoundingBox": { + "height": 560, + "width": 560, + "x": 23286, + "y": 8982 + }, + "height": 560, + "itemSpacing": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "name": "media", + "primaryAxisAlignItems": "MAX", + "visible": true, + "width": 560 + } + }, + "73:9400": { + "id": "73:9400", + "type": "FRAME", + "fields": { + "parentId": "73:9399", + "absoluteBoundingBox": { + "height": 895, + "width": 320, + "x": 23979, + "y": 8768 + }, + "height": 895, + "itemSpacing": 24, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "name": "container", + "visible": true, + "width": 320, + "childrenIds": [ + "73:9401", + "73:9404" + ] + } + }, + "73:9401": { + "id": "73:9401", + "type": "FRAME", + "fields": { + "parentId": "73:9400", + "absoluteBoundingBox": { + "height": 240, + "width": 320, + "x": 23979, + "y": 8768 + }, + "height": 240, + "itemSpacing": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "name": "media", + "primaryAxisAlignItems": "MAX", + "visible": true, + "width": 320 + } + }, + "73:9404": { + "id": "73:9404", + "type": "FRAME", + "fields": { + "parentId": "73:9400", + "absoluteBoundingBox": { + "height": 631, + "width": 320, + "x": 23979, + "y": 9032 + }, + "height": 631, + "itemSpacing": 32, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "name": "text", + "visible": true, + "width": 320 + } + }, + "73:9282": { + "id": "73:9282", + "type": "FRAME", + "fields": { + "name": "Page", + "width": 1440, + "height": 3703.769287109375, + "layoutMode": "VERTICAL", + "childrenIds": [ + "73:9289" + ] + } + }, + "73:9393": { + "id": "73:9393", + "type": "FRAME", + "fields": { + "name": "Page", + "width": 360, + "height": 4238.23095703125, + "layoutMode": "VERTICAL", + "childrenIds": [ + "73:9400" + ] + } + } + }, + "captureScope": "Bounded actual OA Overview container/direct-child layout fields; child content omitted to isolate sibling ordering.", + "fileKey": "captured-oa-overview-order", + "nodeId": "73:9289", + "diagnostics": [] +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/oa-pagemenu-paints.json b/crates/devup-mcp-devup-ui/tests/fixtures/oa-pagemenu-paints.json new file mode 100644 index 00000000..a633595a --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/oa-pagemenu-paints.json @@ -0,0 +1,3126 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "73:8466", + "73:8525" + ], + "nodes": { + "73:8466": { + "id": "73:8466", + "type": "INSTANCE", + "fields": { + "childrenIds": [ + "I73:8466;40:643" + ], + "absoluteBoundingBox": { + "height": 67, + "width": 1440, + "x": 36792, + "y": 8891 + }, + "absoluteRenderBounds": { + "height": 67, + "width": 1440, + "x": 36792, + "y": 8891 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "Slot#44:11": { + "preferredValues": [], + "type": "SLOT" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Desktop" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 67, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "desktop", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 316 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "viewport": "Desktop" + }, + "visible": true, + "width": 1440, + "x": 0, + "y": 316, + "mainComponentId": "40:650", + "mainComponentName": "viewport=Desktop", + "mainComponentSetId": "44:5410" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643": { + "id": "I73:8466;40:643", + "type": "SLOT", + "fields": { + "parentId": "73:8466", + "childrenIds": [ + "I73:8466;40:643;73:8461", + "I73:8466;40:643;73:8462", + "I73:8466;40:643;73:8463", + "I73:8466;40:643;73:8464", + "I73:8466;40:643;73:8465" + ], + "absoluteBoundingBox": { + "height": 67, + "width": 950, + "x": 37037, + "y": 8891 + }, + "absoluteRenderBounds": { + "height": 67, + "width": 950, + "x": 37037, + "y": 8891 + }, + "bottomLeftRadius": 10000, + "bottomRightRadius": 10000, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + ], + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentPropertyReferences": { + "slotContentId": "Slot#44:11" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 10000, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9764705896377563 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 67, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 8, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 1280, + "name": "Slot", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 8, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 8, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 245 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": true, + "topLeftRadius": 10000, + "topRightRadius": 10000, + "visible": true, + "width": 950, + "x": 245, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643;73:8461": { + "id": "I73:8466;40:643;73:8461", + "type": "INSTANCE", + "fields": { + "parentId": "I73:8466;40:643", + "childrenIds": [ + "I73:8466;40:643;73:8461;40:610" + ], + "absoluteBoundingBox": { + "height": 49, + "width": 180, + "x": 37046, + "y": 8900 + }, + "absoluteRenderBounds": { + "height": 49, + "width": 180, + "x": 37046, + "y": 8900 + }, + "bottomLeftRadius": 1000, + "bottomRightRadius": 1000, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 1000, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 49, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 핵심 기술", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I73:8466;40:643;73:8461", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:8466;40:643;73:8461;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 9 + ], + [ + 0, + 1, + 9 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 1000, + "topRightRadius": 1000, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 180, + "x": 9, + "y": 9, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643;73:8461;40:610": { + "id": "I73:8466;40:643;73:8461;40:610", + "type": "TEXT", + "fields": { + "parentId": "I73:8466;40:643;73:8461", + "absoluteBoundingBox": { + "height": 17, + "width": 62, + "x": 37105, + "y": 8916 + }, + "absoluteRenderBounds": { + "height": 15.146484375, + "width": 60.90234375, + "x": 37105.48046875, + "y": 8917.23046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "핵심 기술", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 59 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 62, + "x": 59, + "y": 16, + "styledTextSegments": [ + { + "characters": "핵심 기술", + "end": 5, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643;73:8462": { + "id": "I73:8466;40:643;73:8462", + "type": "INSTANCE", + "fields": { + "parentId": "I73:8466;40:643", + "childrenIds": [ + "I73:8466;40:643;73:8462;40:610" + ], + "absoluteBoundingBox": { + "height": 49, + "width": 180, + "x": 37234, + "y": 8900 + }, + "absoluteRenderBounds": { + "height": 49, + "width": 180, + "x": 37234, + "y": 8900 + }, + "bottomLeftRadius": 1000, + "bottomRightRadius": 1000, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 1000, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 49, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 연구개발 분야", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I73:8466;40:643;73:8462", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:8466;40:643;73:8462;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 197 + ], + [ + 0, + 1, + 9 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 1000, + "topRightRadius": 1000, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 180, + "x": 197, + "y": 9, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643;73:8462;40:610": { + "id": "I73:8466;40:643;73:8462;40:610", + "type": "TEXT", + "fields": { + "parentId": "I73:8466;40:643;73:8462", + "absoluteBoundingBox": { + "height": 17, + "width": 92, + "x": 37278, + "y": 8916 + }, + "absoluteRenderBounds": { + "height": 15.1630859375, + "width": 89.95703125, + "x": 37279.25, + "y": 8917.23046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연구개발 분야", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 44 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 92, + "x": 44, + "y": 16, + "styledTextSegments": [ + { + "characters": "연구개발 분야", + "end": 7, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643;73:8463": { + "id": "I73:8466;40:643;73:8463", + "type": "INSTANCE", + "fields": { + "parentId": "I73:8466;40:643", + "childrenIds": [ + "I73:8466;40:643;73:8463;40:608" + ], + "absoluteBoundingBox": { + "height": 49, + "width": 180, + "x": 37422, + "y": 8900 + }, + "absoluteRenderBounds": { + "height": 49, + "width": 180, + "x": 37422, + "y": 8900 + }, + "bottomLeftRadius": 1000, + "bottomRightRadius": 1000, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ], + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "selected" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 1000, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 49, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 골관절염/연골재생", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I73:8466;40:643;73:8463", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:8466;40:643;73:8463;40:608", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 385 + ], + [ + 0, + 1, + 9 + ] + ], + "strokeBottomWeight": 3, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokesIncludedInLayout": true, + "topLeftRadius": 1000, + "topRightRadius": 1000, + "variantProperties": { + "effect": "selected" + }, + "visible": true, + "width": 180, + "x": 385, + "y": 9, + "mainComponentId": "40:615", + "mainComponentName": "effect=selected", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643;73:8463;40:608": { + "id": "I73:8466;40:643;73:8463;40:608", + "type": "TEXT", + "fields": { + "parentId": "I73:8466;40:643;73:8463", + "absoluteBoundingBox": { + "height": 17, + "width": 124, + "x": 37450, + "y": 8916 + }, + "absoluteRenderBounds": { + "height": 15.3173828125, + "width": 122.1484375, + "x": 37450.578125, + "y": 8917.14453125 + }, + "boundVariables": { + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "골관절염/연골재생", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 28 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:4233490d967b21346e274bd97badac8375c1ed19,", + "textTruncation": "DISABLED", + "visible": true, + "width": 124, + "x": 28, + "y": 16, + "styledTextSegments": [ + { + "characters": "골관절염/연골재생", + "end": 9, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:4233490d967b21346e274bd97badac8375c1ed19," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643;73:8464": { + "id": "I73:8466;40:643;73:8464", + "type": "INSTANCE", + "fields": { + "parentId": "I73:8466;40:643", + "childrenIds": [ + "I73:8466;40:643;73:8464;40:610" + ], + "absoluteBoundingBox": { + "height": 49, + "width": 180, + "x": 37610, + "y": 8900 + }, + "absoluteRenderBounds": { + "height": 49, + "width": 180, + "x": 37610, + "y": 8900 + }, + "bottomLeftRadius": 1000, + "bottomRightRadius": 1000, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 1000, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 49, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 특허/지식재산권", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I73:8466;40:643;73:8464", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:8466;40:643;73:8464;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 573 + ], + [ + 0, + 1, + 9 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 1000, + "topRightRadius": 1000, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 180, + "x": 573, + "y": 9, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643;73:8464;40:610": { + "id": "I73:8466;40:643;73:8464;40:610", + "type": "TEXT", + "fields": { + "parentId": "I73:8466;40:643;73:8464", + "absoluteBoundingBox": { + "height": 17, + "width": 109, + "x": 37645.5, + "y": 8916 + }, + "absoluteRenderBounds": { + "height": 15.146484375, + "width": 107.1484375, + "x": 37646.078125, + "y": 8917.23046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "특허/지식재산권", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 35.5 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 109, + "x": 35.5, + "y": 16, + "styledTextSegments": [ + { + "characters": "특허/지식재산권", + "end": 8, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643;73:8465": { + "id": "I73:8466;40:643;73:8465", + "type": "INSTANCE", + "fields": { + "parentId": "I73:8466;40:643", + "childrenIds": [ + "I73:8466;40:643;73:8465;40:610" + ], + "absoluteBoundingBox": { + "height": 49, + "width": 180, + "x": 37798, + "y": 8900 + }, + "absoluteRenderBounds": { + "height": 49, + "width": 180, + "x": 37798, + "y": 8900 + }, + "bottomLeftRadius": 1000, + "bottomRightRadius": 1000, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 1000, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 49, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 연구성과/주요실적", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "I73:8466;40:643;73:8465", + "overriddenFields": [ + "height", + "name", + "width" + ] + }, + { + "id": "I73:8466;40:643;73:8465;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 24, + "paddingRight": 24, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 761 + ], + [ + 0, + 1, + 9 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 1000, + "topRightRadius": 1000, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 180, + "x": 761, + "y": 9, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8466;40:643;73:8465;40:610": { + "id": "I73:8466;40:643;73:8465;40:610", + "type": "TEXT", + "fields": { + "parentId": "I73:8466;40:643;73:8465", + "absoluteBoundingBox": { + "height": 17, + "width": 124, + "x": 37826, + "y": 8916 + }, + "absoluteRenderBounds": { + "height": 15.1630859375, + "width": 120.984375, + "x": 37827.0234375, + "y": 8917.23046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연구성과/주요실적", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 17, + "height": 17, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 28 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 124, + "x": 28, + "y": 16, + "styledTextSegments": [ + { + "characters": "연구성과/주요실적", + "end": 9, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8525": { + "id": "73:8525", + "type": "FRAME", + "fields": { + "childrenIds": [ + "73:8526" + ], + "absoluteBoundingBox": { + "height": 47, + "width": 360, + "x": 38352, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 47, + "width": 360, + "x": 38352, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + ], + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9764705896377563 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 47, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 1, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "mobile", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 202 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": false, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 0, + "y": 202 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8526": { + "id": "73:8526", + "type": "FRAME", + "fields": { + "parentId": "73:8525", + "childrenIds": [ + "73:8527", + "73:8528", + "73:8529", + "73:8530", + "73:8531" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 582, + "x": 38201, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 360, + "x": 38352, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "tabs (가로 스크롤 · 현재 탭이 보이도록 스크롤된 상태)", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + -151 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 582, + "x": -151, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8527": { + "id": "73:8527", + "type": "INSTANCE", + "fields": { + "parentId": "73:8526", + "childrenIds": [ + "I73:8527;40:610" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 84, + "x": 38201, + "y": 8777 + }, + "absoluteRenderBounds": null, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 핵심 기술", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:8527", + "overriddenFields": [ + "height", + "name", + "paddingLeft", + "paddingRight", + "primaryAxisSizingMode", + "width" + ] + }, + { + "id": "I73:8527;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 84, + "x": 0, + "y": 0, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8527;40:610": { + "id": "I73:8527;40:610", + "type": "TEXT", + "fields": { + "parentId": "73:8527", + "absoluteBoundingBox": { + "height": 14, + "width": 52, + "x": 38217, + "y": 8793 + }, + "absoluteRenderBounds": null, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "핵심 기술", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 16 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 52, + "x": 16, + "y": 16, + "styledTextSegments": [ + { + "characters": "핵심 기술", + "end": 5, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8528": { + "id": "73:8528", + "type": "INSTANCE", + "fields": { + "parentId": "73:8526", + "childrenIds": [ + "I73:8528;40:610" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 107, + "x": 38285, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 40, + "x": 38352, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 연구개발 분야", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:8528", + "overriddenFields": [ + "height", + "name", + "paddingLeft", + "paddingRight", + "primaryAxisSizingMode", + "width" + ] + }, + { + "id": "I73:8528;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 84 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 107, + "x": 84, + "y": 0, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8528;40:610": { + "id": "I73:8528;40:610", + "type": "TEXT", + "fields": { + "parentId": "73:8528", + "absoluteBoundingBox": { + "height": 14, + "width": 75, + "x": 38301, + "y": 8793 + }, + "absoluteRenderBounds": { + "height": 12.48828125, + "width": 23.73046875, + "x": 38352, + "y": 8793.66015625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연구개발 분야", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 16 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 75, + "x": 16, + "y": 16, + "styledTextSegments": [ + { + "characters": "연구개발 분야", + "end": 7, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8529": { + "id": "73:8529", + "type": "INSTANCE", + "fields": { + "parentId": "73:8526", + "childrenIds": [ + "I73:8529;40:608" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 135, + "x": 38392, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 135, + "x": 38392, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ], + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "selected" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 골관절염/연골재생", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:8529", + "overriddenFields": [ + "height", + "name", + "paddingLeft", + "paddingRight", + "primaryAxisSizingMode", + "width" + ] + }, + { + "id": "I73:8529;40:608", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 191 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 3, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "selected" + }, + "visible": true, + "width": 135, + "x": 191, + "y": 0, + "mainComponentId": "40:615", + "mainComponentName": "effect=selected", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8529;40:608": { + "id": "I73:8529;40:608", + "type": "TEXT", + "fields": { + "parentId": "73:8529", + "absoluteBoundingBox": { + "height": 14, + "width": 103, + "x": 38408, + "y": 8793 + }, + "absoluteRenderBounds": { + "height": 12.6142578125, + "width": 100.58984375, + "x": 38408.91796875, + "y": 8793.58984375 + }, + "boundVariables": { + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "골관절염/연골재생", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 16 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:4233490d967b21346e274bd97badac8375c1ed19,", + "textTruncation": "DISABLED", + "visible": true, + "width": 103, + "x": 16, + "y": 16, + "styledTextSegments": [ + { + "characters": "골관절염/연골재생", + "end": 9, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:4233490d967b21346e274bd97badac8375c1ed19," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8530": { + "id": "73:8530", + "type": "INSTANCE", + "fields": { + "parentId": "73:8526", + "childrenIds": [ + "I73:8530;40:610" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 122, + "x": 38527, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 122, + "x": 38527, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 특허/지식재산권", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:8530", + "overriddenFields": [ + "height", + "name", + "paddingLeft", + "paddingRight", + "primaryAxisSizingMode", + "width" + ] + }, + { + "id": "I73:8530;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 326 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 122, + "x": 326, + "y": 0, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8530;40:610": { + "id": "I73:8530;40:610", + "type": "TEXT", + "fields": { + "parentId": "73:8530", + "absoluteBoundingBox": { + "height": 14, + "width": 90, + "x": 38543, + "y": 8793 + }, + "absoluteRenderBounds": { + "height": 12.4736328125, + "width": 88.23828125, + "x": 38543.59375, + "y": 8793.66015625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "특허/지식재산권", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 16 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 90, + "x": 16, + "y": 16, + "styledTextSegments": [ + { + "characters": "특허/지식재산권", + "end": 8, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8531": { + "id": "73:8531", + "type": "INSTANCE", + "fields": { + "parentId": "73:8526", + "childrenIds": [ + "I73:8531;40:610" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 134, + "x": 38649, + "y": 8777 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 63, + "x": 38649, + "y": 8777 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "bottomLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "bottomRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topLeftRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + }, + "topRightRadius": { + "id": "VariableID:44:5419", + "type": "VARIABLE_ALIAS" + } + }, + "componentProperties": { + "effect": { + "boundVariables": {}, + "type": "VARIANT", + "value": "default" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "PageTitleMenu · 연구성과/주요실적", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:8531", + "overriddenFields": [ + "height", + "name", + "paddingLeft", + "paddingRight", + "primaryAxisSizingMode", + "width" + ] + }, + { + "id": "I73:8531;40:610", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 448 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "effect": "default" + }, + "visible": true, + "width": 134, + "x": 448, + "y": 0, + "mainComponentId": "40:614", + "mainComponentName": "effect=default", + "mainComponentSetId": "40:616" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:8531;40:610": { + "id": "I73:8531;40:610", + "type": "TEXT", + "fields": { + "parentId": "73:8531", + "absoluteBoundingBox": { + "height": 14, + "width": 102, + "x": 38665, + "y": 8793 + }, + "absoluteRenderBounds": { + "height": 12.48828125, + "width": 46.21875, + "x": 38665.78125, + "y": 8793.66015625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:44:5420", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연구성과/주요실적", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Bold" + }, + "fontSize": 14, + "height": 14, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 100 + }, + "maxHeight": null, + "maxWidth": null, + "name": "텍스트", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 16 + ], + [ + 0, + 1, + 16 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,", + "textTruncation": "DISABLED", + "visible": true, + "width": 102, + "x": 16, + "y": 16, + "styledTextSegments": [ + { + "characters": "연구성과/주요실적", + "end": 9, + "fillStyleId": "", + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:ab9a4b96cc300838fb4b14185fdd981a07ac2475," + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/pagemenu-primitive-defaults.json b/crates/devup-mcp-devup-ui/tests/fixtures/pagemenu-primitive-defaults.json new file mode 100644 index 00000000..5497140c --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/pagemenu-primitive-defaults.json @@ -0,0 +1 @@ +{"fileKey":"K2kUnxGgvooAJwCQiVTb7g","version":null,"roots":["82:13391"],"nodes":{"82:13391":{"id":"82:13391","type":"INSTANCE","fields":{"parentId":"73:8302","childrenIds":["I82:13391;44:5412"],"absoluteBoundingBox":{"height":47,"width":360,"x":33632,"y":8777},"absoluteRenderBounds":{"height":47,"width":360,"x":33632,"y":8777},"bottomLeftRadius":0,"bottomRightRadius":0,"boundVariables":{"strokes":[{"id":"VariableID:9:351","type":"VARIABLE_ALIAS"}]},"componentProperties":{"Slot#44:11":{"preferredValues":[],"type":"SLOT"},"viewport":{"boundVariables":{},"type":"VARIANT","value":"Mobile"}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisSpacing":0,"height":47,"inferredAutoLayout":{"counterAxisAlignItems":"MIN","counterAxisSizingMode":"AUTO","itemSpacing":0,"layoutAlign":"STRETCH","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":0,"paddingLeft":0,"paddingRight":0,"paddingTop":0,"primaryAxisAlignItems":"MIN","primaryAxisSizingMode":"FIXED"},"itemSpacing":0,"layoutAlign":"STRETCH","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"FILL","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"PageMenu","opacity":1,"overflowDirection":"NONE","paddingBottom":0,"paddingLeft":0,"paddingRight":0,"paddingTop":0,"strokeBottomWeight":1,"strokeLeftWeight":0,"strokeRightWeight":0,"strokeTopWeight":0,"strokeWeight":{"$unsupported":"symbol"},"strokes":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:351","type":"VARIABLE_ALIAS"}},"color":{"b":0.9215686321258544,"g":0.9058823585510254,"r":0.8980392217636108},"opacity":1,"type":"SOLID","visible":true}],"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"viewport":"Mobile"},"visible":true,"width":360,"x":0,"y":202,"mainComponentId":"44:5411","mainComponentName":"viewport=Mobile","mainComponentSetId":"44:5410"},"extra":{},"fieldErrors":{}},"I82:13391;44:5412":{"id":"I82:13391;44:5412","type":"SLOT","fields":{"parentId":"82:13391","childrenIds":["I82:13391;44:5412;82:13386","I82:13391;44:5412;82:13387","I82:13391;44:5412;82:13388","I82:13391;44:5412;82:13389","I82:13391;44:5412;82:13390"],"absoluteBoundingBox":{"height":46,"width":360,"x":33632,"y":8777},"absoluteRenderBounds":{"height":46,"width":360,"x":33632,"y":8777},"bottomLeftRadius":0,"bottomRightRadius":0,"boundVariables":{"fills":[{"id":"VariableID:9:349","type":"VARIABLE_ALIAS"}]},"componentPropertyReferences":{"slotContentId":"Slot#44:11"},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:349","type":"VARIABLE_ALIAS"}},"color":{"b":0.9843137264251708,"g":0.9803921580314636,"r":0.9764705896377563},"opacity":1,"type":"SOLID","visible":true}],"height":46,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":0,"layoutAlign":"INHERIT","layoutGrow":1,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":0,"paddingLeft":0,"paddingRight":0,"paddingTop":0,"primaryAxisAlignItems":"MIN","primaryAxisSizingMode":"FIXED"},"itemSpacing":0,"layoutAlign":"INHERIT","layoutGrow":1,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"FILL","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":1280,"name":"Slot","opacity":1,"overflowDirection":"NONE","paddingBottom":0,"paddingLeft":0,"paddingRight":0,"paddingTop":0,"strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"topLeftRadius":0,"topRightRadius":0,"visible":true,"width":360,"x":0,"y":0},"extra":{},"fieldErrors":{}},"I82:13391;44:5412;82:13386":{"id":"I82:13391;44:5412;82:13386","type":"INSTANCE","fields":{"parentId":"I82:13391;44:5412","childrenIds":["I82:13391;44:5412;82:13386;40:608"],"absoluteBoundingBox":{"height":46,"width":100,"x":33632,"y":8777},"absoluteRenderBounds":{"height":46,"width":100,"x":33632,"y":8777},"bottomLeftRadius":0,"bottomRightRadius":0,"boundVariables":{"bottomLeftRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"bottomRightRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"fills":[{"id":"VariableID:9:345","type":"VARIABLE_ALIAS"}],"topLeftRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"topRightRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"}},"componentProperties":{"effect":{"boundVariables":{},"type":"VARIANT","value":"selected"}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:345","type":"VARIABLE_ALIAS"}},"color":{"b":0.7323716878890991,"g":0.34177345037460327,"r":0},"opacity":1,"type":"SOLID","visible":true}],"height":46,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":16,"paddingLeft":24,"paddingRight":24,"paddingTop":16,"primaryAxisAlignItems":"CENTER","primaryAxisSizingMode":"AUTO"},"itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"PageTitleMenu · 핵심 기술","opacity":1,"overflowDirection":"NONE","overrides":[{"id":"I82:13391;44:5412;82:13386","overriddenFields":["height","name","primaryAxisSizingMode","width"]},{"id":"I82:13391;44:5412;82:13386;40:608","overriddenFields":["characters","styledTextSegments"]}],"paddingBottom":16,"paddingLeft":24,"paddingRight":24,"paddingTop":16,"primaryAxisAlignItems":"CENTER","strokeBottomWeight":3,"strokeLeftWeight":0,"strokeRightWeight":0,"strokeTopWeight":0,"strokeWeight":{"$unsupported":"symbol"},"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"effect":"selected"},"visible":true,"width":100,"x":0,"y":0,"mainComponentId":"40:615","mainComponentName":"effect=selected","mainComponentSetId":"40:616"},"extra":{},"fieldErrors":{}},"I82:13391;44:5412;82:13386;40:608":{"id":"I82:13391;44:5412;82:13386;40:608","type":"TEXT","fields":{"parentId":"I82:13391;44:5412;82:13386","absoluteBoundingBox":{"height":14,"width":52,"x":33656,"y":8793},"absoluteRenderBounds":{"height":12.6142578125,"width":50.28125,"x":33656.80859375,"y":8793.58984375},"boundVariables":{"fontSize":[{"id":"VariableID:44:5420","type":"VARIABLE_ALIAS"}]},"characters":"핵심 기술","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{},"color":{"b":1,"g":1,"r":1},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"ExtraBold"},"fontSize":14,"height":14,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":100},"maxHeight":null,"maxWidth":null,"name":"텍스트","opacity":1,"strokeAlign":"OUTSIDE","strokeWeight":1,"textAlignHorizontal":"CENTER","textAutoResize":"WIDTH_AND_HEIGHT","textStyleId":"S:4233490d967b21346e274bd97badac8375c1ed19,","textTruncation":"DISABLED","visible":true,"width":52,"x":24,"y":16,"styledTextSegments":[{"characters":"핵심 기술","end":5,"fillStyleId":"","fontWeight":800,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:4233490d967b21346e274bd97badac8375c1ed19,"}]},"extra":{},"fieldErrors":{}},"I82:13391;44:5412;82:13387":{"id":"I82:13391;44:5412;82:13387","type":"INSTANCE","fields":{"parentId":"I82:13391;44:5412","childrenIds":["I82:13391;44:5412;82:13387;40:610"],"absoluteBoundingBox":{"height":46,"width":123,"x":33732,"y":8777},"absoluteRenderBounds":{"height":46,"width":123,"x":33732,"y":8777},"bottomLeftRadius":0,"bottomRightRadius":0,"boundVariables":{"bottomLeftRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"bottomRightRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"topLeftRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"topRightRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"}},"componentProperties":{"effect":{"boundVariables":{},"type":"VARIANT","value":"default"}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"height":46,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":16,"paddingLeft":24,"paddingRight":24,"paddingTop":16,"primaryAxisAlignItems":"CENTER","primaryAxisSizingMode":"AUTO"},"itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"PageTitleMenu · 연구개발 분야","opacity":1,"overflowDirection":"NONE","overrides":[{"id":"I82:13391;44:5412;82:13387","overriddenFields":["height","name","primaryAxisSizingMode","width"]},{"id":"I82:13391;44:5412;82:13387;40:610","overriddenFields":["characters","styledTextSegments"]}],"paddingBottom":16,"paddingLeft":24,"paddingRight":24,"paddingTop":16,"primaryAxisAlignItems":"CENTER","strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"effect":"default"},"visible":true,"width":123,"x":100,"y":0,"mainComponentId":"40:614","mainComponentName":"effect=default","mainComponentSetId":"40:616"},"extra":{},"fieldErrors":{}},"I82:13391;44:5412;82:13387;40:610":{"id":"I82:13391;44:5412;82:13387;40:610","type":"TEXT","fields":{"parentId":"I82:13391;44:5412;82:13387","absoluteBoundingBox":{"height":14,"width":75,"x":33756,"y":8793},"absoluteRenderBounds":{"height":12.48828125,"width":74.08203125,"x":33756.6484375,"y":8793.66015625},"boundVariables":{"fills":[{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:44:5420","type":"VARIABLE_ALIAS"}]},"characters":"연구개발 분야","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}},"color":{"b":0.1529411822557449,"g":0.08627451211214066,"r":0.06666667014360428},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Bold"},"fontSize":14,"height":14,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":100},"maxHeight":null,"maxWidth":null,"name":"텍스트","opacity":1,"strokeAlign":"OUTSIDE","strokeWeight":1,"textAlignHorizontal":"CENTER","textAutoResize":"WIDTH_AND_HEIGHT","textStyleId":"S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,","textTruncation":"DISABLED","visible":true,"width":75,"x":24,"y":16,"styledTextSegments":[{"characters":"연구개발 분야","end":7,"fillStyleId":"","fontWeight":700,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,"}]},"extra":{},"fieldErrors":{}},"I82:13391;44:5412;82:13388":{"id":"I82:13391;44:5412;82:13388","type":"INSTANCE","fields":{"parentId":"I82:13391;44:5412","childrenIds":["I82:13391;44:5412;82:13388;40:610"],"absoluteBoundingBox":{"height":46,"width":150,"x":33855,"y":8777},"absoluteRenderBounds":{"height":46,"width":137,"x":33855,"y":8777},"bottomLeftRadius":0,"bottomRightRadius":0,"boundVariables":{"bottomLeftRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"bottomRightRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"topLeftRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"topRightRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"}},"componentProperties":{"effect":{"boundVariables":{},"type":"VARIANT","value":"default"}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"height":46,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":16,"paddingLeft":24,"paddingRight":24,"paddingTop":16,"primaryAxisAlignItems":"CENTER","primaryAxisSizingMode":"AUTO"},"itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"PageTitleMenu · 골관절염/연골재생","opacity":1,"overflowDirection":"NONE","overrides":[{"id":"I82:13391;44:5412;82:13388","overriddenFields":["height","name","primaryAxisSizingMode","width"]},{"id":"I82:13391;44:5412;82:13388;40:610","overriddenFields":["characters","styledTextSegments"]}],"paddingBottom":16,"paddingLeft":24,"paddingRight":24,"paddingTop":16,"primaryAxisAlignItems":"CENTER","strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"effect":"default"},"visible":true,"width":150,"x":223,"y":0,"mainComponentId":"40:614","mainComponentName":"effect=default","mainComponentSetId":"40:616"},"extra":{},"fieldErrors":{}},"I82:13391;44:5412;82:13388;40:610":{"id":"I82:13391;44:5412;82:13388;40:610","type":"TEXT","fields":{"parentId":"I82:13391;44:5412;82:13388","absoluteBoundingBox":{"height":14,"width":102,"x":33879,"y":8793},"absoluteRenderBounds":{"height":12.4599609375,"width":100.0546875,"x":33879.64453125,"y":8793.66015625},"boundVariables":{"fills":[{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:44:5420","type":"VARIABLE_ALIAS"}]},"characters":"골관절염/연골재생","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}},"color":{"b":0.1529411822557449,"g":0.08627451211214066,"r":0.06666667014360428},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Bold"},"fontSize":14,"height":14,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":100},"maxHeight":null,"maxWidth":null,"name":"텍스트","opacity":1,"strokeAlign":"OUTSIDE","strokeWeight":1,"textAlignHorizontal":"CENTER","textAutoResize":"WIDTH_AND_HEIGHT","textStyleId":"S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,","textTruncation":"DISABLED","visible":true,"width":102,"x":24,"y":16,"styledTextSegments":[{"characters":"골관절염/연골재생","end":9,"fillStyleId":"","fontWeight":700,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,"}]},"extra":{},"fieldErrors":{}},"I82:13391;44:5412;82:13389":{"id":"I82:13391;44:5412;82:13389","type":"INSTANCE","fields":{"parentId":"I82:13391;44:5412","childrenIds":["I82:13391;44:5412;82:13389;40:610"],"absoluteBoundingBox":{"height":46,"width":138,"x":34005,"y":8777},"absoluteRenderBounds":null,"bottomLeftRadius":0,"bottomRightRadius":0,"boundVariables":{"bottomLeftRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"bottomRightRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"topLeftRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"topRightRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"}},"componentProperties":{"effect":{"boundVariables":{},"type":"VARIANT","value":"default"}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"height":46,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":16,"paddingLeft":24,"paddingRight":24,"paddingTop":16,"primaryAxisAlignItems":"CENTER","primaryAxisSizingMode":"AUTO"},"itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"PageTitleMenu · 특허/지식재산권","opacity":1,"overflowDirection":"NONE","overrides":[{"id":"I82:13391;44:5412;82:13389","overriddenFields":["height","name","primaryAxisSizingMode","width"]},{"id":"I82:13391;44:5412;82:13389;40:610","overriddenFields":["characters","styledTextSegments"]}],"paddingBottom":16,"paddingLeft":24,"paddingRight":24,"paddingTop":16,"primaryAxisAlignItems":"CENTER","strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"effect":"default"},"visible":true,"width":138,"x":373,"y":0,"mainComponentId":"40:614","mainComponentName":"effect=default","mainComponentSetId":"40:616"},"extra":{},"fieldErrors":{}},"I82:13391;44:5412;82:13389;40:610":{"id":"I82:13391;44:5412;82:13389;40:610","type":"TEXT","fields":{"parentId":"I82:13391;44:5412;82:13389","absoluteBoundingBox":{"height":14,"width":90,"x":34029,"y":8793},"absoluteRenderBounds":null,"boundVariables":{"fills":[{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:44:5420","type":"VARIABLE_ALIAS"}]},"characters":"특허/지식재산권","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}},"color":{"b":0.1529411822557449,"g":0.08627451211214066,"r":0.06666667014360428},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Bold"},"fontSize":14,"height":14,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":100},"maxHeight":null,"maxWidth":null,"name":"텍스트","opacity":1,"strokeAlign":"OUTSIDE","strokeWeight":1,"textAlignHorizontal":"CENTER","textAutoResize":"WIDTH_AND_HEIGHT","textStyleId":"S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,","textTruncation":"DISABLED","visible":true,"width":90,"x":24,"y":16,"styledTextSegments":[{"characters":"특허/지식재산권","end":8,"fillStyleId":"","fontWeight":700,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,"}]},"extra":{},"fieldErrors":{}},"I82:13391;44:5412;82:13390":{"id":"I82:13391;44:5412;82:13390","type":"INSTANCE","fields":{"parentId":"I82:13391;44:5412","childrenIds":["I82:13391;44:5412;82:13390;40:610"],"absoluteBoundingBox":{"height":46,"width":150,"x":34143,"y":8777},"absoluteRenderBounds":null,"bottomLeftRadius":0,"bottomRightRadius":0,"boundVariables":{"bottomLeftRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"bottomRightRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"topLeftRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"},"topRightRadius":{"id":"VariableID:44:5419","type":"VARIABLE_ALIAS"}},"componentProperties":{"effect":{"boundVariables":{},"type":"VARIANT","value":"default"}},"constraints":{"horizontal":"MIN","vertical":"MIN"},"counterAxisAlignContent":"AUTO","counterAxisAlignItems":"CENTER","counterAxisSpacing":0,"height":46,"inferredAutoLayout":{"counterAxisAlignItems":"CENTER","counterAxisSizingMode":"AUTO","itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","paddingBottom":16,"paddingLeft":24,"paddingRight":24,"paddingTop":16,"primaryAxisAlignItems":"CENTER","primaryAxisSizingMode":"AUTO"},"itemSpacing":10,"layoutAlign":"INHERIT","layoutGrow":0,"layoutMode":"HORIZONTAL","layoutWrap":"NO_WRAP","layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","maxHeight":null,"maxWidth":null,"name":"PageTitleMenu · 연구성과/주요실적","opacity":1,"overflowDirection":"NONE","overrides":[{"id":"I82:13391;44:5412;82:13390","overriddenFields":["height","name","primaryAxisSizingMode","width"]},{"id":"I82:13391;44:5412;82:13390;40:610","overriddenFields":["characters","styledTextSegments"]}],"paddingBottom":16,"paddingLeft":24,"paddingRight":24,"paddingTop":16,"primaryAxisAlignItems":"CENTER","strokeBottomWeight":1,"strokeLeftWeight":1,"strokeRightWeight":1,"strokeTopWeight":1,"strokeWeight":1,"topLeftRadius":0,"topRightRadius":0,"variantProperties":{"effect":"default"},"visible":true,"width":150,"x":511,"y":0,"mainComponentId":"40:614","mainComponentName":"effect=default","mainComponentSetId":"40:616"},"extra":{},"fieldErrors":{}},"I82:13391;44:5412;82:13390;40:610":{"id":"I82:13391;44:5412;82:13390;40:610","type":"TEXT","fields":{"parentId":"I82:13391;44:5412;82:13390","absoluteBoundingBox":{"height":14,"width":102,"x":34167,"y":8793},"absoluteRenderBounds":null,"boundVariables":{"fills":[{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}],"fontSize":[{"id":"VariableID:44:5420","type":"VARIABLE_ALIAS"}]},"characters":"연구성과/주요실적","constraints":{"horizontal":"MIN","vertical":"MIN"},"fills":[{"blendMode":"NORMAL","boundVariables":{"color":{"id":"VariableID:9:358","type":"VARIABLE_ALIAS"}},"color":{"b":0.1529411822557449,"g":0.08627451211214066,"r":0.06666667014360428},"opacity":1,"type":"SOLID","visible":true}],"fontName":{"family":"SUIT","style":"Bold"},"fontSize":14,"height":14,"layoutAlign":"INHERIT","layoutGrow":0,"layoutPositioning":"AUTO","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","letterSpacing":{"unit":"PERCENT","value":-2},"lineHeight":{"unit":"PERCENT","value":100},"maxHeight":null,"maxWidth":null,"name":"텍스트","opacity":1,"strokeAlign":"OUTSIDE","strokeWeight":1,"textAlignHorizontal":"CENTER","textAutoResize":"WIDTH_AND_HEIGHT","textStyleId":"S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,","textTruncation":"DISABLED","visible":true,"width":102,"x":24,"y":16,"styledTextSegments":[{"characters":"연구성과/주요실적","end":9,"fillStyleId":"","fontWeight":700,"hyperlink":null,"indentation":0,"listOptions":{"type":"NONE"},"start":0,"textStyleId":"S:ab9a4b96cc300838fb4b14185fdd981a07ac2475,"}]},"extra":{},"fieldErrors":{}}},"diagnostics":[]} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/product-card-mixed-strokes.json b/crates/devup-mcp-devup-ui/tests/fixtures/product-card-mixed-strokes.json new file mode 100644 index 00000000..11798a3d --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/product-card-mixed-strokes.json @@ -0,0 +1,5713 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "73:8861", + "73:9072" + ], + "nodes": { + "73:11291": { + "id": "73:11291", + "type": "INSTANCE", + "fields": { + "parentId": "73:8861", + "childrenIds": [ + "I73:11291;73:11399", + "I73:11291;73:11257", + "I73:11291;73:11258" + ], + "absoluteBoundingBox": { + "height": 434, + "width": 427, + "x": 20398, + "y": 10848 + }, + "absoluteRenderBounds": { + "height": 434, + "width": 427, + "x": 20398, + "y": 10848 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "list#73:16": { + "preferredValues": [], + "type": "SLOT" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Desktop" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 434, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 32, + "layoutAlign": "STRETCH", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 40, + "paddingLeft": 40, + "paddingRight": 40, + "paddingTop": 40, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 32, + "layoutAlign": "STRETCH", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FILL", + "maxHeight": null, + "maxWidth": null, + "name": "DevelopmentGoalsCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:11291", + "overriddenFields": [ + "height", + "primaryAxisSizingMode", + "width" + ] + } + ], + "paddingBottom": 40, + "paddingLeft": 40, + "paddingRight": 40, + "paddingTop": 40, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 0, + "strokeLeftWeight": 0, + "strokeRightWeight": 1, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "viewport": "Desktop" + }, + "visible": true, + "width": 427, + "x": 0, + "y": 0, + "mainComponentId": "73:11271", + "mainComponentName": "viewport=Desktop", + "mainComponentSetId": "73:11494" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399": { + "id": "I73:11291;73:11399", + "type": "INSTANCE", + "fields": { + "parentId": "73:11291", + "childrenIds": [ + "I73:11291;73:11399;73:11383" + ], + "absoluteBoundingBox": { + "height": 120, + "width": 120, + "x": 20551, + "y": 10888 + }, + "absoluteRenderBounds": { + "height": 120, + "width": 120, + "x": 20551, + "y": 10888 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 120, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 3.750152587890625, + "paddingLeft": 7.5, + "paddingRight": 7.5, + "paddingTop": 3.75, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "bones", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 153 + ], + [ + 0, + 1, + 40 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 32, + "y": 32 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 120, + "x": 153, + "y": 40, + "mainComponentId": "73:11398", + "mainComponentName": "bones", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11383": { + "id": "I73:11291;73:11399;73:11383", + "type": "GROUP", + "fields": { + "parentId": "I73:11291;73:11399", + "childrenIds": [ + "I73:11291;73:11399;73:11384", + "I73:11291;73:11399;73:11385", + "I73:11291;73:11399;73:11386", + "I73:11291;73:11399;73:11387", + "I73:11291;73:11399;73:11388", + "I73:11291;73:11399;73:11389" + ], + "absoluteBoundingBox": { + "height": 112.49984741210938, + "width": 105, + "x": 20558.5, + "y": 10891.75 + }, + "absoluteRenderBounds": { + "height": 112.5, + "width": 105, + "x": 20558.5, + "y": 10891.75 + }, + "height": 112.49984741210938, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Bones", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 7.5 + ], + [ + 0, + 1, + 3.75 + ] + ], + "visible": true, + "width": 105, + "x": 7.5, + "y": 3.75 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11384": { + "id": "I73:11291;73:11399;73:11384", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11383", + "absoluteBoundingBox": { + "height": 15, + "width": 33.54290771484375, + "x": 20594.228515625, + "y": 10940.5 + }, + "absoluteRenderBounds": { + "height": 15, + "width": 33.54296875, + "x": 20594.228515625, + "y": 10940.5 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7843137383460999, + "g": 0.4745098054409027, + "r": 0.20000000298023224 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 15, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 43.22854232788086 + ], + [ + 0, + 1, + 52.5 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 33.54290771484375, + "x": 43.22854232788086, + "y": 52.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11385": { + "id": "I73:11291;73:11399;73:11385", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11383", + "absoluteBoundingBox": { + "height": 52.50571060180664, + "width": 44.9069938659668, + "x": 20588.546875, + "y": 10891.75 + }, + "absoluteRenderBounds": { + "height": 52.505859375, + "width": 44.90625, + "x": 20588.546875, + "y": 10891.75 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:3", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:3", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9839743375778198, + "g": 0.9189276099205016, + "r": 0.8625540137290955 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 52.50571060180664, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 37.546499252319336 + ], + [ + 0, + 1, + 3.75 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 44.9069938659668, + "x": 37.546499252319336, + "y": 3.75 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11386": { + "id": "I73:11291;73:11399;73:11386", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11383", + "absoluteBoundingBox": { + "height": 52.50571060180664, + "width": 44.9069938659668, + "x": 20588.546875, + "y": 10951.744140625 + }, + "absoluteRenderBounds": { + "height": 52.505859375, + "width": 44.90625, + "x": 20588.546875, + "y": 10951.744140625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:3", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:3", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9839743375778198, + "g": 0.9189276099205016, + "r": 0.8625540137290955 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 52.50571060180664, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 37.546499252319336 + ], + [ + 0, + 1, + 63.744293212890625 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 44.9069938659668, + "x": 37.546499252319336, + "y": 63.744293212890625 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11387": { + "id": "I73:11291;73:11399;73:11387", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11383", + "absoluteBoundingBox": { + "height": 15.110623359680176, + "width": 15.001348495483398, + "x": 20595.998046875, + "y": 10929.25 + }, + "absoluteRenderBounds": { + "height": 15.1103515625, + "width": 15.001953125, + "x": 20595.998046875, + "y": 10929.25 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.8666666746139526, + "g": 0.6705882549285889, + "r": 0.501960813999176 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 15.110623359680176, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 44.998653411865234 + ], + [ + 0, + 1, + 41.25 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 15.001348495483398, + "x": 44.998653411865234, + "y": 41.25 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11388": { + "id": "I73:11291;73:11399;73:11388", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11383", + "absoluteBoundingBox": { + "height": 15.110623359680176, + "width": 15.001348495483398, + "x": 20611, + "y": 10951.6396484375 + }, + "absoluteRenderBounds": { + "height": 15.1103515625, + "width": 15.001953125, + "x": 20611, + "y": 10951.6396484375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.8666666746139526, + "g": 0.6705882549285889, + "r": 0.501960813999176 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 15.110623359680176, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 60 + ], + [ + 0, + 1, + 63.63937759399414 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 15.001348495483398, + "x": 60, + "y": 63.63937759399414 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11389": { + "id": "I73:11291;73:11399;73:11389", + "type": "GROUP", + "fields": { + "parentId": "I73:11291;73:11399;73:11383", + "childrenIds": [ + "I73:11291;73:11399;73:11390", + "I73:11291;73:11399;73:11391", + "I73:11291;73:11399;73:11392", + "I73:11291;73:11399;73:11393", + "I73:11291;73:11399;73:11394", + "I73:11291;73:11399;73:11395" + ], + "absoluteBoundingBox": { + "height": 42.856971740722656, + "width": 105, + "x": 20558.5, + "y": 10926.5791015625 + }, + "absoluteRenderBounds": { + "height": 42.856971740722656, + "width": 105, + "x": 20558.5, + "y": 10926.5791015625 + }, + "height": 42.856971740722656, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 7.5 + ], + [ + 0, + 1, + 38.57878875732422 + ] + ], + "visible": true, + "width": 105, + "x": 7.5, + "y": 38.57878875732422 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11390": { + "id": "I73:11291;73:11399;73:11390", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11389", + "absoluteBoundingBox": { + "height": 7.5, + "width": 15, + "x": 20558.5, + "y": 10944.25 + }, + "absoluteRenderBounds": { + "height": 7.5, + "width": 15, + "x": 20558.5, + "y": 10944.25 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.5, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 7.5 + ], + [ + 0, + 1, + 56.25 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 15, + "x": 7.5, + "y": 56.25 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11391": { + "id": "I73:11291;73:11399;73:11391", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11389", + "absoluteBoundingBox": { + "height": 12.807470815570923, + "width": 12.807470815572742, + "x": 20570.857421875, + "y": 10956.586650265066 + }, + "absoluteRenderBounds": { + "height": 12.8076171875, + "width": 12.806640625, + "x": 20570.857421875, + "y": 10956.5869140625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.5, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 0.7071067690849304, + 0.7071067690849304, + 19.856738090515137 + ], + [ + -0.7071067690849304, + 0.7071067690849304, + 76.09076690673828 + ] + ], + "rotation": 45, + "strokeWeight": 0.9998489618301392, + "visible": true, + "width": 10.612499237060549, + "x": 19.856738090515137, + "y": 76.09076690673828 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11392": { + "id": "I73:11291;73:11399;73:11392", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11389", + "absoluteBoundingBox": { + "height": 12.807470815570923, + "width": 12.807470815572742, + "x": 20570.833984375, + "y": 10926.591230481865 + }, + "absoluteRenderBounds": { + "height": 12.8076171875, + "width": 12.806640625, + "x": 20570.833984375, + "y": 10926.5908203125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 10.612499237060549, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 0.7071067690849304, + 0.7071067690849304, + 19.834238052368164 + ], + [ + -0.7071067690849304, + 0.7071067690849304, + 43.89424133300781 + ] + ], + "rotation": 45, + "strokeWeight": 0.9998489618301392, + "visible": true, + "width": 7.5, + "x": 19.834238052368164, + "y": 43.89424133300781 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11393": { + "id": "I73:11291;73:11399;73:11393", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11389", + "absoluteBoundingBox": { + "height": 7.5, + "width": 15, + "x": 20648.5, + "y": 10944.25 + }, + "absoluteRenderBounds": { + "height": 7.5, + "width": 15, + "x": 20648.5, + "y": 10944.25 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.5, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 97.5 + ], + [ + 0, + 1, + 56.25 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 15, + "x": 97.5, + "y": 56.25 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11394": { + "id": "I73:11291;73:11399;73:11394", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11389", + "absoluteBoundingBox": { + "height": 12.807470815570923, + "width": 12.807470815572742, + "x": 20638.341796875, + "y": 10956.628339856865 + }, + "absoluteRenderBounds": { + "height": 12.8076171875, + "width": 12.806640625, + "x": 20638.341796875, + "y": 10956.6279296875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 10.612499237060549, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 0.7071067690849304, + 0.7071067690849304, + 87.34173583984375 + ], + [ + -0.7071067690849304, + 0.7071067690849304, + 73.93173599243164 + ] + ], + "rotation": 45, + "strokeWeight": 0.9998489618301392, + "visible": true, + "width": 7.5, + "x": 87.34173583984375, + "y": 73.93173599243164 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11399;73:11395": { + "id": "I73:11291;73:11399;73:11395", + "type": "VECTOR", + "fields": { + "parentId": "I73:11291;73:11399;73:11389", + "absoluteBoundingBox": { + "height": 12.807470815570923, + "width": 12.807470815572742, + "x": 20638.357421875, + "y": 10926.579814327566 + }, + "absoluteRenderBounds": { + "height": 12.8076171875, + "width": 12.806640625, + "x": 20638.357421875, + "y": 10926.580078125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.5, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 0.7071067690849304, + 0.7071067690849304, + 87.35673522949219 + ], + [ + -0.7071067690849304, + 0.7071067690849304, + 46.08326292037964 + ] + ], + "rotation": 45, + "strokeWeight": 0.9998489618301392, + "visible": true, + "width": 10.612499237060549, + "x": 87.35673522949219, + "y": 46.08326292037964 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11257": { + "id": "I73:11291;73:11257", + "type": "TEXT", + "fields": { + "parentId": "73:11291", + "absoluteBoundingBox": { + "height": 34, + "width": 346, + "x": 20438, + "y": 11040 + }, + "absoluteRenderBounds": { + "height": 21.6240234375, + "width": 194.63671875, + "x": 20513.7890625, + "y": 11046.4404296875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:946", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연골 재생 · 기능 회복", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 24, + "height": 34, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "연골 재생 · 기능 회복", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 40 + ], + [ + 0, + 1, + 192 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "HEIGHT", + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66,", + "textTruncation": "DISABLED", + "visible": true, + "width": 346, + "x": 40, + "y": 192, + "styledTextSegments": [ + { + "characters": "연골 재생 · 기능 회복", + "end": 13, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11258": { + "id": "I73:11291;73:11258", + "type": "SLOT", + "fields": { + "parentId": "73:11291", + "childrenIds": [ + "I73:11291;73:11277", + "I73:11291;73:11282", + "I73:11291;73:11286" + ], + "absoluteBoundingBox": { + "height": 136, + "width": 346, + "x": 20438, + "y": 11106 + }, + "absoluteRenderBounds": { + "height": 136, + "width": 346, + "x": 20438, + "y": 11106 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentPropertyReferences": { + "slotContentId": "list#73:16" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 136, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "list", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 40 + ], + [ + 0, + 1, + 258 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 346, + "x": 40, + "y": 258 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11277": { + "id": "I73:11291;73:11277", + "type": "INSTANCE", + "fields": { + "parentId": "I73:11291;73:11258", + "childrenIds": [ + "I73:11291;73:11277;73:11273", + "I73:11291;73:11277;73:11275" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 346, + "x": 20438, + "y": 11106 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 346, + "x": 20438, + "y": 11106 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DevelopmentGoalsList", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 346, + "x": 0, + "y": 0, + "mainComponentId": "73:11276", + "mainComponentName": "DevelopmentGoalsList", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11277;73:11273": { + "id": "I73:11291;73:11277;73:11273", + "type": "FRAME", + "fields": { + "parentId": "I73:11291;73:11277", + "childrenIds": [ + "I73:11291;73:11277;73:11274" + ], + "absoluteBoundingBox": { + "height": 15, + "width": 5, + "x": 20438, + "y": 11106 + }, + "absoluteRenderBounds": { + "height": 15, + "width": 5, + "x": 20438, + "y": 11106 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingTop": { + "id": "VariableID:73:11681", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 15, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 10, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "dot wrap", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 10, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 5, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11277;73:11274": { + "id": "I73:11291;73:11277;73:11274", + "type": "FRAME", + "fields": { + "parentId": "I73:11291;73:11277;73:11273", + "absoluteBoundingBox": { + "height": 5, + "width": 5, + "x": 20438, + "y": 11116 + }, + "absoluteRenderBounds": { + "height": 5, + "width": 5, + "x": 20438, + "y": 11116 + }, + "bottomLeftRadius": 999, + "bottomRightRadius": 999, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 999, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.8666666746139526, + "g": 0.6705882549285889, + "r": 0.501960813999176 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 10 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 999, + "topRightRadius": 999, + "visible": true, + "width": 5, + "x": 0, + "y": 10 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11277;73:11275": { + "id": "I73:11291;73:11277;73:11275", + "type": "TEXT", + "fields": { + "parentId": "I73:11291;73:11277", + "absoluteBoundingBox": { + "height": 48, + "width": 331, + "x": 20453, + "y": 11106 + }, + "absoluteRenderBounds": { + "height": 38.1591796875, + "width": 322.107421875, + "x": 20453.48046875, + "y": 11111.0244140625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "세포 생존력과 연골 분화능을 높이는 것을 목표로 합니다.", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 48, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "세포 생존력과 연골 분화능을 높이는 것을 목표로 합", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 15 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 331, + "x": 15, + "y": 0, + "styledTextSegments": [ + { + "characters": "세포 생존력과 연골 분화능을 높이는 것을 목표로 합니다.", + "end": 31, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11282": { + "id": "I73:11291;73:11282", + "type": "INSTANCE", + "fields": { + "parentId": "I73:11291;73:11258", + "childrenIds": [ + "I73:11291;73:11282;73:11273", + "I73:11291;73:11282;73:11275" + ], + "absoluteBoundingBox": { + "height": 48, + "width": 346, + "x": 20438, + "y": 11162 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 346, + "x": 20438, + "y": 11162 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 48, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DevelopmentGoalsList", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 56 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 346, + "x": 0, + "y": 56, + "mainComponentId": "73:11276", + "mainComponentName": "DevelopmentGoalsList", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11282;73:11273": { + "id": "I73:11291;73:11282;73:11273", + "type": "FRAME", + "fields": { + "parentId": "I73:11291;73:11282", + "childrenIds": [ + "I73:11291;73:11282;73:11274" + ], + "absoluteBoundingBox": { + "height": 15, + "width": 5, + "x": 20438, + "y": 11162 + }, + "absoluteRenderBounds": { + "height": 15, + "width": 5, + "x": 20438, + "y": 11162 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingTop": { + "id": "VariableID:73:11681", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 15, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 10, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "dot wrap", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 10, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 5, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11282;73:11274": { + "id": "I73:11291;73:11282;73:11274", + "type": "FRAME", + "fields": { + "parentId": "I73:11291;73:11282;73:11273", + "absoluteBoundingBox": { + "height": 5, + "width": 5, + "x": 20438, + "y": 11172 + }, + "absoluteRenderBounds": { + "height": 5, + "width": 5, + "x": 20438, + "y": 11172 + }, + "bottomLeftRadius": 999, + "bottomRightRadius": 999, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 999, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.8666666746139526, + "g": 0.6705882549285889, + "r": 0.501960813999176 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 10 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 999, + "topRightRadius": 999, + "visible": true, + "width": 5, + "x": 0, + "y": 10 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11282;73:11275": { + "id": "I73:11291;73:11282;73:11275", + "type": "TEXT", + "fields": { + "parentId": "I73:11291;73:11282", + "absoluteBoundingBox": { + "height": 48, + "width": 331, + "x": 20453, + "y": 11162 + }, + "absoluteRenderBounds": { + "height": 38.1591796875, + "width": 318.580078125, + "x": 20453.640625, + "y": 11167.0244140625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "몸속에서 오래 살아남아 기능을 이어 가도록 개발합니다.", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 48, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "세포 생존력과 연골 분화능을 높이는 것을 목표로 합", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 15 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 331, + "x": 15, + "y": 0, + "styledTextSegments": [ + { + "characters": "몸속에서 오래 살아남아 기능을 이어 가도록 개발합니다.", + "end": 30, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11286": { + "id": "I73:11291;73:11286", + "type": "INSTANCE", + "fields": { + "parentId": "I73:11291;73:11258", + "childrenIds": [ + "I73:11291;73:11286;73:11273", + "I73:11291;73:11286;73:11275" + ], + "absoluteBoundingBox": { + "height": 24, + "width": 346, + "x": 20438, + "y": 11218 + }, + "absoluteRenderBounds": { + "height": 24, + "width": 346, + "x": 20438, + "y": 11218 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 24, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DevelopmentGoalsList", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 112 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 346, + "x": 0, + "y": 112, + "mainComponentId": "73:11276", + "mainComponentName": "DevelopmentGoalsList", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11286;73:11273": { + "id": "I73:11291;73:11286;73:11273", + "type": "FRAME", + "fields": { + "parentId": "I73:11291;73:11286", + "childrenIds": [ + "I73:11291;73:11286;73:11274" + ], + "absoluteBoundingBox": { + "height": 15, + "width": 5, + "x": 20438, + "y": 11218 + }, + "absoluteRenderBounds": { + "height": 15, + "width": 5, + "x": 20438, + "y": 11218 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingTop": { + "id": "VariableID:73:11681", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 15, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 10, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "dot wrap", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 10, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 5, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11286;73:11274": { + "id": "I73:11291;73:11286;73:11274", + "type": "FRAME", + "fields": { + "parentId": "I73:11291;73:11286;73:11273", + "absoluteBoundingBox": { + "height": 5, + "width": 5, + "x": 20438, + "y": 11228 + }, + "absoluteRenderBounds": { + "height": 5, + "width": 5, + "x": 20438, + "y": 11228 + }, + "bottomLeftRadius": 999, + "bottomRightRadius": 999, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 999, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.8666666746139526, + "g": 0.6705882549285889, + "r": 0.501960813999176 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 10 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 999, + "topRightRadius": 999, + "visible": true, + "width": 5, + "x": 0, + "y": 10 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11291;73:11286;73:11275": { + "id": "I73:11291;73:11286;73:11275", + "type": "TEXT", + "fields": { + "parentId": "I73:11291;73:11286", + "absoluteBoundingBox": { + "height": 24, + "width": 331, + "x": 20453, + "y": 11218 + }, + "absoluteRenderBounds": { + "height": 14.17578125, + "width": 306.01953125, + "x": 20453.86328125, + "y": 11223.0244140625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연골 조직의 구조적 · 기능적 회복을 목표로 합니다.", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "세포 생존력과 연골 분화능을 높이는 것을 목표로 합", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 15 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 331, + "x": 15, + "y": 0, + "styledTextSegments": [ + { + "characters": "연골 조직의 구조적 · 기능적 회복을 목표로 합니다.", + "end": 29, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:11525": { + "id": "73:11525", + "type": "INSTANCE", + "fields": { + "parentId": "73:9072", + "childrenIds": [ + "I73:11525;73:11496", + "I73:11525;73:11497", + "I73:11525;73:11498" + ], + "absoluteBoundingBox": { + "height": 391, + "width": 320, + "x": 21821, + "y": 10831 + }, + "absoluteRenderBounds": { + "height": 391, + "width": 320, + "x": 21821, + "y": 10831 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "list#73:16": { + "preferredValues": [], + "type": "SLOT" + }, + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Mobile" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 391, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 40, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 40, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DevelopmentGoalsCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "73:11525", + "overriddenFields": [ + "height", + "primaryAxisSizingMode", + "width" + ] + } + ], + "paddingBottom": 40, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 40, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 0, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "viewport": "Mobile" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 0, + "mainComponentId": "73:11495", + "mainComponentName": "viewport=Mobile", + "mainComponentSetId": "73:11494" + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496": { + "id": "I73:11525;73:11496", + "type": "INSTANCE", + "fields": { + "parentId": "73:11525", + "childrenIds": [ + "I73:11525;73:11496;73:11383" + ], + "absoluteBoundingBox": { + "height": 80, + "width": 80, + "x": 21941, + "y": 10871 + }, + "absoluteRenderBounds": { + "height": 80, + "width": 80, + "x": 21941, + "y": 10871 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": false + } + ], + "height": 80, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 2.5000991821289062, + "paddingLeft": 5, + "paddingRight": 5, + "paddingTop": 2.5, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "bones", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 120 + ], + [ + 0, + 1, + 40 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "targetAspectRatio": { + "x": 32, + "y": 32 + }, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 80, + "x": 120, + "y": 40, + "mainComponentId": "73:11398", + "mainComponentName": "bones", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11383": { + "id": "I73:11525;73:11496;73:11383", + "type": "GROUP", + "fields": { + "parentId": "I73:11525;73:11496", + "childrenIds": [ + "I73:11525;73:11496;73:11384", + "I73:11525;73:11496;73:11385", + "I73:11525;73:11496;73:11386", + "I73:11525;73:11496;73:11387", + "I73:11525;73:11496;73:11388", + "I73:11525;73:11496;73:11389" + ], + "absoluteBoundingBox": { + "height": 74.9999008178711, + "width": 70, + "x": 21946, + "y": 10873.5 + }, + "absoluteRenderBounds": { + "height": 75, + "width": 70, + "x": 21946, + "y": 10873.5 + }, + "height": 74.9999008178711, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "MIN", + "layoutGrow": 0, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Bones", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 5 + ], + [ + 0, + 1, + 2.5 + ] + ], + "visible": true, + "width": 70, + "x": 5, + "y": 2.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11384": { + "id": "I73:11525;73:11496;73:11384", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11383", + "absoluteBoundingBox": { + "height": 10, + "width": 22.3619384765625, + "x": 21969.818359375, + "y": 10906 + }, + "absoluteRenderBounds": { + "height": 10, + "width": 22.361328125, + "x": 21969.818359375, + "y": 10906 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:10", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7843137383460999, + "g": 0.4745098054409027, + "r": 0.20000000298023224 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 28.819028854370117 + ], + [ + 0, + 1, + 35 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 22.3619384765625, + "x": 28.819028854370117, + "y": 35 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11385": { + "id": "I73:11525;73:11496;73:11385", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11383", + "absoluteBoundingBox": { + "height": 35.003807067871094, + "width": 29.93799591064453, + "x": 21966.03125, + "y": 10873.5 + }, + "absoluteRenderBounds": { + "height": 35.00390625, + "width": 29.9375, + "x": 21966.03125, + "y": 10873.5 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:3", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:3", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9839743375778198, + "g": 0.9189276099205016, + "r": 0.8625540137290955 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 35.003807067871094, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 25.03099822998047 + ], + [ + 0, + 1, + 2.5 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 29.93799591064453, + "x": 25.03099822998047, + "y": 2.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11386": { + "id": "I73:11525;73:11496;73:11386", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11383", + "absoluteBoundingBox": { + "height": 35.003807067871094, + "width": 29.93799591064453, + "x": 21966.03125, + "y": 10913.49609375 + }, + "absoluteRenderBounds": { + "height": 35.00390625, + "width": 29.9375, + "x": 21966.03125, + "y": 10913.49609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:3", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:3", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9839743375778198, + "g": 0.9189276099205016, + "r": 0.8625540137290955 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 35.003807067871094, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 25.03099822998047 + ], + [ + 0, + 1, + 42.496192932128906 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 29.93799591064453, + "x": 25.03099822998047, + "y": 42.496192932128906 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11387": { + "id": "I73:11525;73:11496;73:11387", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11383", + "absoluteBoundingBox": { + "height": 10.073748588562012, + "width": 10.000898361206056, + "x": 21971, + "y": 10898.5 + }, + "absoluteRenderBounds": { + "height": 10.07421875, + "width": 10, + "x": 21971, + "y": 10898.5 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.8666666746139526, + "g": 0.6705882549285889, + "r": 0.501960813999176 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 10.073748588562012, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 29.999101638793945 + ], + [ + 0, + 1, + 27.5 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 10.000898361206056, + "x": 29.999101638793945, + "y": 27.5 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11388": { + "id": "I73:11525;73:11496;73:11388", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11383", + "absoluteBoundingBox": { + "height": 10.073748588562012, + "width": 10.000898361206056, + "x": 21981, + "y": 10913.42578125 + }, + "absoluteRenderBounds": { + "height": 10.07421875, + "width": 10, + "x": 21981, + "y": 10913.42578125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.8666666746139526, + "g": 0.6705882549285889, + "r": 0.501960813999176 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 10.073748588562012, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 40 + ], + [ + 0, + 1, + 42.42625427246094 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 10.000898361206056, + "x": 40, + "y": 42.42625427246094 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11389": { + "id": "I73:11525;73:11496;73:11389", + "type": "GROUP", + "fields": { + "parentId": "I73:11525;73:11496;73:11383", + "childrenIds": [ + "I73:11525;73:11496;73:11390", + "I73:11525;73:11496;73:11391", + "I73:11525;73:11496;73:11392", + "I73:11525;73:11496;73:11393", + "I73:11525;73:11496;73:11394", + "I73:11525;73:11496;73:11395" + ], + "absoluteBoundingBox": { + "height": 28.57098960876465, + "width": 70, + "x": 21946, + "y": 10896.7197265625 + }, + "absoluteRenderBounds": { + "height": 28.5712890625, + "width": 70, + "x": 21946, + "y": 10896.7197265625 + }, + "height": 28.57098960876465, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Group", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 5 + ], + [ + 0, + 1, + 25.71969223022461 + ] + ], + "visible": true, + "width": 70, + "x": 5, + "y": 25.71969223022461 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11390": { + "id": "I73:11525;73:11496;73:11390", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11389", + "absoluteBoundingBox": { + "height": 5, + "width": 10, + "x": 21946, + "y": 10908.5 + }, + "absoluteRenderBounds": { + "height": 5, + "width": 10, + "x": 21946, + "y": 10908.5 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 5 + ], + [ + 0, + 1, + 37.49999904632568 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 10, + "x": 5, + "y": 37.49999904632568 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11391": { + "id": "I73:11525;73:11496;73:11391", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11389", + "absoluteBoundingBox": { + "height": 8.53831410183011, + "width": 8.53831410183193, + "x": 21954.23828125, + "y": 10916.724758806096 + }, + "absoluteRenderBounds": { + "height": 8.5380859375, + "width": 8.5390625, + "x": 21954.23828125, + "y": 10916.724609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 0.7071067690849304, + 0.7071067690849304, + 13.237825393676758 + ], + [ + -0.7071067690849304, + 0.7071067690849304, + 50.727176666259766 + ] + ], + "rotation": 45, + "strokeWeight": 0.9998489618301392, + "visible": true, + "width": 7.074999809265137, + "x": 13.237825393676758, + "y": 50.727176666259766 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11392": { + "id": "I73:11525;73:11496;73:11392", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11389", + "absoluteBoundingBox": { + "height": 8.53831410183011, + "width": 8.53831410183193, + "x": 21954.22265625, + "y": 10896.727161467075 + }, + "absoluteRenderBounds": { + "height": 8.5380859375, + "width": 8.5390625, + "x": 21954.22265625, + "y": 10896.7275390625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.074999809265137, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 0.7071067690849304, + 0.7071067690849304, + 13.22282600402832 + ], + [ + -0.7071067690849304, + 0.7071067690849304, + 29.262826204299927 + ] + ], + "rotation": 45, + "strokeWeight": 0.9998489618301392, + "visible": true, + "width": 5, + "x": 13.22282600402832, + "y": 29.262826204299927 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11393": { + "id": "I73:11525;73:11496;73:11393", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11389", + "absoluteBoundingBox": { + "height": 5, + "width": 10, + "x": 22006, + "y": 10908.5 + }, + "absoluteRenderBounds": { + "height": 5, + "width": 10, + "x": 22006, + "y": 10908.5 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 65 + ], + [ + 0, + 1, + 37.49999904632568 + ] + ], + "strokeWeight": 1, + "visible": true, + "width": 10, + "x": 65, + "y": 37.49999904632568 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11394": { + "id": "I73:11525;73:11496;73:11394", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11389", + "absoluteBoundingBox": { + "height": 8.53831410183011, + "width": 8.53831410183193, + "x": 21999.228515625, + "y": 10916.752552092075 + }, + "absoluteRenderBounds": { + "height": 8.5380859375, + "width": 8.5390625, + "x": 21999.228515625, + "y": 10916.7529296875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 7.074999809265137, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 0.7071067690849304, + 0.7071067690849304, + 58.22782516479492 + ], + [ + -0.7071067690849304, + 0.7071067690849304, + 49.287824630737305 + ] + ], + "rotation": 45, + "strokeWeight": 0.9998489618301392, + "visible": true, + "width": 5, + "x": 58.22782516479492, + "y": 49.287824630737305 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11496;73:11395": { + "id": "I73:11525;73:11496;73:11395", + "type": "VECTOR", + "fields": { + "parentId": "I73:11525;73:11496;73:11389", + "absoluteBoundingBox": { + "height": 8.53831410183011, + "width": 8.53831410183193, + "x": 21999.23828125, + "y": 10896.719875993596 + }, + "absoluteRenderBounds": { + "height": 8.5380859375, + "width": 8.5390625, + "x": 21999.23828125, + "y": 10896.7197265625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "SCALE", + "vertical": "SCALE" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector", + "opacity": 1, + "relativeTransform": [ + [ + 0.7071067690849304, + 0.7071067690849304, + 58.237823486328125 + ], + [ + -0.7071067690849304, + 0.7071067690849304, + 30.722174167633057 + ] + ], + "rotation": 45, + "strokeWeight": 0.9998489618301392, + "visible": true, + "width": 7.074999809265137, + "x": 58.237823486328125, + "y": 30.722174167633057 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11497": { + "id": "I73:11525;73:11497", + "type": "TEXT", + "fields": { + "parentId": "73:11525", + "absoluteBoundingBox": { + "height": 28, + "width": 288, + "x": 21837, + "y": 10975 + }, + "absoluteRenderBounds": { + "height": 18.01953125, + "width": 162.19921875, + "x": 21899.990234375, + "y": 10979.7001953125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:946", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연골 재생 · 기능 회복", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 20, + "height": 28, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "연골 재생 · 기능 회복", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 16 + ], + [ + 0, + 1, + 144 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "HEIGHT", + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66,", + "textTruncation": "DISABLED", + "visible": true, + "width": 288, + "x": 16, + "y": 144, + "styledTextSegments": [ + { + "characters": "연골 재생 · 기능 회복", + "end": 13, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11498": { + "id": "I73:11525;73:11498", + "type": "SLOT", + "fields": { + "parentId": "73:11525", + "childrenIds": [ + "I73:11525;73:11499", + "I73:11525;73:11500", + "I73:11525;73:11501" + ], + "absoluteBoundingBox": { + "height": 154, + "width": 288, + "x": 21837, + "y": 11027 + }, + "absoluteRenderBounds": { + "height": 154, + "width": 288, + "x": 21837, + "y": 11027 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentPropertyReferences": { + "slotContentId": "list#73:16" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 154, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "list", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 16 + ], + [ + 0, + 1, + 196 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 288, + "x": 16, + "y": 196 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11499": { + "id": "I73:11525;73:11499", + "type": "INSTANCE", + "fields": { + "parentId": "I73:11525;73:11498", + "childrenIds": [ + "I73:11525;73:11499;73:11273", + "I73:11525;73:11499;73:11275" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 288, + "x": 21837, + "y": 11027 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 288, + "x": 21837, + "y": 11027 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DevelopmentGoalsList", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 288, + "x": 0, + "y": 0, + "mainComponentId": "73:11276", + "mainComponentName": "DevelopmentGoalsList", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11499;73:11273": { + "id": "I73:11525;73:11499;73:11273", + "type": "FRAME", + "fields": { + "parentId": "I73:11525;73:11499", + "childrenIds": [ + "I73:11525;73:11499;73:11274" + ], + "absoluteBoundingBox": { + "height": 13, + "width": 5, + "x": 21837, + "y": 11027 + }, + "absoluteRenderBounds": { + "height": 13, + "width": 5, + "x": 21837, + "y": 11027 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingTop": { + "id": "VariableID:73:11681", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 13, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "dot wrap", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 5, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11499;73:11274": { + "id": "I73:11525;73:11499;73:11274", + "type": "FRAME", + "fields": { + "parentId": "I73:11525;73:11499;73:11273", + "absoluteBoundingBox": { + "height": 5, + "width": 5, + "x": 21837, + "y": 11035 + }, + "absoluteRenderBounds": { + "height": 5, + "width": 5, + "x": 21837, + "y": 11035 + }, + "bottomLeftRadius": 999, + "bottomRightRadius": 999, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 999, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.8666666746139526, + "g": 0.6705882549285889, + "r": 0.501960813999176 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 999, + "topRightRadius": 999, + "visible": true, + "width": 5, + "x": 0, + "y": 8 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11499;73:11275": { + "id": "I73:11525;73:11499;73:11275", + "type": "TEXT", + "fields": { + "parentId": "I73:11525;73:11499", + "absoluteBoundingBox": { + "height": 46, + "width": 273, + "x": 21852, + "y": 11027 + }, + "absoluteRenderBounds": { + "height": 36.1552734375, + "width": 261.57421875, + "x": 21852.44921875, + "y": 11031.955078125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "세포 생존력과 연골 분화능을 높이는 것을 목표로 합니다.", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 46, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "세포 생존력과 연골 분화능을 높이는 것을 목표로 합", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 15 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 273, + "x": 15, + "y": 0, + "styledTextSegments": [ + { + "characters": "세포 생존력과 연골 분화능을 높이는 것을 목표로 합니다.", + "end": 31, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11500": { + "id": "I73:11525;73:11500", + "type": "INSTANCE", + "fields": { + "parentId": "I73:11525;73:11498", + "childrenIds": [ + "I73:11525;73:11500;73:11273", + "I73:11525;73:11500;73:11275" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 288, + "x": 21837, + "y": 11081 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 288, + "x": 21837, + "y": 11081 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DevelopmentGoalsList", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 54 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 288, + "x": 0, + "y": 54, + "mainComponentId": "73:11276", + "mainComponentName": "DevelopmentGoalsList", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11500;73:11273": { + "id": "I73:11525;73:11500;73:11273", + "type": "FRAME", + "fields": { + "parentId": "I73:11525;73:11500", + "childrenIds": [ + "I73:11525;73:11500;73:11274" + ], + "absoluteBoundingBox": { + "height": 13, + "width": 5, + "x": 21837, + "y": 11081 + }, + "absoluteRenderBounds": { + "height": 13, + "width": 5, + "x": 21837, + "y": 11081 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingTop": { + "id": "VariableID:73:11681", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 13, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "dot wrap", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 5, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11500;73:11274": { + "id": "I73:11525;73:11500;73:11274", + "type": "FRAME", + "fields": { + "parentId": "I73:11525;73:11500;73:11273", + "absoluteBoundingBox": { + "height": 5, + "width": 5, + "x": 21837, + "y": 11089 + }, + "absoluteRenderBounds": { + "height": 5, + "width": 5, + "x": 21837, + "y": 11089 + }, + "bottomLeftRadius": 999, + "bottomRightRadius": 999, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 999, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.8666666746139526, + "g": 0.6705882549285889, + "r": 0.501960813999176 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 999, + "topRightRadius": 999, + "visible": true, + "width": 5, + "x": 0, + "y": 8 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11500;73:11275": { + "id": "I73:11525;73:11500;73:11275", + "type": "TEXT", + "fields": { + "parentId": "I73:11525;73:11500", + "absoluteBoundingBox": { + "height": 46, + "width": 273, + "x": 21852, + "y": 11081 + }, + "absoluteRenderBounds": { + "height": 36.1552734375, + "width": 260.62890625, + "x": 21852.599609375, + "y": 11085.955078125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "몸속에서 오래 살아남아 기능을 이어 가도록 개발합니다.", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 46, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "세포 생존력과 연골 분화능을 높이는 것을 목표로 합", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 15 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 273, + "x": 15, + "y": 0, + "styledTextSegments": [ + { + "characters": "몸속에서 오래 살아남아 기능을 이어 가도록 개발합니다.", + "end": 30, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11501": { + "id": "I73:11525;73:11501", + "type": "INSTANCE", + "fields": { + "parentId": "I73:11525;73:11498", + "childrenIds": [ + "I73:11525;73:11501;73:11273", + "I73:11525;73:11501;73:11275" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 288, + "x": 21837, + "y": 11135 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 288, + "x": 21837, + "y": 11135 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "DevelopmentGoalsList", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 108 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 288, + "x": 0, + "y": 108, + "mainComponentId": "73:11276", + "mainComponentName": "DevelopmentGoalsList", + "mainComponentSetId": null + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11501;73:11273": { + "id": "I73:11525;73:11501;73:11273", + "type": "FRAME", + "fields": { + "parentId": "I73:11525;73:11501", + "childrenIds": [ + "I73:11525;73:11501;73:11274" + ], + "absoluteBoundingBox": { + "height": 13, + "width": 5, + "x": 21837, + "y": 11135 + }, + "absoluteRenderBounds": { + "height": 13, + "width": 5, + "x": 21837, + "y": 11135 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "paddingTop": { + "id": "VariableID:73:11681", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 13, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "dot wrap", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 5, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11501;73:11274": { + "id": "I73:11525;73:11501;73:11274", + "type": "FRAME", + "fields": { + "parentId": "I73:11525;73:11501;73:11273", + "absoluteBoundingBox": { + "height": 5, + "width": 5, + "x": 21837, + "y": 11143 + }, + "absoluteRenderBounds": { + "height": 5, + "width": 5, + "x": 21837, + "y": 11143 + }, + "bottomLeftRadius": 999, + "bottomRightRadius": 999, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "cornerRadius": 999, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:7", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.8666666746139526, + "g": 0.6705882549285889, + "r": 0.501960813999176 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 999, + "topRightRadius": 999, + "visible": true, + "width": 5, + "x": 0, + "y": 8 + }, + "extra": {}, + "fieldErrors": {} + }, + "I73:11525;73:11501;73:11275": { + "id": "I73:11525;73:11501;73:11275", + "type": "TEXT", + "fields": { + "parentId": "I73:11525;73:11501", + "absoluteBoundingBox": { + "height": 46, + "width": 273, + "x": 21852, + "y": 11135 + }, + "absoluteRenderBounds": { + "height": 36.275390625, + "width": 270.150390625, + "x": 21852.810546875, + "y": 11139.8349609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연골 조직의 구조적 · 기능적 회복을 목표로 합니다.", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 46, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "세포 생존력과 연골 분화능을 높이는 것을 목표로 합", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 15 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 273, + "x": 15, + "y": 0, + "styledTextSegments": [ + { + "characters": "연골 조직의 구조적 · 기능적 회복을 목표로 합니다.", + "end": 29, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "73:8861": { + "id": "73:8861", + "type": "FRAME", + "fields": { + "parentId": "73:8860", + "parentType": "SECTION", + "parentName": "콘드로스페로이드", + "childrenIds": [ + "73:11291" + ], + "absoluteBoundingBox": { + "height": 5091, + "width": 1440, + "x": 20318, + "y": 8487 + }, + "absoluteRenderBounds": { + "height": 5091, + "width": 1440, + "x": 20318, + "y": 8487 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 5091, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "콘드로스페로이드 · Desktop 1440", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 70 + ], + [ + 0, + 1, + 72 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 70, + "y": 72 + }, + "extra": {}, + "fieldErrors": {} + }, + "73:9072": { + "id": "73:9072", + "type": "FRAME", + "fields": { + "parentId": "73:8860", + "parentType": "SECTION", + "parentName": "콘드로스페로이드", + "childrenIds": [ + "73:11525" + ], + "absoluteBoundingBox": { + "height": 6742, + "width": 360, + "x": 21801, + "y": 8487 + }, + "absoluteRenderBounds": { + "height": 6742, + "width": 360, + "x": 21801, + "y": 8487 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 6742, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "콘드로스페로이드 · Mobile 360", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 1553 + ], + [ + 0, + 1, + 72 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 1553, + "y": 72 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/responsive-title-summary-wrappers.json b/crates/devup-mcp-devup-ui/tests/fixtures/responsive-title-summary-wrappers.json new file mode 100644 index 00000000..1d0edc55 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/responsive-title-summary-wrappers.json @@ -0,0 +1,337 @@ +{ + "fileKey": "responsive-wrapper", + "version": null, + "roots": [ + "page:4", + "page:0" + ], + "nodes": { + "eyebrow:0": { + "id": "eyebrow:0", + "type": "TEXT", + "fields": { + "name": "Eyebrow", + "parentId": "title:0", + "characters": "PATENTS", + "fontSize": 16, + "lineHeight": { + "unit": "PIXELS", + "value": 24 + }, + "width": 280, + "height": 24, + "textAutoResize": "WIDTH_AND_HEIGHT" + }, + "extra": {}, + "fieldErrors": {} + }, + "eyebrow:4": { + "id": "eyebrow:4", + "type": "TEXT", + "fields": { + "name": "Eyebrow", + "parentId": "title:4", + "characters": "PATENTS", + "fontSize": 16, + "lineHeight": { + "unit": "PIXELS", + "value": 24 + }, + "width": 280, + "height": 24, + "textAutoResize": "WIDTH_AND_HEIGHT" + }, + "extra": {}, + "fieldErrors": {} + }, + "heading:0": { + "id": "heading:0", + "type": "TEXT", + "fields": { + "name": "Heading", + "parentId": "title:0", + "characters": "특허 · 지식재산권", + "fontSize": 16, + "lineHeight": { + "unit": "PIXELS", + "value": 24 + }, + "width": 280, + "height": 24, + "textAutoResize": "WIDTH_AND_HEIGHT" + }, + "extra": {}, + "fieldErrors": {} + }, + "heading:4": { + "id": "heading:4", + "type": "TEXT", + "fields": { + "name": "Heading", + "parentId": "title:4", + "characters": "특허 · 지식재산권", + "fontSize": 16, + "lineHeight": { + "unit": "PIXELS", + "value": 24 + }, + "width": 280, + "height": 24, + "textAutoResize": "WIDTH_AND_HEIGHT" + }, + "extra": {}, + "fieldErrors": {} + }, + "page:0": { + "id": "page:0", + "type": "FRAME", + "fields": { + "name": "Patent section", + "parentId": null, + "width": 360, + "height": 100, + "layoutMode": "VERTICAL", + "inferredAutoLayout": { + "layoutMode": "VERTICAL" + }, + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "itemSpacing": 24, + "primaryAxisAlignItems": "MIN", + "counterAxisAlignItems": "MIN", + "childrenIds": [ + "title:0", + "summary:0", + "table:0" + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "page:4": { + "id": "page:4", + "type": "FRAME", + "fields": { + "name": "Patent section", + "parentId": null, + "width": 1440, + "height": 100, + "layoutMode": "VERTICAL", + "inferredAutoLayout": { + "layoutMode": "VERTICAL" + }, + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "itemSpacing": 32, + "primaryAxisAlignItems": "MIN", + "counterAxisAlignItems": "MIN", + "childrenIds": [ + "row:4", + "table:4" + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "row:4": { + "id": "row:4", + "type": "FRAME", + "fields": { + "name": "Title and summary", + "parentId": "page:4", + "width": 1280, + "height": 100, + "layoutMode": "HORIZONTAL", + "inferredAutoLayout": { + "layoutMode": "HORIZONTAL" + }, + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "itemSpacing": 40, + "primaryAxisAlignItems": "SPACE_BETWEEN", + "counterAxisAlignItems": "CENTER", + "childrenIds": [ + "title:4", + "summary:4" + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "summary-text:0": { + "id": "summary-text:0", + "type": "TEXT", + "fields": { + "name": "Summary", + "parentId": "summary:0", + "characters": "전체 3건 / 등록 2건 / 출원 1건", + "fontSize": 16, + "lineHeight": { + "unit": "PIXELS", + "value": 24 + }, + "width": 280, + "height": 24, + "textAutoResize": "WIDTH_AND_HEIGHT" + }, + "extra": {}, + "fieldErrors": {} + }, + "summary-text:4": { + "id": "summary-text:4", + "type": "TEXT", + "fields": { + "name": "Summary", + "parentId": "summary:4", + "characters": "전체 3건 / 등록 2건 / 출원 1건", + "fontSize": 16, + "lineHeight": { + "unit": "PIXELS", + "value": 24 + }, + "width": 280, + "height": 24, + "textAutoResize": "WIDTH_AND_HEIGHT" + }, + "extra": {}, + "fieldErrors": {} + }, + "summary:0": { + "id": "summary:0", + "type": "FRAME", + "fields": { + "name": "Patent summary", + "parentId": "page:0", + "width": 320, + "height": 100, + "layoutMode": "HORIZONTAL", + "inferredAutoLayout": { + "layoutMode": "HORIZONTAL" + }, + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "itemSpacing": 16, + "primaryAxisAlignItems": "MIN", + "counterAxisAlignItems": "MIN", + "childrenIds": [ + "summary-text:0" + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "summary:4": { + "id": "summary:4", + "type": "FRAME", + "fields": { + "name": "Patent summary", + "parentId": "row:4", + "width": 400, + "height": 100, + "layoutMode": "HORIZONTAL", + "inferredAutoLayout": { + "layoutMode": "HORIZONTAL" + }, + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "itemSpacing": 16, + "primaryAxisAlignItems": "MIN", + "counterAxisAlignItems": "MIN", + "childrenIds": [ + "summary-text:4" + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "table:0": { + "id": "table:0", + "type": "TEXT", + "fields": { + "name": "Patent list", + "parentId": "page:0", + "characters": "특허 목록", + "fontSize": 16, + "lineHeight": { + "unit": "PIXELS", + "value": 24 + }, + "width": 280, + "height": 24, + "textAutoResize": "WIDTH_AND_HEIGHT" + }, + "extra": {}, + "fieldErrors": {} + }, + "table:4": { + "id": "table:4", + "type": "TEXT", + "fields": { + "name": "Patent list", + "parentId": "page:4", + "characters": "특허 목록", + "fontSize": 16, + "lineHeight": { + "unit": "PIXELS", + "value": 24 + }, + "width": 280, + "height": 24, + "textAutoResize": "WIDTH_AND_HEIGHT" + }, + "extra": {}, + "fieldErrors": {} + }, + "title:0": { + "id": "title:0", + "type": "FRAME", + "fields": { + "name": "Section title", + "parentId": "page:0", + "width": 320, + "height": 100, + "layoutMode": "VERTICAL", + "inferredAutoLayout": { + "layoutMode": "VERTICAL" + }, + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "itemSpacing": 8, + "primaryAxisAlignItems": "MIN", + "counterAxisAlignItems": "MIN", + "childrenIds": [ + "eyebrow:0", + "heading:0" + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "title:4": { + "id": "title:4", + "type": "FRAME", + "fields": { + "name": "Section title", + "parentId": "row:4", + "width": 600, + "height": 100, + "layoutMode": "VERTICAL", + "inferredAutoLayout": { + "layoutMode": "VERTICAL" + }, + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "itemSpacing": 12, + "primaryAxisAlignItems": "MIN", + "counterAxisAlignItems": "MIN", + "childrenIds": [ + "eyebrow:4", + "heading:4" + ] + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/transferred-image-minimum.json b/crates/devup-mcp-devup-ui/tests/fixtures/transferred-image-minimum.json new file mode 100644 index 00000000..6d00f0d7 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/transferred-image-minimum.json @@ -0,0 +1,336 @@ +{ + "fileKey": "transferred-minimum-regression", + "roots": [ + "mobile", + "desktop" + ], + "nodes": { + "desktop": { + "id": "desktop", + "type": "FRAME", + "fields": { + "name": "desktop", + "width": 1440, + "height": 800, + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "paddingLeft": 163, + "paddingRight": 163, + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "paddingLeft": 163, + "paddingRight": 163, + "counterAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED", + "counterAxisSizingMode": "FIXED", + "paddingTop": 0, + "paddingBottom": 0 + }, + "childrenIds": [ + "body-desktop" + ], + "absoluteBoundingBox": { + "x": 0, + "y": 0, + "width": 1440, + "height": 800 + }, + "absoluteRenderBounds": { + "x": 0, + "y": 0, + "width": 1440, + "height": 800 + }, + "x": 0, + "y": 0 + } + }, + "body-desktop": { + "id": "body-desktop", + "type": "FRAME", + "fields": { + "name": "body", + "parentId": "desktop", + "width": 1114, + "height": 400.74074074074076, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "itemSpacing": 80, + "inferredAutoLayout": { + "layoutMode": "HORIZONTAL", + "itemSpacing": 80, + "counterAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED", + "counterAxisSizingMode": "AUTO", + "paddingTop": 0, + "paddingRight": 0, + "paddingBottom": 0, + "paddingLeft": 0 + }, + "childrenIds": [ + "steps-desktop", + "image-desktop" + ], + "absoluteBoundingBox": { + "x": 163, + "y": 0, + "width": 1114, + "height": 400.74074074074076 + }, + "absoluteRenderBounds": { + "x": 163, + "y": 0, + "width": 1114, + "height": 400.74074074074076 + }, + "x": 0, + "y": 0 + } + }, + "steps-desktop": { + "id": "steps-desktop", + "type": "FRAME", + "fields": { + "name": "steps", + "parentId": "body-desktop", + "width": 493, + "height": 392, + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "layoutGrow": 1, + "layoutAlign": "INHERIT", + "childrenIds": [], + "absoluteBoundingBox": { + "x": 163, + "y": 0, + "width": 493, + "height": 392 + }, + "absoluteRenderBounds": { + "x": 163, + "y": 0, + "width": 493, + "height": 392 + }, + "x": 0, + "y": 0 + } + }, + "image-desktop": { + "id": "image-desktop", + "type": "FRAME", + "fields": { + "name": "visual", + "parentId": "body-desktop", + "width": 541, + "height": 400.74074074074076, + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "layoutGrow": 1, + "layoutAlign": "INHERIT", + "isAsset": true, + "layoutMode": "VERTICAL", + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "paddingTop": 0, + "paddingRight": 0, + "paddingBottom": 0, + "paddingLeft": 0 + }, + "targetAspectRatio": { + "x": 540, + "y": 400 + }, + "fills": [ + { + "type": "IMAGE", + "imageHash": "source-image", + "scaleMode": "FILL" + } + ], + "absoluteBoundingBox": { + "x": 736, + "y": 0, + "width": 541, + "height": 400.74074074074076 + }, + "absoluteRenderBounds": { + "x": 736, + "y": 0, + "width": 541, + "height": 400.74074074074076 + }, + "x": 0, + "y": 0 + } + }, + "mobile": { + "id": "mobile", + "type": "FRAME", + "fields": { + "name": "mobile", + "width": 360, + "height": 800, + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "paddingLeft": 20, + "paddingRight": 20, + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "paddingLeft": 20, + "paddingRight": 20, + "counterAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED", + "counterAxisSizingMode": "FIXED", + "paddingTop": 0, + "paddingBottom": 0 + }, + "childrenIds": [ + "body-mobile" + ], + "absoluteBoundingBox": { + "x": 0, + "y": 0, + "width": 360, + "height": 800 + }, + "absoluteRenderBounds": { + "x": 0, + "y": 0, + "width": 360, + "height": 800 + }, + "x": 0, + "y": 0 + } + }, + "body-mobile": { + "id": "body-mobile", + "type": "FRAME", + "fields": { + "name": "body", + "parentId": "mobile", + "width": 320, + "height": 400.74074074074076, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "itemSpacing": 24, + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "itemSpacing": 24, + "counterAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO", + "counterAxisSizingMode": "FIXED", + "paddingTop": 0, + "paddingRight": 0, + "paddingBottom": 0, + "paddingLeft": 0 + }, + "childrenIds": [ + "steps-mobile", + "image-mobile" + ], + "absoluteBoundingBox": { + "x": 20, + "y": 0, + "width": 320, + "height": 400.74074074074076 + }, + "absoluteRenderBounds": { + "x": 20, + "y": 0, + "width": 320, + "height": 400.74074074074076 + }, + "x": 0, + "y": 0 + } + }, + "steps-mobile": { + "id": "steps-mobile", + "type": "FRAME", + "fields": { + "name": "steps", + "parentId": "body-mobile", + "width": 320, + "height": 392, + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "layoutGrow": 0, + "layoutAlign": "INHERIT", + "childrenIds": [], + "absoluteBoundingBox": { + "x": 20, + "y": 0, + "width": 320, + "height": 392 + }, + "absoluteRenderBounds": { + "x": 20, + "y": 0, + "width": 320, + "height": 392 + }, + "x": 0, + "y": 0 + } + }, + "image-mobile": { + "id": "image-mobile", + "type": "FRAME", + "fields": { + "name": "visual", + "parentId": "body-mobile", + "width": 320, + "height": 237.03703703703704, + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "layoutGrow": 0, + "layoutAlign": "INHERIT", + "isAsset": true, + "layoutMode": "VERTICAL", + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "paddingTop": 0, + "paddingRight": 0, + "paddingBottom": 0, + "paddingLeft": 0 + }, + "targetAspectRatio": { + "x": 540, + "y": 400 + }, + "fills": [ + { + "type": "IMAGE", + "imageHash": "source-image", + "scaleMode": "FILL" + } + ], + "absoluteBoundingBox": { + "x": 20, + "y": 416, + "width": 320, + "height": 237.03703703703704 + }, + "absoluteRenderBounds": { + "x": 20, + "y": 416, + "width": 320, + "height": 237.03703703703704 + }, + "x": 0, + "y": 0 + } + } + }, + "diagnostics": [] +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/vision-border-origin.json b/crates/devup-mcp-devup-ui/tests/fixtures/vision-border-origin.json new file mode 100644 index 00000000..5187cdeb --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/vision-border-origin.json @@ -0,0 +1,573 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "44:2832", + "44:3235" + ], + "nodes": { + "44:2832": { + "id": "44:2832", + "type": "INSTANCE", + "fields": { + "parentId": "44:2831", + "childrenIds": [ + "I44:2832;44:3209", + "I44:2832;44:3125", + "I44:2832;44:3146" + ], + "absoluteBoundingBox": { + "height": 189, + "width": 752, + "x": 23175, + "y": 3369 + }, + "absoluteRenderBounds": { + "height": 189, + "width": 770, + "x": 23157, + "y": 3369 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Desktop" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 189, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 52, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 52, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "CoreValuesCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "44:2832", + "overriddenFields": [ + "height", + "stokeTopWeight", + "width" + ] + }, + { + "id": "I44:2832;44:3125;44:2703", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:2832;44:3125;44:2704", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:2832;44:3125;44:2705", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:2832;44:3125;44:2706", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:2832;44:3125;44:2707", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:2832;44:3125;44:2708", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:2832;44:3125;44:2709", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:2832;44:3130", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 52, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 52, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 1, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "viewport": "Desktop" + }, + "visible": true, + "width": 752, + "x": 0, + "y": 0, + "mainComponentId": "44:3123", + "mainComponentName": "viewport=Desktop", + "mainComponentSetId": "44:3122" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2832;44:3209": { + "id": "I44:2832;44:3209", + "type": "ELLIPSE", + "fields": { + "parentId": "44:2832", + "absoluteBoundingBox": { + "height": 48, + "width": 48, + "x": 23157, + "y": 3403 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 48, + "x": 23157, + "y": 3403 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + -18 + ], + [ + 0, + 1, + 34 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 48, + "x": -18, + "y": 34 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:3235": { + "id": "44:3235", + "type": "INSTANCE", + "fields": { + "parentId": "44:3234", + "childrenIds": [ + "I44:3235;44:3700", + "I44:3235;44:3701", + "I44:3235;44:3702" + ], + "absoluteBoundingBox": { + "height": 192, + "width": 320, + "x": 24064, + "y": 3485 + }, + "absoluteRenderBounds": { + "height": 192, + "width": 330, + "x": 24054, + "y": 3485 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "strokes": [ + { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + ] + }, + "componentProperties": { + "viewport": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Mobile" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 192, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 20, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 36, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 36, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 20, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "CoreValuesCard", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "44:3235", + "overriddenFields": [ + "height", + "stokeTopWeight", + "width" + ] + }, + { + "id": "I44:3235;44:3701;44:2703", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:3235;44:3701;44:2704", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:3235;44:3701;44:2705", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:3235;44:3701;44:2706", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:3235;44:3701;44:2707", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:3235;44:3701;44:2708", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:3235;44:3701;44:2709", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:3235;44:3704", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 36, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 36, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 0, + "strokeRightWeight": 0, + "strokeTopWeight": 1, + "strokeWeight": { + "$unsupported": "symbol" + }, + "strokes": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:351", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9215686321258544, + "g": 0.9058823585510254, + "r": 0.8980392217636108 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "viewport": "Mobile" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 0, + "mainComponentId": "44:3699", + "mainComponentName": "viewport=Mobile", + "mainComponentSetId": "44:3122" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3235;44:3700": { + "id": "I44:3235;44:3700", + "type": "ELLIPSE", + "fields": { + "parentId": "44:3235", + "absoluteBoundingBox": { + "height": 32, + "width": 32, + "x": 24054, + "y": 3514 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32, + "x": 24054, + "y": 3514 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 32, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + -10 + ], + [ + 0, + 1, + 29 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 32, + "x": -10, + "y": 29 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/vision-solid-ellipses.json b/crates/devup-mcp-devup-ui/tests/fixtures/vision-solid-ellipses.json new file mode 100644 index 00000000..22357d94 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/vision-solid-ellipses.json @@ -0,0 +1,729 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "I44:2832;44:3209", + "I44:2833;44:3209", + "I44:2834;44:3209", + "I44:2835;44:3209", + "I44:3235;44:3700", + "I44:3236;44:3700", + "I44:3237;44:3700", + "I44:3238;44:3700" + ], + "nodes": { + "I44:2832;44:3209": { + "id": "I44:2832;44:3209", + "type": "ELLIPSE", + "fields": { + "parentId": "44:2832", + "absoluteBoundingBox": { + "height": 48, + "width": 48, + "x": 23157, + "y": 3403 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 48, + "x": 23157, + "y": 3403 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + -18 + ], + [ + 0, + 1, + 34 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 48, + "x": -18, + "y": 34 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2833;44:3209": { + "id": "I44:2833;44:3209", + "type": "ELLIPSE", + "fields": { + "parentId": "44:2833", + "absoluteBoundingBox": { + "height": 48, + "width": 48, + "x": 23157, + "y": 3592 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 48, + "x": 23157, + "y": 3592 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + -18 + ], + [ + 0, + 1, + 34 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 48, + "x": -18, + "y": 34 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2834;44:3209": { + "id": "I44:2834;44:3209", + "type": "ELLIPSE", + "fields": { + "parentId": "44:2834", + "absoluteBoundingBox": { + "height": 48, + "width": 48, + "x": 23157, + "y": 3780 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 48, + "x": 23157, + "y": 3780 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + -18 + ], + [ + 0, + 1, + 34 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 48, + "x": -18, + "y": 34 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2835;44:3209": { + "id": "I44:2835;44:3209", + "type": "ELLIPSE", + "fields": { + "parentId": "44:2835", + "absoluteBoundingBox": { + "height": 48, + "width": 48, + "x": 23157, + "y": 3968 + }, + "absoluteRenderBounds": { + "height": 48, + "width": 48, + "x": 23157, + "y": 3968 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 48, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + -18 + ], + [ + 0, + 1, + 34 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 48, + "x": -18, + "y": 34 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3235;44:3700": { + "id": "I44:3235;44:3700", + "type": "ELLIPSE", + "fields": { + "parentId": "44:3235", + "absoluteBoundingBox": { + "height": 32, + "width": 32, + "x": 24054, + "y": 3514 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32, + "x": 24054, + "y": 3514 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 32, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + -10 + ], + [ + 0, + 1, + 29 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 32, + "x": -10, + "y": 29 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3236;44:3700": { + "id": "I44:3236;44:3700", + "type": "ELLIPSE", + "fields": { + "parentId": "44:3236", + "absoluteBoundingBox": { + "height": 32, + "width": 32, + "x": 24054, + "y": 3706 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32, + "x": 24054, + "y": 3706 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 32, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + -10 + ], + [ + 0, + 1, + 29 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 32, + "x": -10, + "y": 29 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3237;44:3700": { + "id": "I44:3237;44:3700", + "type": "ELLIPSE", + "fields": { + "parentId": "44:3237", + "absoluteBoundingBox": { + "height": 32, + "width": 32, + "x": 24054, + "y": 3871 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32, + "x": 24054, + "y": 3871 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 32, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + -10 + ], + [ + 0, + 1, + 29 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 32, + "x": -10, + "y": 29 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3238;44:3700": { + "id": "I44:3238;44:3700", + "type": "ELLIPSE", + "fields": { + "parentId": "44:3238", + "absoluteBoundingBox": { + "height": 32, + "width": 32, + "x": 24054, + "y": 4036 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 32, + "x": 24054, + "y": 4036 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:11:2", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 0.9715099334716796, + "r": 0.9487178325653076 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 32, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 3", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + -10 + ], + [ + 0, + 1, + 29 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 32, + "x": -10, + "y": 29 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/vision-timeline-width-specific-spacers.json b/crates/devup-mcp-devup-ui/tests/fixtures/vision-timeline-width-specific-spacers.json new file mode 100644 index 00000000..583b1d04 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/vision-timeline-width-specific-spacers.json @@ -0,0 +1,7179 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "44:2812", + "44:3215" + ], + "diagnostics": [], + "nodes": { + "I44:2843;44:1983": { + "id": "I44:2843;44:1983", + "type": "FRAME", + "fields": { + "parentId": "44:2843", + "childrenIds": [ + "I44:2843;44:1984" + ], + "absoluteBoundingBox": { + "height": 32, + "width": 16, + "x": 23279, + "y": 4740 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 16, + "x": 23279, + "y": 4740 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 32, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 522", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "relativeTransform": [ + [ + 1, + 0, + 632 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 16, + "x": 632, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2845;44:1983": { + "id": "I44:2845;44:1983", + "type": "FRAME", + "fields": { + "parentId": "44:2845", + "childrenIds": [ + "I44:2845;44:1984" + ], + "absoluteBoundingBox": { + "height": 32, + "width": 16, + "x": 23279, + "y": 5212 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 16, + "x": 23279, + "y": 5212 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 32, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 522", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "relativeTransform": [ + [ + 1, + 0, + 632 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 16, + "x": 632, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2845;44:1984": { + "id": "I44:2845;44:1984", + "type": "ELLIPSE", + "fields": { + "parentId": "I44:2845;44:1983", + "absoluteBoundingBox": { + "height": 16, + "width": 16, + "x": 23279, + "y": 5220 + }, + "absoluteRenderBounds": { + "height": 16, + "width": 16, + "x": 23279, + "y": 5220 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 16, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 2", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 16, + "x": 0, + "y": 8 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:3250": { + "id": "44:3250", + "type": "INSTANCE", + "fields": { + "parentId": "44:3242", + "childrenIds": [ + "I44:3250;44:3553", + "I44:3250;44:3555" + ], + "absoluteBoundingBox": { + "height": 116, + "width": 320, + "x": 24064, + "y": 5363 + }, + "absoluteRenderBounds": { + "height": 116, + "width": 320, + "x": 24064, + "y": 5363 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "Slot#44:4": { + "preferredValues": [], + "type": "SLOT" + }, + "align": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Mobile" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 116, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Rodamap", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "44:3250", + "overriddenFields": [ + "counterAxisSizingMode", + "height", + "width" + ] + }, + { + "id": "I44:3250;44:3554", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:3250;44:3556", + "overriddenFields": [ + "characters", + "fills", + "styledTextSegments" + ] + }, + { + "id": "I44:3250;44:3557", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 974 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "align": "Mobile" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 974, + "mainComponentId": "44:3551", + "mainComponentName": "align=Mobile", + "mainComponentSetId": "44:1981" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2847;44:1984": { + "id": "I44:2847;44:1984", + "type": "ELLIPSE", + "fields": { + "parentId": "I44:2847;44:1983", + "absoluteBoundingBox": { + "height": 16, + "width": 16, + "x": 23279, + "y": 5630 + }, + "absoluteRenderBounds": { + "height": 16, + "width": 16, + "x": 23279, + "y": 5630 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 16, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 2", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 16, + "x": 0, + "y": 8 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3250;44:3553": { + "id": "I44:3250;44:3553", + "type": "FRAME", + "fields": { + "parentId": "44:3250", + "childrenIds": [ + "I44:3250;44:3554" + ], + "absoluteBoundingBox": { + "height": 26, + "width": 12, + "x": 24064, + "y": 5363 + }, + "absoluteRenderBounds": { + "height": 26, + "width": 12, + "x": 24064, + "y": 5363 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 26, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 7, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 7, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 522", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 7, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 7, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2843;44:1988;44:2859": { + "id": "I44:2843;44:1988;44:2859", + "type": "TEXT", + "fields": { + "parentId": "I44:2843;44:1988", + "absoluteBoundingBox": { + "height": 23, + "width": 608, + "x": 22647, + "y": 4889 + }, + "absoluteRenderBounds": { + "height": 13.3349609375, + "width": 188.47265625, + "x": 23065.94140625, + "y": 4893.81982421875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "동종 · 주사형 전임상 데이터 확보", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "인프라 구축", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 62 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 62, + "styledTextSegments": [ + { + "characters": "동종 · 주사형 전임상 데이터 확보", + "end": 19, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2843;44:1988": { + "id": "I44:2843;44:1988", + "type": "SLOT", + "fields": { + "parentId": "I44:2843;44:1985", + "childrenIds": [ + "I44:2843;44:1988;44:2857", + "I44:2843;44:1988;44:2858", + "I44:2843;44:1988;44:2859" + ], + "absoluteBoundingBox": { + "height": 85, + "width": 608, + "x": 22647, + "y": 4827 + }, + "absoluteRenderBounds": { + "height": 85, + "width": 608, + "x": 22647, + "y": 4827 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentPropertyReferences": { + "slotContentId": "Slot#44:4" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "MAX", + "counterAxisSpacing": 0, + "height": 85, + "inferredAutoLayout": { + "counterAxisAlignItems": "MAX", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Slot", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 87 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 608, + "x": 0, + "y": 87 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2847;44:1986": { + "id": "I44:2847;44:1986", + "type": "TEXT", + "fields": { + "parentId": "I44:2847;44:1985", + "absoluteBoundingBox": { + "height": 34, + "width": 608, + "x": 22647, + "y": 5622 + }, + "absoluteRenderBounds": { + "height": 17.97607421875, + "width": 55.359375, + "x": 23198.552734375, + "y": 5630.35986328125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:946", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2035", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 24, + "height": 34, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "2025-2026", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "2035", + "end": 4, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2847;44:1987": { + "id": "I44:2847;44:1987", + "type": "TEXT", + "fields": { + "parentId": "I44:2847;44:1985", + "absoluteBoundingBox": { + "height": 29, + "width": 608, + "x": 22647, + "y": 5668 + }, + "absoluteRenderBounds": { + "height": 16.1279296875, + "width": 79.89453125, + "x": 23174.529296875, + "y": 5674.330078125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:951", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "글로벌 진출", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 18, + "height": 29, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 160.0000023841858 + }, + "maxHeight": null, + "maxWidth": null, + "name": "기반 구축", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 46 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 46, + "styledTextSegments": [ + { + "characters": "글로벌 진출", + "end": 6, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "44:3239": { + "id": "44:3239", + "type": "FRAME", + "fields": { + "parentId": "44:3215", + "childrenIds": [ + "44:3240" + ], + "absoluteBoundingBox": { + "height": 1331, + "width": 360, + "x": 24044, + "y": 4236 + }, + "absoluteRenderBounds": { + "height": 1331, + "width": 360, + "x": 24044, + "y": 4236 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + ], + "paddingBottom": { + "id": "VariableID:42:1085", + "type": "VARIABLE_ALIAS" + }, + "paddingLeft": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + }, + "paddingRight": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + }, + "paddingTop": { + "id": "VariableID:42:1085", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9764705896377563 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1331, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 64, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 64, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 482", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 64, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 64, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 3117 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 0, + "y": 3117 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3246;44:3558;44:3260": { + "id": "I44:3246;44:3558;44:3260", + "type": "TEXT", + "fields": { + "parentId": "I44:3246;44:3558", + "absoluteBoundingBox": { + "height": 21, + "width": 114, + "x": 24092, + "y": 4663 + }, + "absoluteRenderBounds": { + "height": 12.34814453125, + "width": 111.525390625, + "x": 24092.658203125, + "y": 4667.72998046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "기업부설 연구소 설립", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "기업부설 연구소 설립", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 114, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "기업부설 연구소 설립", + "end": 11, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3248;44:3555": { + "id": "I44:3248;44:3555", + "type": "FRAME", + "fields": { + "parentId": "44:3248", + "childrenIds": [ + "I44:3248;44:3556", + "I44:3248;44:3557", + "I44:3248;44:3558" + ], + "absoluteBoundingBox": { + "height": 116, + "width": 292, + "x": 24092, + "y": 5003 + }, + "absoluteRenderBounds": { + "height": 116, + "width": 292, + "x": 24092, + "y": 5003 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 116, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 519", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 28 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 292, + "x": 28, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3248;44:3558": { + "id": "I44:3248;44:3558", + "type": "SLOT", + "fields": { + "parentId": "I44:3248;44:3555", + "childrenIds": [ + "I44:3248;44:3558;44:3268", + "I44:3248;44:3558;44:3269" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 292, + "x": 24092, + "y": 5073 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 292, + "x": 24092, + "y": 5073 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentPropertyReferences": { + "slotContentId": "Slot#44:4" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Slot", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 70 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 292, + "x": 0, + "y": 70 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2843;44:2036": { + "id": "I44:2843;44:2036", + "type": "FRAME", + "fields": { + "parentId": "44:2843", + "absoluteBoundingBox": { + "height": 100, + "width": 608, + "x": 23319, + "y": 4740 + }, + "absoluteRenderBounds": { + "height": 100, + "width": 608, + "x": 23319, + "y": 4740 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 100, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 523", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 672 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 608, + "x": 672, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3246;44:3555": { + "id": "I44:3246;44:3555", + "type": "FRAME", + "fields": { + "parentId": "44:3246", + "childrenIds": [ + "I44:3246;44:3556", + "I44:3246;44:3557", + "I44:3246;44:3558" + ], + "absoluteBoundingBox": { + "height": 141, + "width": 292, + "x": 24092, + "y": 4593 + }, + "absoluteRenderBounds": { + "height": 141, + "width": 292, + "x": 24092, + "y": 4593 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 141, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 519", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 28 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 292, + "x": 28, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3248;44:3553": { + "id": "I44:3248;44:3553", + "type": "FRAME", + "fields": { + "parentId": "44:3248", + "childrenIds": [ + "I44:3248;44:3554" + ], + "absoluteBoundingBox": { + "height": 26, + "width": 12, + "x": 24064, + "y": 5003 + }, + "absoluteRenderBounds": { + "height": 26, + "width": 12, + "x": 24064, + "y": 5003 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 26, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 7, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 7, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 522", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 7, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 7, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2843;44:1985": { + "id": "I44:2843;44:1985", + "type": "FRAME", + "fields": { + "parentId": "44:2843", + "childrenIds": [ + "I44:2843;44:1986", + "I44:2843;44:1987", + "I44:2843;44:1988" + ], + "absoluteBoundingBox": { + "height": 172, + "width": 608, + "x": 22647, + "y": 4740 + }, + "absoluteRenderBounds": { + "height": 172, + "width": 608, + "x": 22647, + "y": 4740 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 172, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 12, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 12, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 519", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 608, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3248;44:3554": { + "id": "I44:3248;44:3554", + "type": "ELLIPSE", + "fields": { + "parentId": "I44:3248;44:3553", + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 24064, + "y": 5010 + }, + "absoluteRenderBounds": { + "height": 12, + "width": 12, + "x": 24064, + "y": 5010 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 12, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 2", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 7 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 12, + "x": 0, + "y": 7 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3246;44:3558;44:3261": { + "id": "I44:3246;44:3558;44:3261", + "type": "TEXT", + "fields": { + "parentId": "I44:3246;44:3558", + "absoluteBoundingBox": { + "height": 21, + "width": 150, + "x": 24092, + "y": 4688 + }, + "absoluteRenderBounds": { + "height": 12.39013671875, + "width": 147.8984375, + "x": 24092.517578125, + "y": 4692.68798828125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "첨단재생의료 임상연구 승인", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "첨단재생의료 임상연구 승인", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 25 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 150, + "x": 0, + "y": 25, + "styledTextSegments": [ + { + "characters": "첨단재생의료 임상연구 승인", + "end": 14, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2847;44:1988;44:2872": { + "id": "I44:2847;44:1988;44:2872", + "type": "TEXT", + "fields": { + "parentId": "I44:2847;44:1988", + "absoluteBoundingBox": { + "height": 23, + "width": 608, + "x": 22647, + "y": 5740 + }, + "absoluteRenderBounds": { + "height": 13.28955078125, + "width": 95.0859375, + "x": 23159.283203125, + "y": 5744.865234375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "글로벌 시장 진출", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "인프라 구축", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 31 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 31, + "styledTextSegments": [ + { + "characters": "글로벌 시장 진출", + "end": 9, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2845;44:1985": { + "id": "I44:2845;44:1985", + "type": "FRAME", + "fields": { + "parentId": "44:2845", + "childrenIds": [ + "I44:2845;44:1986", + "I44:2845;44:1987", + "I44:2845;44:1988" + ], + "absoluteBoundingBox": { + "height": 141, + "width": 608, + "x": 22647, + "y": 5212 + }, + "absoluteRenderBounds": { + "height": 141, + "width": 608, + "x": 22647, + "y": 5212 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 141, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 12, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 12, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 519", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 608, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:2839": { + "id": "44:2839", + "type": "FRAME", + "fields": { + "parentId": "44:2837", + "childrenIds": [ + "44:2843", + "44:2845", + "44:2847" + ], + "absoluteBoundingBox": { + "height": 1308, + "width": 1280, + "x": 22647, + "y": 4495 + }, + "absoluteRenderBounds": { + "height": 1309, + "width": 1280, + "x": 22647, + "y": 4494.5 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 1308, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 64, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 40, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 40, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 64, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 526", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 40, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 40, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 133 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1280, + "x": 0, + "y": 133 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3246;44:3554": { + "id": "I44:3246;44:3554", + "type": "ELLIPSE", + "fields": { + "parentId": "I44:3246;44:3553", + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 24064, + "y": 4600 + }, + "absoluteRenderBounds": { + "height": 12, + "width": 12, + "x": 24064, + "y": 4600 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 12, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 2", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 7 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 12, + "x": 0, + "y": 7 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3250;44:3558": { + "id": "I44:3250;44:3558", + "type": "SLOT", + "fields": { + "parentId": "I44:3250;44:3555", + "childrenIds": [ + "I44:3250;44:3558;44:3274", + "I44:3250;44:3558;44:3275" + ], + "absoluteBoundingBox": { + "height": 46, + "width": 292, + "x": 24092, + "y": 5433 + }, + "absoluteRenderBounds": { + "height": 46, + "width": 292, + "x": 24092, + "y": 5433 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentPropertyReferences": { + "slotContentId": "Slot#44:4" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 46, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Slot", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 70 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 292, + "x": 0, + "y": 70 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2845;44:1988;44:2865": { + "id": "I44:2845;44:1988;44:2865", + "type": "TEXT", + "fields": { + "parentId": "I44:2845;44:1988", + "absoluteBoundingBox": { + "height": 23, + "width": 608, + "x": 22647, + "y": 5299 + }, + "absoluteRenderBounds": { + "height": 13.349609375, + "width": 148.359375, + "x": 23104.900390625, + "y": 5303.80517578125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "동물실험 대체법 판매 개시", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "법인 설립 (2025. 08)", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "동물실험 대체법 판매 개시", + "end": 14, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2847;44:1985": { + "id": "I44:2847;44:1985", + "type": "FRAME", + "fields": { + "parentId": "44:2847", + "childrenIds": [ + "I44:2847;44:1986", + "I44:2847;44:1987", + "I44:2847;44:1988" + ], + "absoluteBoundingBox": { + "height": 141, + "width": 608, + "x": 22647, + "y": 5622 + }, + "absoluteRenderBounds": { + "height": 141, + "width": 608, + "x": 22647, + "y": 5622 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 141, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 12, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 12, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 519", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 608, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:3248": { + "id": "44:3248", + "type": "INSTANCE", + "fields": { + "parentId": "44:3242", + "childrenIds": [ + "I44:3248;44:3553", + "I44:3248;44:3555" + ], + "absoluteBoundingBox": { + "height": 116, + "width": 320, + "x": 24064, + "y": 5003 + }, + "absoluteRenderBounds": { + "height": 116, + "width": 320, + "x": 24064, + "y": 5003 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "Slot#44:4": { + "preferredValues": [], + "type": "SLOT" + }, + "align": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Mobile" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 116, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Rodamap", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "44:3248", + "overriddenFields": [ + "counterAxisSizingMode", + "height", + "width" + ] + }, + { + "id": "I44:3248;44:3554", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:3248;44:3556", + "overriddenFields": [ + "characters", + "fills", + "styledTextSegments" + ] + }, + { + "id": "I44:3248;44:3557", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 614 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "align": "Mobile" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 614, + "mainComponentId": "44:3551", + "mainComponentName": "align=Mobile", + "mainComponentSetId": "44:1981" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2843;44:1984": { + "id": "I44:2843;44:1984", + "type": "ELLIPSE", + "fields": { + "parentId": "I44:2843;44:1983", + "absoluteBoundingBox": { + "height": 16, + "width": 16, + "x": 23279, + "y": 4748 + }, + "absoluteRenderBounds": { + "height": 16, + "width": 16, + "x": 23279, + "y": 4748 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 16, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 2", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 8 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 16, + "x": 0, + "y": 8 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:3240": { + "id": "44:3240", + "type": "FRAME", + "fields": { + "parentId": "44:3239", + "childrenIds": [ + "44:3242" + ], + "absoluteBoundingBox": { + "height": 1203, + "width": 320, + "x": 24064, + "y": 4300 + }, + "absoluteRenderBounds": { + "height": 1203.5, + "width": 320, + "x": 24064, + "y": 4300 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 1203, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 24, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 1280, + "name": "Frame 482", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 20 + ], + [ + 0, + 1, + 64 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 20, + "y": 64 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3248;44:3556": { + "id": "I44:3248;44:3556", + "type": "TEXT", + "fields": { + "parentId": "I44:3248;44:3555", + "absoluteBoundingBox": { + "height": 28, + "width": 292, + "x": 24092, + "y": 5003 + }, + "absoluteRenderBounds": { + "height": 14.92041015625, + "width": 99.513671875, + "x": 24092.919921875, + "y": 5009.2998046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:946", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2031–2032", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 20, + "height": 28, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "2025-2026", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66,", + "textTruncation": "DISABLED", + "visible": true, + "width": 292, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "2031–2032", + "end": 9, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2845;44:1988;44:2866": { + "id": "I44:2845;44:1988;44:2866", + "type": "TEXT", + "fields": { + "parentId": "I44:2845;44:1988", + "absoluteBoundingBox": { + "height": 23, + "width": 608, + "x": 22647, + "y": 5330 + }, + "absoluteRenderBounds": { + "height": 13.96533203125, + "width": 185.341796875, + "x": 23067.93359375, + "y": 5334.81982421875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "동종 · 주사형 2상 완료, 3상 진입", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "인프라 구축", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 31 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 31, + "styledTextSegments": [ + { + "characters": "동종 · 주사형 2상 완료, 3상 진입", + "end": 21, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3246;44:3558;44:3262": { + "id": "I44:3246;44:3558;44:3262", + "type": "TEXT", + "fields": { + "parentId": "I44:3246;44:3558", + "absoluteBoundingBox": { + "height": 21, + "width": 178, + "x": 24092, + "y": 4713 + }, + "absoluteRenderBounds": { + "height": 12.4462890625, + "width": 175.908203125, + "x": 24092.546875, + "y": 4717.6318359375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "동종 · 주사형 전임상 데이터 확보", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "동종 · 주사형 전임상 데이터 확보", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 50 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "WIDTH_AND_HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 178, + "x": 0, + "y": 50, + "styledTextSegments": [ + { + "characters": "동종 · 주사형 전임상 데이터 확보", + "end": 19, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2845;44:1987": { + "id": "I44:2845;44:1987", + "type": "TEXT", + "fields": { + "parentId": "I44:2845;44:1985", + "absoluteBoundingBox": { + "height": 29, + "width": 608, + "x": 22647, + "y": 5258 + }, + "absoluteRenderBounds": { + "height": 16.25390625, + "width": 79.01171875, + "x": 23174.2421875, + "y": 5264.31201171875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:951", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "상용화 준비", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 18, + "height": 29, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 160.0000023841858 + }, + "maxHeight": null, + "maxWidth": null, + "name": "기반 구축", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 46 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 46, + "styledTextSegments": [ + { + "characters": "상용화 준비", + "end": 6, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2843;44:1986": { + "id": "I44:2843;44:1986", + "type": "TEXT", + "fields": { + "parentId": "I44:2843;44:1985", + "absoluteBoundingBox": { + "height": 34, + "width": 608, + "x": 22647, + "y": 4740 + }, + "absoluteRenderBounds": { + "height": 18.16796875, + "width": 122.14453125, + "x": 23131.787109375, + "y": 4748.26416015625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:946", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2027–2028", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 24, + "height": 34, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "2025-2026", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "2027–2028", + "end": 9, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "44:2836": { + "id": "44:2836", + "type": "FRAME", + "fields": { + "parentId": "44:2812", + "childrenIds": [ + "44:2837" + ], + "absoluteBoundingBox": { + "height": 1681, + "width": 1440, + "x": 22567, + "y": 4242 + }, + "absoluteRenderBounds": { + "height": 1681, + "width": 1440, + "x": 22567, + "y": 4242 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + ], + "paddingBottom": { + "id": "VariableID:42:1085", + "type": "VARIABLE_ALIAS" + }, + "paddingLeft": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + }, + "paddingRight": { + "id": "VariableID:30:628", + "type": "VARIABLE_ALIAS" + }, + "paddingTop": { + "id": "VariableID:42:1085", + "type": "VARIABLE_ALIAS" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:349", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.9843137264251708, + "g": 0.9803921580314636, + "r": 0.9764705896377563 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 1681, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 120, + "paddingLeft": 40, + "paddingRight": 40, + "paddingTop": 120, + "primaryAxisAlignItems": "CENTER", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 10, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 482", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 120, + "paddingLeft": 40, + "paddingRight": 40, + "paddingTop": 120, + "primaryAxisAlignItems": "CENTER", + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 3123 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 0, + "y": 3123 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2847;44:1988;44:2871": { + "id": "I44:2847;44:1988;44:2871", + "type": "TEXT", + "fields": { + "parentId": "I44:2847;44:1988", + "absoluteBoundingBox": { + "height": 23, + "width": 608, + "x": 22647, + "y": 5709 + }, + "absoluteRenderBounds": { + "height": 13.3349609375, + "width": 202.916015625, + "x": 23051.73828125, + "y": 5713.81982421875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "주사형 세포치료제 3상 완료 및 시판", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "법인 설립 (2025. 08)", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "주사형 세포치료제 3상 완료 및 시판", + "end": 20, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2843;44:1988;44:2858": { + "id": "I44:2843;44:1988;44:2858", + "type": "TEXT", + "fields": { + "parentId": "I44:2843;44:1988", + "absoluteBoundingBox": { + "height": 23, + "width": 608, + "x": 22647, + "y": 4858 + }, + "absoluteRenderBounds": { + "height": 13.27490234375, + "width": 158.462890625, + "x": 23095.216796875, + "y": 4862.8798828125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "첨단재생의료 임상연구 승인", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "인프라 구축", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 31 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 31, + "styledTextSegments": [ + { + "characters": "첨단재생의료 임상연구 승인", + "end": 14, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3248;44:3558;44:3269": { + "id": "I44:3248;44:3558;44:3269", + "type": "TEXT", + "fields": { + "parentId": "I44:3248;44:3558", + "absoluteBoundingBox": { + "height": 21, + "width": 292, + "x": 24092, + "y": 5098 + }, + "absoluteRenderBounds": { + "height": 13.0341796875, + "width": 172.984375, + "x": 24092.546875, + "y": 5102.6318359375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "동종 · 주사형 2상 완료, 3상 진입", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "동종 · 주사형 2상 완료, 3상 진입", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 25 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 292, + "x": 0, + "y": 25, + "styledTextSegments": [ + { + "characters": "동종 · 주사형 2상 완료, 3상 진입", + "end": 21, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3250;44:3556": { + "id": "I44:3250;44:3556", + "type": "TEXT", + "fields": { + "parentId": "I44:3250;44:3555", + "absoluteBoundingBox": { + "height": 28, + "width": 292, + "x": 24092, + "y": 5363 + }, + "absoluteRenderBounds": { + "height": 14.97998046875, + "width": 46.134765625, + "x": 24092.919921875, + "y": 5369.2998046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:946", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2035", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 20, + "height": 28, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "2025-2026", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66,", + "textTruncation": "DISABLED", + "visible": true, + "width": 292, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "2035", + "end": 4, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2843;44:1987": { + "id": "I44:2843;44:1987", + "type": "TEXT", + "fields": { + "parentId": "I44:2843;44:1985", + "absoluteBoundingBox": { + "height": 29, + "width": 608, + "x": 22647, + "y": 4786 + }, + "absoluteRenderBounds": { + "height": 16.23583984375, + "width": 95.373046875, + "x": 23159.337890625, + "y": 4792.330078125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:951", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연구개발 강화", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 18, + "height": 29, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 160.0000023841858 + }, + "maxHeight": null, + "maxWidth": null, + "name": "기반 구축", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 46 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 46, + "styledTextSegments": [ + { + "characters": "연구개발 강화", + "end": 7, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2845;44:1986": { + "id": "I44:2845;44:1986", + "type": "TEXT", + "fields": { + "parentId": "I44:2845;44:1985", + "absoluteBoundingBox": { + "height": 34, + "width": 608, + "x": 22647, + "y": 5212 + }, + "absoluteRenderBounds": { + "height": 17.904296875, + "width": 119.416015625, + "x": 23134.529296875, + "y": 5220.35986328125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:946", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2031–2032", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 24, + "height": 34, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "2025-2026", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "2031–2032", + "end": 9, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3250;44:3558;44:3274": { + "id": "I44:3250;44:3558;44:3274", + "type": "TEXT", + "fields": { + "parentId": "I44:3250;44:3558", + "absoluteBoundingBox": { + "height": 21, + "width": 292, + "x": 24092, + "y": 5433 + }, + "absoluteRenderBounds": { + "height": 12.4462890625, + "width": 189.388671875, + "x": 24092.53125, + "y": 5437.6318359375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "주사형 세포치료제 3상 완료 및 시판", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "주사형 세포치료제 3상 완료 및 시판", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 292, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "주사형 세포치료제 3상 완료 및 시판", + "end": 20, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "44:2812": { + "id": "44:2812", + "type": "FRAME", + "fields": { + "parentId": "44:3783", + "parentType": "SECTION", + "parentName": "비전/미션", + "childrenIds": [ + "44:2836" + ], + "absoluteBoundingBox": { + "height": 4985, + "width": 1440, + "x": 22567, + "y": 1119 + }, + "absoluteRenderBounds": { + "height": 4985, + "width": 1440, + "x": 22567, + "y": 1119 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4985, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Desktop", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 111 + ], + [ + 0, + 1, + 81 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1440, + "x": 111, + "y": 81 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3246;44:3558": { + "id": "I44:3246;44:3558", + "type": "SLOT", + "fields": { + "parentId": "I44:3246;44:3555", + "childrenIds": [ + "I44:3246;44:3558;44:3260", + "I44:3246;44:3558;44:3261", + "I44:3246;44:3558;44:3262" + ], + "absoluteBoundingBox": { + "height": 71, + "width": 292, + "x": 24092, + "y": 4663 + }, + "absoluteRenderBounds": { + "height": 71, + "width": 292, + "x": 24092, + "y": 4663 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentPropertyReferences": { + "slotContentId": "Slot#44:4" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 71, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 4, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Slot", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 70 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 292, + "x": 0, + "y": 70 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:3242": { + "id": "44:3242", + "type": "FRAME", + "fields": { + "parentId": "44:3240", + "childrenIds": [ + "44:3246", + "44:3248", + "44:3250" + ], + "absoluteBoundingBox": { + "height": 1114, + "width": 320, + "x": 24064, + "y": 4389 + }, + "absoluteRenderBounds": { + "height": 1115, + "width": 320, + "x": 24064, + "y": 4388.5 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 1114, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 64, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 24, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 24, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 64, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 526", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 24, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 24, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 89 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 320, + "x": 0, + "y": 89 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3246;44:3556": { + "id": "I44:3246;44:3556", + "type": "TEXT", + "fields": { + "parentId": "I44:3246;44:3555", + "absoluteBoundingBox": { + "height": 28, + "width": 292, + "x": 24092, + "y": 4593 + }, + "absoluteRenderBounds": { + "height": 15.1396484375, + "width": 101.787109375, + "x": 24092.919921875, + "y": 4599.22021484375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:946", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "2027–2028", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:345", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.7323716878890991, + "g": 0.34177345037460327, + "r": 0 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 20, + "height": 28, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 139.9999976158142 + }, + "maxHeight": null, + "maxWidth": null, + "name": "2025-2026", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66,", + "textTruncation": "DISABLED", + "visible": true, + "width": 292, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "2027–2028", + "end": 9, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:695d59044b4e4f7eea6a65d6dd08296043af8a66," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3248;44:3557": { + "id": "I44:3248;44:3557", + "type": "TEXT", + "fields": { + "parentId": "I44:3248;44:3555", + "absoluteBoundingBox": { + "height": 26, + "width": 292, + "x": 24092, + "y": 5039 + }, + "absoluteRenderBounds": { + "height": 14.4482421875, + "width": 70.232421875, + "x": 24092.224609375, + "y": 5044.94384765625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:951", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "상용화 준비", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 16, + "height": 26, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 160.0000023841858 + }, + "maxHeight": null, + "maxWidth": null, + "name": "기반 구축", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 36 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06,", + "textTruncation": "DISABLED", + "visible": true, + "width": 292, + "x": 0, + "y": 36, + "styledTextSegments": [ + { + "characters": "상용화 준비", + "end": 6, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3250;44:3555": { + "id": "I44:3250;44:3555", + "type": "FRAME", + "fields": { + "parentId": "44:3250", + "childrenIds": [ + "I44:3250;44:3556", + "I44:3250;44:3557", + "I44:3250;44:3558" + ], + "absoluteBoundingBox": { + "height": 116, + "width": 292, + "x": 24092, + "y": 5363 + }, + "absoluteRenderBounds": { + "height": 116, + "width": 292, + "x": 24092, + "y": 5363 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 116, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 519", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 28 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 292, + "x": 28, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:3215": { + "id": "44:3215", + "type": "FRAME", + "fields": { + "parentId": "44:3783", + "parentType": "SECTION", + "parentName": "비전/미션", + "childrenIds": [ + "44:3239" + ], + "absoluteBoundingBox": { + "height": 4695, + "width": 360, + "x": 24044, + "y": 1119 + }, + "absoluteRenderBounds": { + "height": 4695, + "width": 360, + "x": 24044, + "y": 1119 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + ] + }, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:350", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 1, + "g": 1, + "r": 1 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 4695, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Mobile", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 1588 + ], + [ + 0, + 1, + 81 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 360, + "x": 1588, + "y": 81 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2845;44:2036": { + "id": "I44:2845;44:2036", + "type": "FRAME", + "fields": { + "parentId": "44:2845", + "absoluteBoundingBox": { + "height": 100, + "width": 608, + "x": 23319, + "y": 5212 + }, + "absoluteRenderBounds": { + "height": 100, + "width": 608, + "x": 23319, + "y": 5212 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 100, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 523", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 672 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 608, + "x": 672, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3250;44:3554": { + "id": "I44:3250;44:3554", + "type": "ELLIPSE", + "fields": { + "parentId": "I44:3250;44:3553", + "absoluteBoundingBox": { + "height": 12, + "width": 12, + "x": 24064, + "y": 5370 + }, + "absoluteRenderBounds": { + "height": 12, + "width": 12, + "x": 24064, + "y": 5370 + }, + "arcData": { + "endingAngle": 6.2831854820251465, + "innerRadius": 0, + "startingAngle": 0 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + ] + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:359", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.6682692170143127, + "g": 0.6104382276535034, + "r": 0.5815227627754211 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 12, + "isAsset": true, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Ellipse 2", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 7 + ] + ], + "strokeWeight": 1, + "targetAspectRatio": { + "x": 100, + "y": 100 + }, + "visible": true, + "width": 12, + "x": 0, + "y": 7 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:2847": { + "id": "44:2847", + "type": "INSTANCE", + "fields": { + "parentId": "44:2839", + "childrenIds": [ + "I44:2847;44:1985", + "I44:2847;44:1983", + "I44:2847;44:2036" + ], + "absoluteBoundingBox": { + "height": 141, + "width": 1280, + "x": 22647, + "y": 5622 + }, + "absoluteRenderBounds": { + "height": 141, + "width": 1280, + "x": 22647, + "y": 5622 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "Slot#44:4": { + "preferredValues": [], + "type": "SLOT" + }, + "align": { + "boundVariables": {}, + "type": "VARIANT", + "value": "right" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 141, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Rodamap", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "44:2847", + "overriddenFields": [ + "counterAxisSizingMode", + "height", + "width" + ] + }, + { + "id": "I44:2847;44:1984", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:2847;44:1986", + "overriddenFields": [ + "characters", + "fills", + "styledTextSegments" + ] + }, + { + "id": "I44:2847;44:1987", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 1127 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "align": "right" + }, + "visible": true, + "width": 1280, + "x": 0, + "y": 1127, + "mainComponentId": "44:1982", + "mainComponentName": "align=right", + "mainComponentSetId": "44:1981" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2847;44:1983": { + "id": "I44:2847;44:1983", + "type": "FRAME", + "fields": { + "parentId": "44:2847", + "childrenIds": [ + "I44:2847;44:1984" + ], + "absoluteBoundingBox": { + "height": 32, + "width": 16, + "x": 23279, + "y": 5622 + }, + "absoluteRenderBounds": { + "height": 32, + "width": 16, + "x": 23279, + "y": 5622 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 32, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 522", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 8, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 8, + "relativeTransform": [ + [ + 1, + 0, + 632 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 16, + "x": 632, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2845;44:1988": { + "id": "I44:2845;44:1988", + "type": "SLOT", + "fields": { + "parentId": "I44:2845;44:1985", + "childrenIds": [ + "I44:2845;44:1988;44:2865", + "I44:2845;44:1988;44:2866" + ], + "absoluteBoundingBox": { + "height": 54, + "width": 608, + "x": 22647, + "y": 5299 + }, + "absoluteRenderBounds": { + "height": 54, + "width": 608, + "x": 22647, + "y": 5299 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentPropertyReferences": { + "slotContentId": "Slot#44:4" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "MAX", + "counterAxisSpacing": 0, + "height": 54, + "inferredAutoLayout": { + "counterAxisAlignItems": "MAX", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Slot", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 87 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 608, + "x": 0, + "y": 87 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:2837": { + "id": "44:2837", + "type": "FRAME", + "fields": { + "parentId": "44:2836", + "childrenIds": [ + "44:2839" + ], + "absoluteBoundingBox": { + "height": 1441, + "width": 1280, + "x": 22647, + "y": 4362 + }, + "absoluteRenderBounds": { + "height": 1441.5, + "width": 1280, + "x": 22647, + "y": 4362 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 1441, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 48, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 48, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": 1280, + "name": "Frame 482", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 80 + ], + [ + 0, + 1, + 120 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 1280, + "x": 80, + "y": 120 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2847;44:1988": { + "id": "I44:2847;44:1988", + "type": "SLOT", + "fields": { + "parentId": "I44:2847;44:1985", + "childrenIds": [ + "I44:2847;44:1988;44:2871", + "I44:2847;44:1988;44:2872" + ], + "absoluteBoundingBox": { + "height": 54, + "width": 608, + "x": 22647, + "y": 5709 + }, + "absoluteRenderBounds": { + "height": 54, + "width": 608, + "x": 22647, + "y": 5709 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentPropertyReferences": { + "slotContentId": "Slot#44:4" + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "MAX", + "counterAxisSpacing": 0, + "height": 54, + "inferredAutoLayout": { + "counterAxisAlignItems": "MAX", + "counterAxisSizingMode": "FIXED", + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 8, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "VERTICAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Slot", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 87 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 608, + "x": 0, + "y": 87 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3246;44:3557": { + "id": "I44:3246;44:3557", + "type": "TEXT", + "fields": { + "parentId": "I44:3246;44:3555", + "absoluteBoundingBox": { + "height": 26, + "width": 292, + "x": 24092, + "y": 4629 + }, + "absoluteRenderBounds": { + "height": 14.43212890625, + "width": 84.77734375, + "x": 24092.640625, + "y": 4634.9599609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:951", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "연구개발 강화", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 16, + "height": 26, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 160.0000023841858 + }, + "maxHeight": null, + "maxWidth": null, + "name": "기반 구축", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 36 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06,", + "textTruncation": "DISABLED", + "visible": true, + "width": 292, + "x": 0, + "y": 36, + "styledTextSegments": [ + { + "characters": "연구개발 강화", + "end": 7, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "44:2843": { + "id": "44:2843", + "type": "INSTANCE", + "fields": { + "parentId": "44:2839", + "childrenIds": [ + "I44:2843;44:1985", + "I44:2843;44:1983", + "I44:2843;44:2036" + ], + "absoluteBoundingBox": { + "height": 172, + "width": 1280, + "x": 22647, + "y": 4740 + }, + "absoluteRenderBounds": { + "height": 172, + "width": 1280, + "x": 22647, + "y": 4740 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "Slot#44:4": { + "preferredValues": [], + "type": "SLOT" + }, + "align": { + "boundVariables": {}, + "type": "VARIANT", + "value": "right" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 172, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Rodamap", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "44:2843", + "overriddenFields": [ + "counterAxisSizingMode", + "height", + "width" + ] + }, + { + "id": "I44:2843;44:1986", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I44:2843;44:1987", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 245 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "align": "right" + }, + "visible": true, + "width": 1280, + "x": 0, + "y": 245, + "mainComponentId": "44:1982", + "mainComponentName": "align=right", + "mainComponentSetId": "44:1981" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2847;44:2036": { + "id": "I44:2847;44:2036", + "type": "FRAME", + "fields": { + "parentId": "44:2847", + "absoluteBoundingBox": { + "height": 100, + "width": 608, + "x": 23319, + "y": 5622 + }, + "absoluteRenderBounds": { + "height": 100, + "width": 608, + "x": 23319, + "y": 5622 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "clipsContent": true, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 100, + "itemSpacing": 0, + "layoutAlign": "INHERIT", + "layoutGrow": 1, + "layoutMode": "NONE", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 523", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 672 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": false, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 608, + "x": 672, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3250;44:3558;44:3275": { + "id": "I44:3250;44:3558;44:3275", + "type": "TEXT", + "fields": { + "parentId": "I44:3250;44:3558", + "absoluteBoundingBox": { + "height": 21, + "width": 292, + "x": 24092, + "y": 5458 + }, + "absoluteRenderBounds": { + "height": 12.404296875, + "width": 88.748046875, + "x": 24092.53125, + "y": 5462.673828125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "글로벌 시장 진출", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "글로벌 시장 진출", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 25 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 292, + "x": 0, + "y": 25, + "styledTextSegments": [ + { + "characters": "글로벌 시장 진출", + "end": 9, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "44:2845": { + "id": "44:2845", + "type": "INSTANCE", + "fields": { + "parentId": "44:2839", + "childrenIds": [ + "I44:2845;44:1985", + "I44:2845;44:1983", + "I44:2845;44:2036" + ], + "absoluteBoundingBox": { + "height": 141, + "width": 1280, + "x": 22647, + "y": 5212 + }, + "absoluteRenderBounds": { + "height": 141, + "width": 1280, + "x": 22647, + "y": 5212 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "Slot#44:4": { + "preferredValues": [], + "type": "SLOT" + }, + "align": { + "boundVariables": {}, + "type": "VARIANT", + "value": "right" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 141, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 24, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Rodamap", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "44:2845", + "overriddenFields": [ + "counterAxisSizingMode", + "height", + "width" + ] + }, + { + "id": "I44:2845;44:1984", + "overriddenFields": [ + "fills" + ] + }, + { + "id": "I44:2845;44:1986", + "overriddenFields": [ + "characters", + "fills", + "styledTextSegments" + ] + }, + { + "id": "I44:2845;44:1987", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 717 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "align": "right" + }, + "visible": true, + "width": 1280, + "x": 0, + "y": 717, + "mainComponentId": "44:1982", + "mainComponentName": "align=right", + "mainComponentSetId": "44:1981" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3246;44:3553": { + "id": "I44:3246;44:3553", + "type": "FRAME", + "fields": { + "parentId": "44:3246", + "childrenIds": [ + "I44:3246;44:3554" + ], + "absoluteBoundingBox": { + "height": 26, + "width": 12, + "x": 24064, + "y": 4593 + }, + "absoluteRenderBounds": { + "height": 26, + "width": 12, + "x": 24064, + "y": 4593 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSpacing": 0, + "height": 26, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 7, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 7, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "AUTO" + }, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 522", + "opacity": 1, + "overflowDirection": "NONE", + "paddingBottom": 7, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 7, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 12, + "x": 0, + "y": 0 + }, + "extra": {}, + "fieldErrors": {} + }, + "44:3246": { + "id": "44:3246", + "type": "INSTANCE", + "fields": { + "parentId": "44:3242", + "childrenIds": [ + "I44:3246;44:3553", + "I44:3246;44:3555" + ], + "absoluteBoundingBox": { + "height": 141, + "width": 320, + "x": 24064, + "y": 4593 + }, + "absoluteRenderBounds": { + "height": 141, + "width": 320, + "x": 24064, + "y": 4593 + }, + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "componentProperties": { + "Slot#44:4": { + "preferredValues": [], + "type": "SLOT" + }, + "align": { + "boundVariables": {}, + "type": "VARIANT", + "value": "Mobile" + } + }, + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "counterAxisAlignContent": "AUTO", + "counterAxisSpacing": 0, + "height": 141, + "inferredAutoLayout": { + "counterAxisAlignItems": "MIN", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "itemSpacing": 16, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutWrap": "NO_WRAP", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "Rodamap", + "opacity": 1, + "overflowDirection": "NONE", + "overrides": [ + { + "id": "44:3246", + "overriddenFields": [ + "counterAxisSizingMode", + "height", + "width" + ] + }, + { + "id": "I44:3246;44:3556", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + }, + { + "id": "I44:3246;44:3557", + "overriddenFields": [ + "characters", + "styledTextSegments" + ] + } + ], + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 204 + ] + ], + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokesIncludedInLayout": true, + "topLeftRadius": 0, + "topRightRadius": 0, + "variantProperties": { + "align": "Mobile" + }, + "visible": true, + "width": 320, + "x": 0, + "y": 204, + "mainComponentId": "44:3551", + "mainComponentName": "align=Mobile", + "mainComponentSetId": "44:1981" + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3248;44:3558;44:3268": { + "id": "I44:3248;44:3558;44:3268", + "type": "TEXT", + "fields": { + "parentId": "I44:3248;44:3558", + "absoluteBoundingBox": { + "height": 21, + "width": 292, + "x": 24092, + "y": 5073 + }, + "absoluteRenderBounds": { + "height": 12.4599609375, + "width": 138.46875, + "x": 24092.546875, + "y": 5077.6181640625 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "동물실험 대체법 판매 개시", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 14, + "height": 21, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "동물실험 대체법 판매 개시", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 292, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "동물실험 대체법 판매 개시", + "end": 14, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3250;44:3557": { + "id": "I44:3250;44:3557", + "type": "TEXT", + "fields": { + "parentId": "I44:3250;44:3555", + "absoluteBoundingBox": { + "height": 26, + "width": 292, + "x": 24092, + "y": 5399 + }, + "absoluteRenderBounds": { + "height": 14.3359375, + "width": 71.015625, + "x": 24092.48046875, + "y": 5404.9599609375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:951", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "글로벌 진출", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:358", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.1529411822557449, + "g": 0.08627451211214066, + "r": 0.06666667014360428 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "ExtraBold" + }, + "fontSize": 16, + "height": 26, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 160.0000023841858 + }, + "maxHeight": null, + "maxWidth": null, + "name": "기반 구축", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 36 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06,", + "textTruncation": "DISABLED", + "visible": true, + "width": 292, + "x": 0, + "y": 36, + "styledTextSegments": [ + { + "characters": "글로벌 진출", + "end": 6, + "fillStyleId": "", + "fontWeight": 800, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:7394fa3e4417d5710756b2f56d83e6105d173d06," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:2843;44:1988;44:2857": { + "id": "I44:2843;44:1988;44:2857", + "type": "TEXT", + "fields": { + "parentId": "I44:2843;44:1988", + "absoluteBoundingBox": { + "height": 23, + "width": 608, + "x": 22647, + "y": 4827 + }, + "absoluteRenderBounds": { + "height": 13.22998046875, + "width": 119.4921875, + "x": 23133.796875, + "y": 4831.9248046875 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:40:430", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "기업부설 연구소 설립", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:13:153", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.5160255432128906, + "g": 0.4530186951160431, + "r": 0.4333290457725525 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Medium" + }, + "fontSize": 15, + "height": 23, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "법인 설립 (2025. 08)", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "RIGHT", + "textAutoResize": "HEIGHT", + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6,", + "textTruncation": "DISABLED", + "visible": true, + "width": 608, + "x": 0, + "y": 0, + "styledTextSegments": [ + { + "characters": "기업부설 연구소 설립", + "end": 11, + "fillStyleId": "", + "fontWeight": 500, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:a2319c6931a342fd831baf32b252756b15de72a6," + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + }, + "captureScope": "Actual three right-aligned Vision timeline rows, corresponding mobile rows, descendants and bounded original ancestry. Other siblings excluded." +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/vision-wrapping-text.json b/crates/devup-mcp-devup-ui/tests/fixtures/vision-wrapping-text.json new file mode 100644 index 00000000..41f48993 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/vision-wrapping-text.json @@ -0,0 +1,248 @@ +{ + "fileKey": "K2kUnxGgvooAJwCQiVTb7g", + "version": null, + "roots": [ + "I44:3228;44:3621", + "I44:3238;44:3704" + ], + "nodes": { + "I44:3228;44:3621": { + "id": "I44:3228;44:3621", + "type": "TEXT", + "fields": { + "parentId": "I44:3228;44:3617", + "absoluteBoundingBox": { + "height": 46, + "width": 240, + "x": 24104, + "y": 2311 + }, + "absoluteRenderBounds": { + "height": 36.155029296875, + "width": 226.763671875, + "x": 24110.595703125, + "y": 2315.955078125 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:955", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "통증을 줄이는 데 그치지 않고, 관절 기능의 회복과 더 나은 일상을 목표로 합니다.", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 15, + "height": 46, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 150 + }, + "maxHeight": null, + "maxWidth": null, + "name": "통증 완화를 넘어, 손상된 관절의 기능 회복과 삶의 질 향상을 목표로 합니다.", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 73 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAutoResize": "HEIGHT", + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775,", + "textTruncation": "DISABLED", + "visible": true, + "width": 240, + "x": 0, + "y": 73, + "styledTextSegments": [ + { + "characters": "통증을 줄이는 데 그치지 않고, 관절 기능의 회복과 더 나은 일상을 목표로 합니다.", + "end": 46, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:e4c7b7dacbd4e73f6c967cfc38e87b24df7d8775," + } + ] + }, + "extra": {}, + "fieldErrors": {} + }, + "I44:3238;44:3704": { + "id": "I44:3238;44:3704", + "type": "TEXT", + "fields": { + "parentId": "I44:3238;44:3702", + "absoluteBoundingBox": { + "height": 52, + "width": 260, + "x": 24124, + "y": 4083 + }, + "absoluteRenderBounds": { + "height": 40.112060546875, + "width": 248.140625, + "x": 24124.607421875, + "y": 4089.072021484375 + }, + "boundVariables": { + "fills": [ + { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + ], + "fontSize": [ + { + "id": "VariableID:18:951", + "type": "VARIABLE_ALIAS" + } + ] + }, + "characters": "기술 경쟁력을 바탕으로 해외 시장에 진출해 글로벌 바이오 기업으로 성장합니다.", + "constraints": { + "horizontal": "MIN", + "vertical": "MIN" + }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": { + "color": { + "id": "VariableID:9:348", + "type": "VARIABLE_ALIAS" + } + }, + "color": { + "b": 0.3176470696926117, + "g": 0.250980406999588, + "r": 0.21568627655506137 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { + "family": "SUIT", + "style": "Regular" + }, + "fontSize": 16, + "height": 52, + "layoutAlign": "STRETCH", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", + "layoutSizingVertical": "HUG", + "letterSpacing": { + "unit": "PERCENT", + "value": -2 + }, + "lineHeight": { + "unit": "PERCENT", + "value": 160.0000023841858 + }, + "maxHeight": null, + "maxWidth": null, + "name": "콘드로스페로이드 접착고정 기술로 장기 생착과 연골재생 극대화", + "opacity": 1, + "relativeTransform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 40 + ] + ], + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAutoResize": "HEIGHT", + "textStyleId": "S:cfcbc71ecd9536404e6fe4dfeb971ce6261df4c3,", + "textTruncation": "DISABLED", + "visible": true, + "width": 260, + "x": 0, + "y": 40, + "styledTextSegments": [ + { + "characters": "기술 경쟁력을 바탕으로 해외 시장에 진출해 글로벌 바이오 기업으로 성장합니다.", + "end": 43, + "fillStyleId": "", + "fontWeight": 400, + "hyperlink": null, + "indentation": 0, + "listOptions": { + "type": "NONE" + }, + "start": 0, + "textStyleId": "S:cfcbc71ecd9536404e6fe4dfeb971ce6261df4c3," + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-devup-ui/tests/folded_asset_size.rs b/crates/devup-mcp-devup-ui/tests/folded_asset_size.rs index 018afc5b..752cea8e 100644 --- a/crates/devup-mcp-devup-ui/tests/folded_asset_size.rs +++ b/crates/devup-mcp-devup-ui/tests/folded_asset_size.rs @@ -63,12 +63,23 @@ fn fixed_non_square_frame_folded_into_mask_keeps_its_size() { ); } -fn wquw119() -> devup_mcp_figma::Snapshot { - serde_json::from_str(include_str!("../../../fixtures/wquw-119-snapshot.json")).unwrap() +fn explicitly_folded_wquw119() -> devup_mcp_figma::Snapshot { + let mut snapshot: devup_mcp_figma::Snapshot = + serde_json::from_str(include_str!("../../../fixtures/wquw-119-snapshot.json")).unwrap(); + // These tests exercise a folded paint boundary and its HUG-size evidence. + // The original FILL layout wrapper now stays live around its FIXED logo; + // select an explicit source asset boundary for the separate mask contract. + snapshot + .nodes + .get_mut("3997:46317") + .unwrap() + .fields + .insert("isAsset".into(), json!(true)); + snapshot } fn offset_hug_mask(horizontal: &str, vertical: &str) -> devup_mcp_figma::Snapshot { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); let node = snapshot.nodes.get_mut("3997:46317").unwrap(); for (field, value) in [ ("layoutSizingHorizontal", json!(horizontal)), @@ -221,8 +232,8 @@ fn offset_mask_restoration_does_not_claim_fluid_rotated_or_positioned_geometry() } #[test] -fn r5_wquw119_hug_mask_preserves_render_size_and_source_map() { - let snapshot = wquw119(); +fn r5_explicit_asset_boundary_hug_mask_preserves_render_size_and_source_map() { + let snapshot = explicitly_folded_wquw119(); let node = &snapshot.nodes["3997:46317"]; assert_eq!(node.fields["height"], 48); assert_eq!(node.fields["absoluteRenderBounds"]["width"], 320); @@ -280,7 +291,7 @@ fn r5_wquw119_hug_mask_preserves_render_size_and_source_map() { #[test] fn r5_hug_asset_missing_geometry_is_not_exact() { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); let node = snapshot.nodes.get_mut("3997:46317").unwrap(); node.fields.remove("height"); node.fields.remove("absoluteBoundingBox"); @@ -298,7 +309,7 @@ fn r5_hug_asset_missing_geometry_is_not_exact() { #[test] fn r5_asset_size_mapping_to_zero_is_not_evidence() { - let snapshot = wquw119(); + let snapshot = explicitly_folded_wquw119(); let mut output = generate_component(&snapshot, "3997:46315", &CodegenOptions::default()).unwrap(); // Preserve byte offsets to isolate value validation from range validity. @@ -315,7 +326,7 @@ fn r5_asset_size_mapping_to_zero_is_not_evidence() { #[test] fn r5_hug_asset_wrong_positive_height_is_not_exact() { - let snapshot = wquw119(); + let snapshot = explicitly_folded_wquw119(); let mut output = generate_component(&snapshot, "3997:46315", &CodegenOptions::default()).unwrap(); output.tsx = output.tsx.replace("320 / 48", "320 / 49"); @@ -331,7 +342,7 @@ fn r5_hug_asset_wrong_positive_height_is_not_exact() { #[test] fn r5_absolute_wide_hug_mask_does_not_collapse() { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); let node = snapshot.nodes.get_mut("3997:46317").unwrap(); node.fields .insert("layoutPositioning".into(), json!("ABSOLUTE")); @@ -349,7 +360,7 @@ fn r5_absolute_wide_hug_mask_does_not_collapse() { #[test] fn r5_empty_non_asset_hug_box_cannot_claim_visible_height() { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); let node = snapshot.nodes.get_mut("3997:46317").unwrap(); node.fields.insert("childrenIds".into(), json!([])); node.fields.insert( @@ -367,7 +378,7 @@ fn r5_empty_non_asset_hug_box_cannot_claim_visible_height() { #[test] fn r5_asset_url_identity_cannot_be_replaced_with_another_file() { - let snapshot = wquw119(); + let snapshot = explicitly_folded_wquw119(); let mut output = generate_component(&snapshot, "3997:46315", &CodegenOptions::default()).unwrap(); assert_eq!(output.fidelity_report.assets.covered, 1); @@ -383,7 +394,7 @@ fn r5_asset_url_identity_cannot_be_replaced_with_another_file() { #[test] fn r5_embedded_hug_asset_reports_host_size_without_pinning_root() { - let snapshot = wquw119(); + let snapshot = explicitly_folded_wquw119(); let output = generate_component( &snapshot, "3997:46317", @@ -404,7 +415,7 @@ fn r5_embedded_hug_asset_reports_host_size_without_pinning_root() { #[test] fn r5_asset_url_in_non_rendering_attribute_is_not_covered() { - let snapshot = wquw119(); + let snapshot = explicitly_folded_wquw119(); let mut output = generate_component(&snapshot, "3997:46315", &CodegenOptions::default()).unwrap(); output.tsx = output.tsx.replace("maskImage=", "data-icon="); @@ -419,7 +430,7 @@ fn r5_asset_url_in_non_rendering_attribute_is_not_covered() { #[test] fn r5_empty_hug_non_asset_cannot_use_percentage_or_wrong_size_as_proof() { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); let node = snapshot.nodes.get_mut("3997:46317").unwrap(); node.fields.insert("childrenIds".into(), json!([])); node.fields.insert( @@ -446,7 +457,7 @@ fn r5_empty_hug_non_asset_cannot_use_percentage_or_wrong_size_as_proof() { #[test] fn r5_ratio_without_fill_anchor_or_with_fixed_fill_width_is_not_exact() { - let snapshot = wquw119(); + let snapshot = explicitly_folded_wquw119(); let output = generate_component(&snapshot, "3997:46315", &CodegenOptions::default()).unwrap(); for replacement in ["x=\"100%\"", "w=\"32px\""] { let mut wrong = output.clone(); @@ -469,7 +480,7 @@ fn r5_ratio_without_fill_anchor_or_with_fixed_fill_width_is_not_exact() { #[test] fn r5_both_hug_mask_uses_measured_anchors_and_records_the_lost_children() { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); snapshot .nodes .get_mut("3997:46317") @@ -490,7 +501,7 @@ fn r5_both_hug_mask_uses_measured_anchors_and_records_the_lost_children() { #[test] fn r5_vertical_fill_requires_definite_parent_height() { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); let node = snapshot.nodes.get_mut("3997:46317").unwrap(); node.fields .insert("layoutSizingHorizontal".into(), json!("HUG")); @@ -518,7 +529,7 @@ fn r5_vertical_fill_requires_definite_parent_height() { #[test] fn r5_fixed_mask_never_receives_hug_repair_provenance() { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); snapshot .nodes .get_mut("3997:46317") @@ -538,7 +549,7 @@ fn r5_fixed_mask_never_receives_hug_repair_provenance() { #[test] fn r5_fill_ratio_cannot_bootstrap_a_hug_parent_width() { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); let parent = snapshot.nodes.get_mut("3997:46316").unwrap(); parent .fields @@ -554,7 +565,7 @@ fn r5_fill_ratio_cannot_bootstrap_a_hug_parent_width() { #[test] fn r5_mask_ratio_keeps_fractional_geometry_precision() { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); let node = snapshot.nodes.get_mut("3997:46317").unwrap(); node.fields.insert("width".into(), json!(320.123)); node.fields.insert("height".into(), json!(48.234)); @@ -574,7 +585,7 @@ fn r5_mask_ratio_keeps_fractional_geometry_precision() { #[test] fn r5_rotated_or_offset_mask_cannot_claim_responsive_repair() { for rotated in [true, false] { - let mut snapshot = wquw119(); + let mut snapshot = explicitly_folded_wquw119(); let node = snapshot.nodes.get_mut("3997:46317").unwrap(); if rotated { node.fields.insert("rotation".into(), json!(90)); diff --git a/crates/devup-mcp-devup-ui/tests/hard_break_whitespace.rs b/crates/devup-mcp-devup-ui/tests/hard_break_whitespace.rs index 6c1083ff..caf1ada2 100644 --- a/crates/devup-mcp-devup-ui/tests/hard_break_whitespace.rs +++ b/crates/devup-mcp-devup-ui/tests/hard_break_whitespace.rs @@ -101,11 +101,11 @@ fn segment_boundary_spaces_use_segment_provenance() { #[test] fn unrelated_whitespace_and_constrained_text_keep_existing_policy() { - for characters in [ - "Single trailing ", - " leading", - "Two spaces", - "First\nSecond", + for (characters, preserves_spaces) in [ + ("Single trailing ", true), + (" leading", true), + ("Two spaces", true), + ("First\nSecond", false), ] { let output = generate_component( &text(characters, "HEIGHT"), @@ -113,7 +113,10 @@ fn unrelated_whitespace_and_constrained_text_keep_existing_policy() { &CodegenOptions::default(), ) .unwrap(); - assert!(!output.tsx.contains("whiteSpace=\"pre-wrap\"")); + assert_eq!( + output.tsx.contains("whiteSpace=\"pre-wrap\""), + preserves_spaces + ); } for max_lines in [1, 2] { let mut snapshot = text("First \nSecond", "HEIGHT"); diff --git a/crates/devup-mcp-devup-ui/tests/image_representation.rs b/crates/devup-mcp-devup-ui/tests/image_representation.rs index 08288e4c..02b6d24c 100644 --- a/crates/devup-mcp-devup-ui/tests/image_representation.rs +++ b/crates/devup-mcp-devup-ui/tests/image_representation.rs @@ -55,6 +55,17 @@ fn close(actual: &Value, expected: f64) { ); } +fn assert_intrinsic_metadata_without_style_aliases( + output: &devup_mcp_devup_ui::codegen::CodegenOutput, +) { + assert!(!output.tsx.contains(" width="), "{}", output.tsx); + assert!(!output.tsx.contains(" height="), "{}", output.tsx); + assert_eq!( + output.source_map.asset_css_mappings[0]["intrinsicAttributes"], + json!({"width":800.0,"height":600.0}) + ); +} + #[test] fn original_source_has_one_css_effect_and_native_has_one_baked_effect() { let photo = node("CROP", json!([[0.5, 0, 0.25], [0, 0.5, 0.1]])); @@ -72,11 +83,7 @@ fn original_source_has_one_css_effect_and_native_has_one_baked_effect() { output.tsx ); assert!(output.tsx.contains("src=\"/images/full.webp\"")); - assert!( - output.tsx.contains("width=\"800\"") && output.tsx.contains("height=\"600\""), - "{}", - output.tsx - ); + assert_intrinsic_metadata_without_style_aliases(&output); assert!(output.tsx.contains("overflow=\"hidden\"")); assert!( output @@ -418,7 +425,7 @@ fn crop_hug_content_keeps_measured_display_size_instead_of_source_intrinsic_size "{}", output.tsx ); - assert!(output.tsx.contains("width=\"800\"") && output.tsx.contains("height=\"600\"")); + assert_intrinsic_metadata_without_style_aliases(&output); assert!(!output.tsx.contains("w=\"800px\"")); } @@ -636,5 +643,5 @@ fn affine_fill_width_follows_flex_parent_and_embedded_hug_keeps_host_control() { "{}", embedded.tsx ); - assert!(embedded.tsx.contains("width=\"800\"") && embedded.tsx.contains("height=\"600\"")); + assert_intrinsic_metadata_without_style_aliases(&embedded); } diff --git a/crates/devup-mcp-devup-ui/tests/image_transferred_minimum.rs b/crates/devup-mcp-devup-ui/tests/image_transferred_minimum.rs new file mode 100644 index 00000000..be6e5a90 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/image_transferred_minimum.rs @@ -0,0 +1,143 @@ +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, generate_component, responsive::merge_confirmed_breakpoints, +}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +fn snapshot() -> Snapshot { + serde_json::from_str(include_str!("fixtures/transferred-image-minimum.json")).unwrap() +} + +#[test] +fn captured_ratio_keeps_horizontal_transferred_minimum_and_verified_allocation() { + let snapshot = snapshot(); + let output = generate_component( + &snapshot, + "desktop", + &CodegenOptions { + width_policy: Some(Default::default()), + ..Default::default() + }, + ) + .unwrap(); + let map = output + .source_map + .asset_css_mappings + .iter() + .find(|m| m["nodeId"] == "image-desktop") + .unwrap(); + assert_eq!(map["css"]["wrapper"]["minW"], "auto"); + assert_eq!( + map["minimumSizingEvidence"]["capturedTransferredMinimum"], + 541. + ); + assert!(output.tsx.contains("minW=\"auto\""), "{}", output.tsx); + assert!( + output + .source_map + .entries + .iter() + .any(|entry| entry.node_id.as_deref() == Some("image-desktop") + && entry.property.as_deref() == Some("layoutSizingHorizontal") + && entry.resolution == "verified-layout-sizing"), + "{:?}", + output.diagnostics + ); +} + +#[test] +fn complete_responsive_module_resets_mobile_zero_to_desktop_auto_without_fixed_width() { + let snapshot = snapshot(); + let output = merge_confirmed_breakpoints( + &snapshot, + &CodegenOptions { + width_policy: Some(Default::default()), + ..Default::default() + }, + ) + .unwrap() + .unwrap(); + assert!(output.tsx.contains("minW={["), "{}", output.tsx); + assert!(output.tsx.contains("\"auto\""), "{}", output.tsx); + assert!(!output.tsx.contains("w=\"541px\""), "{}", output.tsx); + assert!(output.verified, "{:?}", output.gaps); +} + +#[test] +fn unknown_ratio_vertical_parent_or_unsized_cross_axis_keep_conservative_zero_minimum() { + for variation in ["ratio", "parent", "height", "minWidth", "mismatchedRatio"] { + let mut snapshot = snapshot(); + match variation { + "ratio" => { + snapshot + .nodes + .get_mut("image-desktop") + .unwrap() + .fields + .remove("targetAspectRatio"); + } + "parent" => { + snapshot + .nodes + .get_mut("body-desktop") + .unwrap() + .fields + .insert("layoutMode".into(), json!("VERTICAL")); + } + "height" => { + snapshot + .nodes + .get_mut("image-desktop") + .unwrap() + .fields + .insert("layoutSizingVertical".into(), json!("HUG")); + } + "minWidth" => { + snapshot + .nodes + .get_mut("image-desktop") + .unwrap() + .fields + .insert("minWidth".into(), json!(0)); + } + _ => { + snapshot + .nodes + .get_mut("image-desktop") + .unwrap() + .fields + .insert("targetAspectRatio".into(), json!({"x":2,"y":1})); + } + } + let output = generate_component( + &snapshot, + "desktop", + &CodegenOptions { + width_policy: Some(Default::default()), + ..Default::default() + }, + ) + .unwrap(); + let map = output + .source_map + .asset_css_mappings + .iter() + .find(|m| m["nodeId"] == "image-desktop") + .unwrap(); + assert_eq!(map["css"]["wrapper"]["minW"], "0", "{variation}"); + assert!(map["minimumSizingEvidence"].is_null(), "{variation}"); + } + let mobile = generate_component( + &snapshot(), + "mobile", + &CodegenOptions { + width_policy: Some(Default::default()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + mobile.source_map.asset_css_mappings[0]["css"]["wrapper"]["minW"], + "0" + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/instance_overrides.rs b/crates/devup-mcp-devup-ui/tests/instance_overrides.rs index 7cc02192..89f92512 100644 --- a/crates/devup-mcp-devup-ui/tests/instance_overrides.rs +++ b/crates/devup-mcp-devup-ui/tests/instance_overrides.rs @@ -52,7 +52,7 @@ fn chondrogen_primary_button_fill_override_requires_preservation() { .contains(&json!("bg=\"$secondary\"")) ); let output = generate_component(&snapshot, "51:5705", &options).unwrap(); - assert!(output.tsx.contains("// @devup-override ")); + assert!(!output.tsx.contains("// @devup-")); assert!( output .diagnostics diff --git a/crates/devup-mcp-devup-ui/tests/integer_line_advance.rs b/crates/devup-mcp-devup-ui/tests/integer_line_advance.rs index 7d3b5c60..d8f9f094 100644 --- a/crates/devup-mcp-devup-ui/tests/integer_line_advance.rs +++ b/crates/devup-mcp-devup-ui/tests/integer_line_advance.rs @@ -92,7 +92,7 @@ fn variable_or_missing_font_size_keeps_the_percentage_in_the_theme() { /// A size bound to a variable whose modes are breakpoints is one size per /// breakpoint: Mobile at the first slot and Tablet at the third (768px), each -/// with the percentage. Modes that are not breakpoints keep the token. +/// with its integer line advance. Modes that are not breakpoints keep the token. #[test] fn font_size_whose_modes_are_breakpoints_is_one_size_per_breakpoint() { let theme = |mobile: &str, tablet: &str| { @@ -115,9 +115,9 @@ fn font_size_whose_modes_are_breakpoints_is_one_size_per_breakpoint() { assert_eq!( theme("Mobile", "Tablet"), json!([ - {"fontFamily": "Inter", "fontWeight": 400, "fontSize": "24px", "lineHeight": "150%"}, + {"fontFamily": "Inter", "fontWeight": 400, "fontSize": "24px", "lineHeight": "36px"}, null, - {"fontFamily": "Inter", "fontWeight": 400, "fontSize": "16px", "lineHeight": "150%"} + {"fontFamily": "Inter", "fontWeight": 400, "fontSize": "16px", "lineHeight": "24px"} ]) ); assert_eq!( @@ -144,6 +144,68 @@ fn percentage_style_uses_its_own_integer_advance() { } } +#[test] +fn breakpoint_font_sizes_materialize_each_integer_advance_and_preserve_unresolved_sizes() { + for (mobile, desktop, advances) in [ + (json!(15), json!(16), Some(("23px", "24px"))), + (json!(13), json!(14), Some(("20px", "21px"))), + (json!("calc(15px + 1px)"), json!(16), None), + (json!("var(--font-size)"), json!(16), None), + ] { + let snapshot: VariableSnapshot = serde_json::from_value(json!({ + "collections":[{"id":"c","name":"Size","defaultModeId":"m","modes":[ + {"modeId":"m","name":"Mobile"},{"modeId":"d","name":"Desktop"}]}], + "variables":[{"id":"size","name":"size","resolvedType":"FLOAT", + "variableCollectionId":"c","valuesByMode":{"m":mobile,"d":desktop}}], + "styles":[{"id":"s","name":"body","styleType":"TEXT","value":{ + "fontSize":16,"lineHeight":{"unit":"PERCENT","value":150}, + "boundVariables":{"fontSize":{"type":"VARIABLE_ALIAS","id":"size"}} + }}] + })) + .unwrap(); + let output = generate_devup_json(&snapshot, ThemeScope::File).unwrap(); + let theme: Value = serde_json::from_str(&output.json).unwrap(); + let body = &theme["theme"]["typography"]["body"]; + if let Some((mobile, desktop)) = advances { + assert_eq!(body[0]["lineHeight"], mobile); + assert_eq!(body[4]["lineHeight"], desktop); + assert!(body[1].is_null() && body[2].is_null() && body[3].is_null()); + } else { + assert_eq!(body["fontSize"], "$size"); + assert_eq!(body["lineHeight"], "150%"); + } + } +} + +#[test] +fn breakpoint_size_does_not_override_a_bound_line_height() { + let snapshot: VariableSnapshot = serde_json::from_value(json!({ + "collections":[{"id":"c","name":"Size","defaultModeId":"m","modes":[ + {"modeId":"m","name":"Mobile"},{"modeId":"d","name":"Desktop"}]}], + "variables":[ + {"id":"size","name":"size","resolvedType":"FLOAT","variableCollectionId":"c", + "valuesByMode":{"m":15,"d":16}}, + {"id":"height","name":"height","resolvedType":"FLOAT","variableCollectionId":"c", + "valuesByMode":{"m":30,"d":32}} + ], + "styles":[{"id":"s","name":"body","styleType":"TEXT","value":{ + "fontSize":16,"lineHeight":{"unit":"PERCENT","value":150}, + "boundVariables":{"fontSize":{"id":"size"},"lineHeight":{"id":"height"}} + }}] + })) + .unwrap(); + let output = generate_devup_json(&snapshot, ThemeScope::File).unwrap(); + let theme: Value = serde_json::from_str(&output.json).unwrap(); + assert_eq!( + theme["theme"]["typography"]["body"][0]["lineHeight"], + "$height" + ); + assert_eq!( + theme["theme"]["typography"]["body"][4]["lineHeight"], + "$height" + ); +} + #[test] fn inline_text_uses_integer_advance() { let snapshot: Snapshot = serde_json::from_value(json!({ @@ -159,7 +221,7 @@ fn inline_text_uses_integer_advance() { } #[test] -fn variable_font_size_keeps_the_percentage_advance_instead_of_a_pixel_one() { +fn variable_font_size_keeps_relative_integer_advance_instead_of_frozen_pixels() { let snapshot: Snapshot = serde_json::from_value(json!({ "fileKey":"test", "version":"1", "roots":["t"], "diagnostics":[], "nodes":{"t":{"id":"t","type":"TEXT","fields":{ @@ -170,7 +232,13 @@ fn variable_font_size_keeps_the_percentage_advance_instead_of_a_pixel_one() { })) .unwrap(); let output = generate_component(&snapshot, "t", &CodegenOptions::default()).unwrap(); - assert!(output.tsx.contains("lineHeight=\"150%\""), "{}", output.tsx); + assert!( + output + .tsx + .contains("lineHeight=\"round(nearest, 1.5em, 1px)\""), + "{}", + output.tsx + ); assert!( !output.tsx.contains("lineHeight=\"27px\""), "{}", diff --git a/crates/devup-mcp-devup-ui/tests/intrinsic_text_width.rs b/crates/devup-mcp-devup-ui/tests/intrinsic_text_width.rs new file mode 100644 index 00000000..48221b1a --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/intrinsic_text_width.rs @@ -0,0 +1,53 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::{Value, json}; + +fn project(resize: &str, max_lines: Value) -> devup_mcp_devup_ui::codegen::CodegenOutput { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"intrinsic-width", "roots":["frame"], "diagnostics":[], + "nodes":{ + "frame":{"id":"frame","type":"FRAME","fields":{ + "width":360,"height":200,"layoutMode":"HORIZONTAL","childrenIds":["text"]}}, + "text":{"id":"text","type":"TEXT","fields":{ + "parentId":"frame","characters":"Regenerating Joints ✦ Restoring Lives\nChondroSpheroid", + "width":2400,"height":120,"textAutoResize":resize,"textTruncation":"DISABLED", + "fontSize":80,"maxLines":max_lines}} + } + })).unwrap(); + generate_component(&snapshot, "frame", &CodegenOptions::default()).unwrap() +} + +#[test] +fn auto_width_text_preserves_unwrapped_width_spaces_and_hard_breaks() { + let output = project("WIDTH_AND_HEIGHT", Value::Null); + assert!(output.tsx.contains("whiteSpace=\"pre\""), "{}", output.tsx); + assert!(output.tsx.contains("
    "), "{}", output.tsx); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE") + ); + assert!( + output + .source_map + .entries + .iter() + .any(|e| e.resolution == "derived-intrinsic-text-width" + && e.property.as_deref() == Some("textAutoResize") + && e.generated_property.as_deref() == Some("whiteSpace=\"pre\"")) + ); +} + +#[test] +fn fixed_width_text_retains_soft_wrapping_policy() { + let output = project("HEIGHT", Value::Null); + assert!(!output.tsx.contains("whiteSpace=\"pre\"")); +} + +#[test] +fn explicit_line_clamp_takes_precedence_over_auto_width() { + let output = project("WIDTH_AND_HEIGHT", json!(1)); + assert!(output.tsx.contains("whiteSpace=\"nowrap\"")); + assert!(!output.tsx.contains("whiteSpace=\"pre\"")); +} diff --git a/crates/devup-mcp-devup-ui/tests/jsx_text_fidelity.rs b/crates/devup-mcp-devup-ui/tests/jsx_text_fidelity.rs index ad2c0454..834d4e4d 100644 --- a/crates/devup-mcp-devup-ui/tests/jsx_text_fidelity.rs +++ b/crates/devup-mcp-devup-ui/tests/jsx_text_fidelity.rs @@ -131,14 +131,15 @@ fn assert_round_trip(snapshot: &Snapshot, id: &str) -> String { output.tsx } -// Design line separators each paint one break; CRLF is one separator. No +// LF/Unicode separators paint one break; CRLF is one separator and lone CR is an inline space. No // spaces, tabs, NBSP or Unicode spaces are trimmed or collapsed by this oracle. // This checks JSX child text plus explicit breaks, before CSS normal/nowrap // collapsing. Those CSS modes both collapse spaces; that residue is diagnosed. fn design_breaks(value: &str) -> String { value .replace("\r\n", "\n") - .replace(['\r', '\u{2028}', '\u{2029}'], "\n") + .replace('\r', " ") + .replace(['\u{2028}', '\u{2029}'], "\n") } #[test] @@ -215,7 +216,7 @@ fn jsx_already_faithful_text_keeps_its_existing_spelling() { for (characters, emitted) in [ ("plain text", "plain text"), ("中 文", "中 文"), - (" a b ", "{\" \"}a b{\" \"}"), + (" a b ", "{\" \"}a{\" \"}b{\" \"}"), ("a\nb", "a
    b"), ("<&>", "{\"<&>\"}"), ] { @@ -361,6 +362,7 @@ fn jsx_existing_fixture_texts_round_trip() { let mut count = 0; let mut css_collapsing = 0; let mut css_preserved = 0; + let mut intrinsic_preserved = 0; let mut failures = Vec::new(); for path in json_files(&root.join("../../fixtures/devup-figma-plugin/cases")) .into_iter() @@ -406,6 +408,28 @@ fn jsx_existing_fixture_texts_round_trip() { { css_collapsing += 1; } + if output.tsx.contains("whiteSpace=\"pre\"") { + assert_eq!(view.string("textAutoResize"), Some("WIDTH_AND_HEIGHT")); + if expected + .split(['\r', '\n', '\u{2028}', '\u{2029}']) + .any(|line| { + line.starts_with(' ') + || line.ends_with(' ') + || line.contains(" ") + || line.contains('\t') + }) + { + intrinsic_preserved += 1; + } + assert!( + output + .source_map + .entries + .iter() + .any(|entry| entry.resolution == "derived-intrinsic-text-width" + && entry.generated_property.as_deref() == Some("whiteSpace=\"pre\"")) + ); + } if output.tsx.contains("whiteSpace=\"pre-wrap\"") { assert_eq!(view.string("textAutoResize"), Some("HEIGHT")); assert!( @@ -436,11 +460,15 @@ fn jsx_existing_fixture_texts_round_trip() { } assert!(count > 400, "representative sweep shrank: {count}"); assert_eq!( - css_collapsing + css_preserved, + css_collapsing + css_preserved + intrinsic_preserved, 35, "original whitespace population" ); - assert_eq!(css_collapsing, 5, "remaining CSS-collapse population"); + assert_eq!(css_collapsing, 3, "remaining CSS-collapse population"); + assert_eq!( + intrinsic_preserved, 2, + "intrinsic-width text now preserves its spaces" + ); assert_eq!( css_preserved, 30, "fixed-inline-width text now preserves its spaces" @@ -455,3 +483,15 @@ fn jsx_existing_fixture_texts_round_trip() { "JSX fixture sweep: {count} text nodes round-tripped; {css_collapsing} CSS-collapse diagnostics" ); } + +#[test] +fn crlf_split_between_styled_runs_is_one_break_without_an_inserted_space() { + let source = snapshot( + "First\r\nSecond", + Some(json!([ + {"characters":"First\r", "fontWeight":400}, + {"characters":"\nSecond", "fontWeight":700} + ])), + ); + assert_round_trip(&source, "text"); +} diff --git a/crates/devup-mcp-devup-ui/tests/live_asset_layout.rs b/crates/devup-mcp-devup-ui/tests/live_asset_layout.rs new file mode 100644 index 00000000..013ceaf5 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/live_asset_layout.rs @@ -0,0 +1,107 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{Snapshot, discover_asset_manifest}; +use serde_json::json; + +fn captured() -> Snapshot { + serde_json::from_str(include_str!( + "fixtures/chondrogen_mobile_header_single_logo.json" + )) + .unwrap() +} + +#[test] +fn captured_fill_header_row_keeps_fixed_logo_as_its_own_exported_asset() { + let snapshot = captured(); + let wrapper = "I51:6610;44:5194"; + let logo = "I51:6610;44:5195"; + let manifest = discover_asset_manifest(&snapshot); + assert!( + !manifest.assets.iter().any(|a| a.node_id == wrapper), + "{:?}", + manifest.assets + ); + assert!( + manifest.assets.iter().any(|a| a.node_id == logo), + "{:?}", + manifest.assets + ); + let output = generate_component( + &snapshot, + wrapper, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap(); + assert!(output.tsx.contains(" Snapshot { + serde_json::from_str(include_str!("fixtures/home-news-mixed-strokes.json")).unwrap() +} +fn generate(s: &Snapshot) -> CodegenOutput { + generate_component( + s, + "desktop", + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() +} +fn opening(output: &CodegenOutput, id: &str) -> String { + let entry = output + .source_map + .entries + .iter() + .find(|e| e.node_id.as_deref() == Some(id) && e.property.is_none()) + .unwrap(); + let range = entry.generated_range.as_ref().unwrap(); + output.tsx[range.start..range.end] + .split('>') + .next() + .unwrap() + .into() +} +#[test] +fn actual_home_bottom_edge_is_absorbed_by_captured_padding() { + let s = fixture(); + assert_eq!(s.nodes["51:5838"].fields["strokesIncludedInLayout"], false); + let output = generate(&s); + let row = opening(&output, "51:5838"); + assert!(row.contains("pt=\"24px\""), "{row}"); + assert!(row.contains("pb=\"23px\""), "{row}"); + assert!(row.contains("borderBottom="), "{row}"); + assert!(!row.contains("h="), "source remains HUG"); + assert!( + output + .source_map + .entries + .iter() + .any(|e| e.node_id.as_deref() == Some("51:5838") + && e.property.as_deref() == Some("paddingBottom") + && e.generated_property.as_deref() == Some("pb=\"23px\"")), + "{:?}", + output.source_map.entries + ); +} +#[test] +fn true_missing_unknown_error_and_insufficient_cases_are_not_compensated() { + for case in [ + "true", + "missing", + "align", + "align-error", + "weight-error", + "weight-missing", + "insufficient", + "dash", + "gradient", + ] { + let mut s = fixture(); + let node = s.nodes.get_mut("51:5838").unwrap(); + match case { + "true" => { + node.fields + .insert("strokesIncludedInLayout".into(), json!(true)); + } + "missing" => { + node.fields.remove("strokesIncludedInLayout"); + } + "align" => { + node.fields + .insert("strokeAlign".into(), json!({"$unsupported":"symbol"})); + } + "align-error" => { + node.field_errors + .insert("strokeAlign".into(), "read failed".into()); + } + "weight-error" => { + node.field_errors + .insert("strokeBottomWeight".into(), "read failed".into()); + } + "weight-missing" => { + node.fields.remove("strokeTopWeight"); + } + "insufficient" => { + node.fields.insert("paddingBottom".into(), json!(0)); + node.fields.get_mut("inferredAutoLayout").unwrap()["paddingBottom"] = json!(0); + } + "dash" => { + node.fields.insert("dashPattern".into(), json!([2, 2])); + } + _ => { + node.fields.insert( + "strokes".into(), + json!([{"type":"GRADIENT_LINEAR","visible":true}]), + ); + } + } + let output = generate(&s); + let row = opening(&output, "51:5838"); + assert!(!row.contains("pb=\"23px\""), "{case}:{row}"); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.node_id.as_deref() == Some("51:5838") + && d.details.as_ref().is_some_and(|v| v["calculation"] + .as_str() + .is_some_and(|x| x.starts_with("Captured inside per-edge")))), + "{case}" + ); + } +} +#[test] +fn existing_effect_shadow_and_border_paint_are_preserved() { + let mut s = fixture(); + s.nodes.get_mut("51:5838").unwrap().fields.insert("effects".into(),json!([{"type":"DROP_SHADOW","visible":true,"radius":4,"spread":0,"offset":{"x":0,"y":2},"color":{"r":0,"g":0,"b":0,"a":0.5}}])); + let output = generate(&s); + let row = opening(&output, "51:5838"); + assert!(row.contains("boxShadow="), "{row}"); + assert!(row.contains("borderBottom="), "{row}"); + assert!(row.contains("pb=\"23px\""), "{row}"); +} diff --git a/crates/devup-mcp-devup-ui/tests/overlay_anchors.rs b/crates/devup-mcp-devup-ui/tests/overlay_anchors.rs new file mode 100644 index 00000000..10248736 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/overlay_anchors.rs @@ -0,0 +1,216 @@ +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, generate_component, overlay_anchor_metadata, + responsive::merge_confirmed_breakpoints, +}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +fn options() -> CodegenOptions { + CodegenOptions { + inline_instances: true, + ..Default::default() + } +} + +fn source() -> Snapshot { + serde_json::from_value(json!({"fileKey":"dropdown-anchor","roots":["root"],"diagnostics":[],"nodes":{ + "root":{"id":"root","type":"FRAME","fields":{"name":"Screen","width":360,"height":500,"layoutMode":"VERTICAL","childrenIds":["control","overlay"],"absoluteBoundingBox":{"x":20,"y":30,"width":360,"height":500}}}, + "control":{"id":"control","type":"INSTANCE","fields":{"name":"input","mainComponentId":"input-master","parentId":"root","layoutMode":"HORIZONTAL","width":320,"height":51,"layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED","childrenIds":["caret"],"absoluteBoundingBox":{"x":40,"y":100,"width":320,"height":51}}}, + "caret":{"id":"caret","type":"INSTANCE","fields":{"name":"Caret","mainComponentId":"caret-master","mainComponentName":"type=CaretDown","parentId":"control","width":12,"height":12}}, + "overlay":{"id":"overlay","type":"FRAME","fields":{"name":"Frame 547","parentId":"root","layoutMode":"VERTICAL","layoutPositioning":"ABSOLUTE","width":320,"height":94,"childrenIds":["option"],"absoluteBoundingBox":{"x":40,"y":159,"width":320,"height":94}}}, + "option":{"id":"option","type":"INSTANCE","fields":{"name":"DropdownOption","mainComponentId":"option-master","parentId":"overlay","width":318,"height":47}} + }})).unwrap() +} + +#[test] +fn unique_captured_dropdown_relation_emits_fallback_anchors_and_source_metadata() { + let snapshot = source(); + let output = generate_component(&snapshot, "root", &options()).unwrap(); + for expected in [ + "anchorName=", + "anchorScope=\"all\"", + "positionAnchor=", + "top=\"calc(anchor(bottom, 121px) + 8px)\"", + "left=\"anchor(left, 20px)\"", + "w=\"anchor-size(width, 320px)\"", + ] { + assert!( + output.tsx.contains(expected), + "missing {expected}: {}", + output.tsx + ); + } + assert!(!output.tsx.contains("// @devup-")); + if let Ok(path) = std::env::var("DEVUP_ANCHOR_PROBE_OUTPUT") { + std::fs::write(path, &output.tsx).unwrap(); + } + let mut without_relation = snapshot.clone(); + without_relation + .nodes + .get_mut("option") + .unwrap() + .fields + .insert("name".into(), json!("Option")); + let baseline = generate_component(&without_relation, "root", &options()).unwrap(); + let tags = |tsx: &str| { + devup_mcp_devup_ui::provenance::attributes::opening_elements(tsx) + .into_iter() + .map(|element| { + tsx[element.start + 1..element.end] + .split(|c: char| !c.is_alphanumeric()) + .next() + .unwrap_or_default() + .to_owned() + }) + .collect::>() + }; + assert_eq!( + tags(&output.tsx), + tags(&baseline.tsx), + "Anchoring must not add or reparent DOM elements" + ); + let records = overlay_anchor_metadata(&snapshot, &snapshot.roots, &output.tsx); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["controlNodeId"], "control"); + assert_eq!(records[0]["gap"].as_f64(), Some(8.0)); + assert_eq!(records[0]["capturedFallback"]["top"].as_f64(), Some(129.0)); + assert_eq!(records[0]["projected"], true); + assert!( + records[0]["cssIdentifier"] + .as_str() + .unwrap() + .starts_with("\\2d \\2d ") + ); + let theme_variable_spelling = output.tsx.replace("\\2d \\2d ", "--"); + assert!( + overlay_anchor_metadata(&snapshot, &snapshot.roots, &theme_variable_spelling).is_empty() + ); + assert_eq!(records[0]["runtimeVerified"], false); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED"), + "{:?}", + output.diagnostics + ); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_OVERLAY_ANCHOR_RUNTIME_UNVERIFIED") + ); + let fake = format!("// @devup-overlay-anchor {}", records[0]); + assert!(overlay_anchor_metadata(&snapshot, &snapshot.roots, &fake).is_empty()); +} + +#[test] +fn ambiguous_controls_and_transformed_geometry_keep_original_absolute_placement() { + for ambiguous in [true, false] { + let mut snapshot = source(); + if ambiguous { + let mut other = snapshot.nodes["control"].clone(); + other.id = "other-control".into(); + other.fields["absoluteBoundingBox"]["y"] = json!(99); + snapshot.nodes.insert(other.id.clone(), other); + snapshot.nodes.get_mut("root").unwrap().fields["childrenIds"] = + json!(["control", "other-control", "overlay"]); + } else { + snapshot + .nodes + .get_mut("overlay") + .unwrap() + .fields + .insert("rotation".into(), json!(15)); + } + let output = generate_component(&snapshot, "root", &options()).unwrap(); + assert!(!output.tsx.contains("positionAnchor="), "{}", output.tsx); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_OVERLAY_ANCHOR_UNRESOLVED") + ); + } +} + +#[test] +fn source_name_or_geometry_alone_cannot_invent_a_dropdown_control() { + let mut snapshot = source(); + snapshot + .nodes + .get_mut("caret") + .unwrap() + .fields + .insert("visible".into(), json!(false)); + let output = generate_component(&snapshot, "root", &options()).unwrap(); + assert!(!output.tsx.contains("positionAnchor=")); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_OVERLAY_ANCHOR_UNRESOLVED") + ); +} + +#[test] +fn captured_contact_controls_keep_zero_gap_and_responsive_aliases() { + // Read-only selected-node extraction, 2026-11-02: control, caret, + // DropdownOption subtrees and their ancestors; unrelated siblings omitted. + let snapshot: Snapshot = serde_json::from_str(include_str!( + "fixtures/chondrogen_dropdown_anchor_nodes.json" + )) + .unwrap(); + for (root, control, overlay, top, width) in [ + ("44:5652", "I44:5664;44:5509", "45:2039", 414, 540), + ("44:6155", "I44:6167;44:5509", "45:2050", 489, 320), + ] { + let output = generate_component(&snapshot, root, &options()).unwrap(); + let records = overlay_anchor_metadata(&snapshot, &[root.into()], &output.tsx); + assert_eq!(records.len(), 1, "{} {:?}", output.tsx, output.diagnostics); + assert_eq!(records[0]["controlNodeId"], control); + assert_eq!(records[0]["overlayNodeId"], overlay); + assert_eq!(records[0]["gap"].as_f64(), Some(0.0)); + assert_eq!( + records[0]["capturedFallback"]["top"].as_f64(), + Some(f64::from(top)) + ); + assert_eq!( + records[0]["capturedFallback"]["width"].as_f64(), + Some(f64::from(width)) + ); + } + let merged = merge_confirmed_breakpoints(&snapshot, &options()) + .unwrap() + .unwrap(); + let metadata = overlay_anchor_metadata(&snapshot, &snapshot.roots, &merged.tsx); + assert_eq!(metadata.len(), 2, "{}", merged.tsx); + assert!( + metadata + .iter() + .all(|r| r["gap"].as_f64() == Some(0.0) && r["runtimeVerified"] == false) + ); + assert!(merged.tsx.contains("anchorName="), "{}", merged.tsx); + assert!(merged.tsx.contains("positionAnchor="), "{}", merged.tsx); +} + +#[test] +fn shared_component_references_keep_numeric_offsets_and_explicit_recovery() { + let snapshot = source(); + let output = generate_component( + &snapshot, + "root", + &CodegenOptions { + inline_instances: false, + ..Default::default() + }, + ) + .unwrap(); + assert!(!output.tsx.contains("positionAnchor="), "{}", output.tsx); + assert!(output.diagnostics.iter().any(|d| { + d.code == "DEVUP_CODEGEN_OVERLAY_ANCHOR_UNRESOLVED" + && d.details + .as_ref() + .is_some_and(|v| v["classification"] == "native-control-not-expanded") + })); +} diff --git a/crates/devup-mcp-devup-ui/tests/provenance.rs b/crates/devup-mcp-devup-ui/tests/provenance.rs index c33effb7..f6f84ee2 100644 --- a/crates/devup-mcp-devup-ui/tests/provenance.rs +++ b/crates/devup-mcp-devup-ui/tests/provenance.rs @@ -512,7 +512,14 @@ fn inline_instance_provenance_offsets_reference_sorted_and_prefixed_final_tsx() }) .expect("final inline component type provenance"); assert_eq!(mapped_property(&output.tsx, component_type), "Box"); - assert!(output.tsx.starts_with("{/* */}")); + assert!(!output.tsx.contains("{/*")); + assert!(output.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "DEVUP_CODEGEN_COMPONENT_USAGE" + && diagnostic + .details + .as_ref() + .is_some_and(|details| details["componentUsage"] == "") + })); assert!(!output.projection_trace.entries.is_empty()); assert!(output.fidelity_report.nodes.total > 0); } @@ -658,7 +665,7 @@ fn non_default_variant_missing_child_is_lossy_not_falsely_covered() { assert!(output.tsx.contains("Default only") || output.tsx.contains("w=\"100px\"")); assert!(output.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "DEVUP_CODEGEN_VARIANT_CHILD_FALLBACK" + diagnostic.code == "DEVUP_CODEGEN_EFFECT_TOPOLOGY_UNSUPPORTED" && diagnostic.node_id.as_deref() == Some("hover") && diagnostic.fidelity_impact() == devup_mcp_figma::FidelityImpact::Lossy })); diff --git a/crates/devup-mcp-devup-ui/tests/r8_absolute_scroll.rs b/crates/devup-mcp-devup-ui/tests/r8_absolute_scroll.rs index 5a56bf0d..ad5f36a6 100644 --- a/crates/devup-mcp-devup-ui/tests/r8_absolute_scroll.rs +++ b/crates/devup-mcp-devup-ui/tests/r8_absolute_scroll.rs @@ -155,14 +155,6 @@ fn r8_all_four_sizing_resolutions_have_semantic_mapping() { "accounted-for-implicit-flex-grow", "flex=\"1\"", ), - ( - include_str!("../../../fixtures/r2/wquw-119-payload.json"), - "3997:46315", - "3997:46317", - "height", - "restored-hug-after-mask-child-folding", - "aspectRatio=\"320 / 48\"", - ), ] { let payload: devup_mcp_figma::CollectedPayload = serde_json::from_str(fixture).unwrap(); let o = generate_component( @@ -181,8 +173,54 @@ fn r8_all_four_sizing_resolutions_have_semantic_mapping() { .unwrap() .iter() .find(|e| e["nodeId"] == id && e["property"] == field && e["resolution"] == resolution) - .unwrap_or_else(|| panic!("missing {id} {field} {resolution}")); + .unwrap_or_else(|| { + panic!( + "missing {id} {field} {resolution}: {:?}\n{}", + map["entries"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["nodeId"] == id) + .collect::>(), + o.tsx + ) + }); assert_eq!(e["generatedProperty"], generated, "{id} {field}"); assert!(e.get("generatedRange").is_none()); } } + +#[test] +fn captured_hug_wrapper_keeps_its_actual_fixed_child_instead_of_becoming_an_asset() { + let payload: devup_mcp_figma::CollectedPayload = + serde_json::from_str(include_str!("../../../fixtures/r2/wquw-119-payload.json")).unwrap(); + let parent = &payload.snapshot.nodes["3997:46317"].fields; + assert_eq!(parent["layoutSizingVertical"], "HUG"); + assert_eq!(parent["childrenIds"], json!(["3997:46318"])); + assert_eq!(payload.snapshot.nodes["3997:46318"].fields["height"], 48); + let out = generate_component( + &payload.snapshot, + "3997:46315", + &CodegenOptions { + inline_instances: true, + ..Default::default() + } + .with_payload_tokens(&payload), + ) + .unwrap(); + let entry = out + .source_map + .entries + .iter() + .find(|e| e.node_id.as_deref() == Some("3997:46317") && e.property.is_none()) + .unwrap(); + let range = entry.generated_range.as_ref().unwrap(); + let wrapper = &out.tsx[range.start..range.end]; + let opening = wrapper.split('>').next().unwrap(); + assert!(opening.contains("Center")); + assert!(!opening.contains("aspectRatio=")); + assert!(!opening.contains("maskImage=")); + assert!(wrapper.contains("h=\"48px\"")); + assert!(wrapper.contains("w=\"127px\"")); + assert!(wrapper.contains("maskImage=\"url(/icons/bi.svg)\"")); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_child_order.rs b/crates/devup-mcp-devup-ui/tests/responsive_child_order.rs new file mode 100644 index 00000000..f5276cff --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_child_order.rs @@ -0,0 +1,134 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, responsive::merge_confirmed_breakpoints}; +use devup_mcp_figma::Snapshot; +use serde_json::json; +fn fixture() -> Snapshot { + serde_json::from_str(include_str!("fixtures/oa-overview-child-order.json")).unwrap() +} +fn render(s: &Snapshot) -> devup_mcp_devup_ui::codegen::responsive::MergedScreen { + merge_confirmed_breakpoints(s, &CodegenOptions::default()) + .unwrap() + .unwrap() +} +fn compact(s: &str) -> String { + s.split_whitespace().collect() +} +#[test] +fn actual_oa_overview_keeps_text_first_desktop_and_media_first_mobile() { + let source = fixture(); + assert_eq!( + source.nodes["73:9289"].fields["childrenIds"], + json!(["73:9290", "73:9310"]) + ); + assert_eq!( + source.nodes["73:9400"].fields["childrenIds"], + json!(["73:9401", "73:9404"]) + ); + let output = render(&source); + let text = compact(&output.tsx); + assert!( + text.contains("order={[\"0\",null,null,null,\"1\"]}"), + "{text}" + ); + assert!( + text.contains("order={[\"1\",null,null,null,\"0\"]}"), + "{text}" + ); + if let Ok(file) = std::env::var("DEVUP_CHILD_ORDER_PROBE") { + std::fs::write(file, output.module("ChildOrderProbe")).unwrap(); + } + let derived = output + .contract + .iter() + .filter(|c| c["derivedSource"].is_array()) + .collect::>(); + assert_eq!(derived.len(), 2); + assert!(derived.iter().all(|c| { + c["derivedSource"] + .as_array() + .unwrap() + .iter() + .all(|s| s["sourceField"] == "childrenIds") + })); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_RESPONSIVE_READING_ORDER_REVIEW") + ); +} +#[test] +fn identical_order_does_not_add_css_order_or_review() { + let mut s = fixture(); + s.nodes + .get_mut("73:9400") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["73:9404", "73:9401"])); + let o = render(&s); + assert!(!o.tsx.contains("order=")); + assert!( + !o.diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_RESPONSIVE_READING_ORDER_REVIEW") + ); +} +#[test] +fn missing_or_unknown_source_correspondence_is_not_guessed() { + for case in ["missing-child", "unknown-layout", "missing-children"] { + let mut s = fixture(); + let parent = s.nodes.get_mut("73:9400").unwrap(); + match case { + "missing-child" => { + parent + .fields + .insert("childrenIds".into(), json!(["73:9401"])); + } + "unknown-layout" => { + parent.fields.remove("layoutMode"); + } + _ => { + parent.fields.remove("childrenIds"); + } + } + assert!(!render(&s).tsx.contains("order="), "{case}"); + } +} + +#[test] +fn ambiguous_identity_or_absolute_child_does_not_invent_order() { + for case in ["ambiguous", "absolute"] { + let mut s = fixture(); + if case == "absolute" { + s.nodes + .get_mut("73:9401") + .unwrap() + .fields + .insert("layoutPositioning".into(), json!("ABSOLUTE")); + } else { + for id in ["73:9290", "73:9310", "73:9401", "73:9404"] { + s.nodes + .get_mut(id) + .unwrap() + .fields + .insert("name".into(), json!("same")); + } + } + assert!(!render(&s).tsx.contains("order="), "{case}"); + } +} + +#[test] +fn viewport_visibility_gates_are_retained_without_order_guessing() { + let mut source = fixture(); + source + .nodes + .get_mut("73:9401") + .unwrap() + .fields + .insert("visible".into(), json!(false)); + let output = render(&source); + // A captured hidden child can still have a known sibling index; its + // existing visibility gate must survive the independent order projection. + assert!(output.tsx.contains("display="), "{}", output.tsx); + assert!(output.tsx.contains("none"), "{}", output.tsx); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_child_order_extras.rs b/crates/devup-mcp-devup-ui/tests/responsive_child_order_extras.rs new file mode 100644 index 00000000..566095e4 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_child_order_extras.rs @@ -0,0 +1,127 @@ +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, + responsive::{MergedScreen, merge_confirmed_breakpoints}, +}; +use devup_mcp_figma::Snapshot; +use serde_json::json; +fn fixture() -> Snapshot { + serde_json::from_str(include_str!( + "fixtures/vision-timeline-width-specific-spacers.json" + )) + .unwrap() +} +fn render(source: &Snapshot) -> MergedScreen { + merge_confirmed_breakpoints( + source, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .unwrap() +} +const DESKTOP: [&str; 3] = ["44:2843", "44:2845", "44:2847"]; +#[test] +fn actual_vision_keeps_body_dot_spacer_desktop_and_dot_body_mobile() { + let source = fixture(); + let output = render(&source); + for parent in DESKTOP { + let body = format!("I{parent};44:1985"); + let dot = format!("I{parent};44:1983"); + let spacer = format!("I{parent};44:2036"); + assert_eq!( + source.nodes[parent].fields["childrenIds"], + json!([body, dot, spacer]) + ); + for (id, index) in [(body, 0), (dot, 1), (spacer.clone(), 2)] { + assert!( + output + .contract + .iter() + .any(|entry| entry["generatedProperty"] + .as_str() + .is_some_and(|p| p.starts_with("order=")) + && entry["derivedSource"] + .as_array() + .is_some_and(|sources| sources + .iter() + .any(|s| s["childNodeId"] == id && s["sourceIndex"] == index))), + "Missing captured order for {id}: {:?}", + output.contract + ); + } + let extra = output + .elements + .iter() + .find(|e| { + e.ids + .iter() + .any(|(width, id)| *width == 1440 && id == &spacer) + }) + .unwrap(); + assert!(extra.hidden_at.contains(&360)); + } + if let Ok(file) = std::env::var("DEVUP_VISION_ORDER_PROBE") { + std::fs::write(&file, output.module("VisionOrderProbe")).unwrap(); + let elements = output + .elements + .iter() + .enumerate() + .map(|(index, e)| json!({"index":index,"ids":e.ids,"hiddenAt":e.hidden_at})) + .collect::>(); + std::fs::write( + format!("{file}.elements.json"), + serde_json::to_vec(&elements).unwrap(), + ) + .unwrap(); + } +} +#[test] +fn unknown_declared_extra_or_parent_layout_does_not_guess_order() { + for unknown in ["extra", "layout"] { + let mut source = fixture(); + for parent in DESKTOP { + if unknown == "extra" { + source.nodes.remove(&format!("I{parent};44:2036")); + } else { + source + .nodes + .get_mut(parent) + .unwrap() + .fields + .remove("layoutMode"); + } + } + assert!(!render(&source).tsx.contains("order="), "{unknown}"); + } +} +#[test] +fn possible_unmatched_counterpart_is_ambiguous_not_a_proven_extra() { + let mut source = fixture(); + for parent in DESKTOP { + let name = source.nodes[&format!("I{parent};44:1985")].fields["name"].clone(); + source + .nodes + .get_mut(&format!("I{parent};44:2036")) + .unwrap() + .fields + .insert("name".into(), name); + } + assert!(!render(&source).tsx.contains("order=")); +} +#[test] +fn width_specific_extra_alone_does_not_require_order() { + let mut source = fixture(); + for parent in DESKTOP { + source.nodes.get_mut(parent).unwrap().fields.insert( + "childrenIds".into(), + json!([ + format!("I{parent};44:2036"), + format!("I{parent};44:1983"), + format!("I{parent};44:1985") + ]), + ); + } + assert!(!render(&source).tsx.contains("order=")); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_contract.rs b/crates/devup-mcp-devup-ui/tests/responsive_contract.rs index 95ddcbb1..bfcb4011 100644 --- a/crates/devup-mcp-devup-ui/tests/responsive_contract.rs +++ b/crates/devup-mcp-devup-ui/tests/responsive_contract.rs @@ -117,39 +117,45 @@ fn a_break_one_width_has_is_verified_by_reading_the_text_at_each_slot() { assert!(merged.verified, "{:?}\n{}", merged.gaps, merged.tsx); } -/// A fill only the mobile screen has is not cleared at desktop — devup-ui -/// keeps the narrower value — so the desktop width is not reproduced. +/// A captured empty desktop paint resets the mobile fill rather than inheriting it. #[test] -fn a_value_carried_into_a_width_that_does_not_have_it_is_not_verified() { - let merged = merged(|index, nodes| { +fn captured_empty_paint_is_reset_and_verified_at_the_other_width() { + let result = merged(|index, nodes| { if index == 0 { nodes["frame:0"]["fields"]["fills"] = json!([{"type":"SOLID","visible":true,"color":{"r":1,"g":0,"b":0}}]); } }); - assert!(!merged.verified, "{}", merged.tsx); - assert!( - reasons(&merged).contains(&"value-differs-at-width"), - "{:?}", - merged.gaps - ); - let bg = merged + assert!(result.verified, "{:?}\n{}", result.gaps, result.tsx); + assert!(result.tsx.contains("\"#F00\"")); + assert!(result.tsx.contains("\"initial\"")); + let bg = result .contract .iter() - .find(|entry| { - entry["generatedProperty"] + .find(|e| { + e["generatedProperty"] .as_str() - .is_some_and(|property| property.starts_with("bg=")) + .is_some_and(|p| p.starts_with("bg=")) }) .unwrap(); - assert_eq!(bg["verified"], false); - let gap = merged - .gaps - .iter() - .find(|gap| gap["property"] == "bg") - .unwrap(); - assert_eq!(gap["slot"], 4); - assert_eq!(gap["widthNodeId"], "frame:1"); + assert_eq!(bg["verified"], true); +} +#[test] +fn unreadable_desktop_paint_cannot_be_reset_or_verified_as_empty() { + let result = merged(|index, nodes| { + if index == 0 { + nodes["frame:0"]["fields"]["fills"] = + json!([{"type":"SOLID","visible":true,"color":{"r":1,"g":0,"b":0}}]); + } else { + nodes["frame:1"]["fieldErrors"] = json!({"fills":"read failed"}); + } + }); + assert!(!result.verified, "{}", result.tsx); + assert!( + reasons(&result).contains(&"value-differs-at-width"), + "{:?}", + result.gaps + ); } /// Two children the widths order oppositely keep one order in the module, so @@ -349,21 +355,20 @@ fn a_layer_no_width_draws_is_left_out_and_the_rest_verified() { assert!(merged.verified, "{:?}\n{}", merged.gaps, merged.tsx); } -/// Words that differ by more than their breaks keep the first width's, and -/// the other width's text is then not what the module writes there. +/// Literal labels that differ preserve each width's source text in its own visible branch. #[test] -fn text_that_reads_differently_at_a_width_is_not_verified() { - let merged = merged(|index, nodes| { +fn literal_text_that_differs_at_each_width_has_verified_branches() { + let result = merged(|index, nodes| { if index == 1 { nodes["label:1"]["fields"]["characters"] = json!("three four"); } }); - assert!(!merged.verified, "{}", merged.tsx); - assert!( - reasons(&merged).contains(&"text-differs-at-width"), - "{:?}", - merged.gaps - ); + assert!(result.verified, "{:?}\n{}", result.gaps, result.tsx); + assert!(result.tsx.contains("one two")); + assert!(result.tsx.contains("three four")); + assert_eq!(result.tsx.matches(">(); + assert_eq!(elements.len(), 1, "{id}: {:?}", output.elements); + assert!( + elements[0].hidden_at.contains(&other_width), + "{id}: {:?}", + elements[0] + ); + // Separate existing instance roots let a consumer bind one label/action + // per branch, rather than sharing a parent link across differing text. + assert!(!elements[0].ids.iter().any(|(at, _)| *at == other_width)); + } +} + +#[test] +fn viewport_copy_changes_preserve_whole_tab_hit_areas() { + let mut nodes = serde_json::Map::new(); + for (suffix, width, labels) in [ + ("mobile", 360, ["News", "Notice"]), + ("desktop", 1440, ["Notice", "News"]), + ] { + let root = format!("root-{suffix}"); + let tabs = [format!("selected-{suffix}"), format!("default-{suffix}")]; + nodes.insert(root.clone(),json!({"id":root,"type":"FRAME","fields":{"name":"Screen","width":width,"height":100,"layoutMode":"HORIZONTAL","childrenIds":tabs}})); + for (index, tab) in tabs.iter().enumerate() { + let text = format!("text-{tab}"); + nodes.insert(tab.clone(),json!({"id":tab,"type":"INSTANCE","fields":{"name":"TabItem","parentId":root,"mainComponentId":format!("master-{index}"),"componentSetId":"tabs","layoutMode":"HORIZONTAL","height":44,"layoutSizingVertical":"FIXED","childrenIds":[text]}})); + nodes.insert(text.clone(),json!({"id":text,"type":"TEXT","fields":{"name":"Label","parentId":tab,"characters":labels[index],"fontSize":16}})); + } + } + let snapshot: Snapshot=serde_json::from_value(json!({"fileKey":"tabs","roots":["root-mobile","root-desktop"],"nodes":nodes,"diagnostics":[]})).unwrap(); + let ordinary = devup_mcp_devup_ui::codegen::generate_component( + &snapshot, + "root-mobile", + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap(); + assert!(ordinary.tsx.contains("44px")); + assert_tab_branches( + &snapshot, + [ + "selected-mobile", + "default-mobile", + "selected-desktop", + "default-desktop", + ], + ["News", "Notice"], + ); +} + +#[test] +fn captured_notice_keeps_mobile_news_and_desktop_notice_labels_on_their_own_tabs() { + // Bounded original capture: two PageMenu subtrees plus their ancestors. + // The source itself selects News on mobile, Notice on desktop. Preserve + // that inconsistency so application wiring cannot silently rename a tab. + let snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/chondrogen_notice_tab_nodes.json")).unwrap(); + assert_tab_branches( + &snapshot, + [ + "I73:7835;44:5412;73:7833", + "I73:7835;44:5412;73:7834", + "I73:7865;40:643;73:7864", + "I73:7865;40:643;73:7863", + ], + ["기업 뉴스", "공지사항"], + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_layout_defaults.rs b/crates/devup-mcp-devup-ui/tests/responsive_layout_defaults.rs new file mode 100644 index 00000000..501dc5fb --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_layout_defaults.rs @@ -0,0 +1,240 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, responsive::merge_breakpoints}; +use devup_mcp_figma::Snapshot; +use serde_json::{Value, json}; + +fn output(snapshot: &Snapshot, verified: bool) -> String { + let result = merge_breakpoints( + snapshot, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .unwrap(); + assert_eq!(result.gaps.is_empty(), verified, "{:?}", result.gaps); + result.tsx.split_whitespace().collect() +} + +fn captured_output(snapshot: &Snapshot, ids: &[&str], properties: &[&str]) -> String { + let result = merge_breakpoints( + snapshot, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .unwrap(); + // These exact source subtrees also contain independently unverified native + // appearance/image-layer properties. Keep those reported; verify only the + // alignment/gap constraints this regression changes. + let relevant_gap = result.gaps.iter().any(|gap| { + ids.contains(&gap["widthNodeId"].as_str().unwrap_or_default()) + && properties.contains(&gap["property"].as_str().unwrap_or_default()) + }); + assert!(!relevant_gap, "{:?}", result.gaps); + result.tsx.split_whitespace().collect() +} + +fn frames(desktop: Value, mobile: Value) -> Snapshot { + let node = |id, width, extra: Value| { + let mut fields = json!({"name":if id=="d" {"desktop"} else {"mobile"}, "width":width,"height":100, + "layoutMode":"HORIZONTAL","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED", + "inferredAutoLayout":{"layoutMode":"HORIZONTAL","itemSpacing":0}}); + fields + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + json!({"id":id,"type":"FRAME","fields":fields}) + }; + serde_json::from_value( + json!({"fileKey":"layout-defaults","roots":["d","m"],"nodes":{ + "d":node("d",1440,desktop),"m":node("m",360,mobile)},"diagnostics":[]}), + ) + .unwrap() +} + +#[test] +fn mobile_center_resets_only_known_desktop_min_defaults() { + for desktop in [ + json!({"primaryAxisAlignItems":"MIN"}), + json!({"inferredAutoLayout":{"layoutMode":"HORIZONTAL","primaryAxisAlignItems":"MIN"}}), + ] { + let source = output( + &frames(desktop, json!({"primaryAxisAlignItems":"CENTER"})), + true, + ); + assert!( + source.contains("justifyContent={[\"center\",null,null,null,\"initial\"]}"), + "{source}" + ); + } + let reverse = output( + &frames( + json!({"primaryAxisAlignItems":"CENTER"}), + json!({"primaryAxisAlignItems":"MIN"}), + ), + true, + ); + assert!( + reverse.contains("justifyContent={[\"initial\",null,null,null,\"center\"]}"), + "{reverse}" + ); +} + +#[test] +fn unknown_main_alignment_cannot_be_reset_and_verified() { + for desktop in [ + json!({}), + json!({"primaryAxisAlignItems":null}), + json!({"primaryAxisAlignItems":{"$unsupported":"mixed"}}), + ] { + let source = output( + &frames(desktop, json!({"primaryAxisAlignItems":"CENTER"})), + false, + ); + assert!(!source.contains("justifyContent={["), "{source}"); + } + let mut snapshot = frames( + json!({"primaryAxisAlignItems":"MIN"}), + json!({"primaryAxisAlignItems":"CENTER"}), + ); + snapshot + .nodes + .get_mut("d") + .unwrap() + .field_errors + .insert("primaryAxisAlignItems".into(), "read failed".into()); + let source = output(&snapshot, false); + assert!(!source.contains("justifyContent={["), "{source}"); +} + +#[test] +fn captured_home_ctas_retain_mobile_center_and_desktop_start() { + // Exact CTA subtrees under captured viewport roots; unrelated root children + // are pruned and direct parent links adapted only to bound this regression. + let snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/home-product-cta-defaults.json")).unwrap(); + let source = captured_output( + &snapshot, + &["51:5685", "51:5704", "51:6663", "51:6684"], + &["justifyContent"], + ); + assert_eq!( + source + .matches("justifyContent={[\"center\",null,null,null,\"initial\"]}") + .count(), + 2, + "{source}" + ); + if let Ok(path) = std::env::var("DEVUP_CTA_DEFAULT_PROBE_OUTPUT") { + let result = merge_breakpoints( + &snapshot, + &CodegenOptions { + component_name: Some("CapturedCtas".into()), + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .unwrap(); + std::fs::write(path, result.tsx).unwrap(); + } +} + +#[test] +fn captured_news_gap_axes_survive_and_about_grid_uses_its_own_source_axes() { + for (fixture, column, row) in [ + ( + include_str!("fixtures/news-grid-gap-aliases.json"), + "32px", + "48px", + ), + ( + include_str!("fixtures/about-grid-gap-aliases.json"), + "40px", + "48px", + ), + ] { + let snapshot: Snapshot = serde_json::from_str(fixture).unwrap(); + let source = captured_output( + &snapshot, + &["73:4686", "73:4766", "41:771", "42:964"], + &["gap", "columnGap", "rowGap"], + ); + assert!( + source.contains(&format!( + "columnGap={{[\"{}\",null,null,null,\"{column}\"]}}", + if column == "32px" { "32px" } else { "20px" } + )) || source.contains(&format!("columnGap=\"{column}\"")), + "{source}" + ); + assert!( + source.contains(&format!( + "rowGap={{[\"{}\",null,null,null,\"{row}\"]}}", + if column == "32px" { "32px" } else { "20px" } + )), + "{source}" + ); + } +} + +#[test] +fn captured_gap_axes_also_merge_when_wrap_distribution_is_at_mobile() { + let mut snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/news-grid-gap-aliases.json")).unwrap(); + for (id, mode, wrap, cross) in [ + ("73:4686", "VERTICAL", "NO_WRAP", 0), + ("73:4766", "HORIZONTAL", "WRAP", 48), + ] { + let fields = &mut snapshot.nodes.get_mut(id).unwrap().fields; + fields.insert("layoutMode".into(), json!(mode)); + fields.insert("layoutWrap".into(), json!(wrap)); + fields.insert("itemSpacing".into(), json!(32)); + fields.insert("counterAxisSpacing".into(), json!(cross)); + fields.get_mut("inferredAutoLayout").unwrap()["layoutMode"] = json!(mode); + fields.get_mut("inferredAutoLayout").unwrap()["itemSpacing"] = json!(32); + } + let source = captured_output( + &snapshot, + &["73:4686", "73:4766"], + &["gap", "columnGap", "rowGap"], + ); + assert!(source.contains("columnGap=\"32px\""), "{source}"); + assert!( + source.contains("rowGap={[\"48px\",null,null,null,\"32px\"]}"), + "{source}" + ); +} + +#[test] +fn unknown_gap_reads_do_not_gain_synthetic_verified_axes() { + for field in ["itemSpacing", "counterAxisSpacing", "inferredAutoLayout"] { + let mut snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/news-grid-gap-aliases.json")).unwrap(); + snapshot + .nodes + .get_mut("73:4766") + .unwrap() + .field_errors + .insert(field.into(), "read failed".into()); + let result = merge_breakpoints( + &snapshot, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .unwrap(); + let source: String = result.tsx.split_whitespace().collect(); + // Without trustworthy per-frame spacing the old shorthand/axis output + // remains; the normalizer must not assert two known mobile axes. + assert!( + source.contains("gap={[\"32px\",null,null,null,\"initial\"]}"), + "{source}" + ); + assert!(!source.contains("columnGap=\"32px\""), "{source}"); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_length_tokens.rs b/crates/devup-mcp-devup-ui/tests/responsive_length_tokens.rs new file mode 100644 index 00000000..0296d231 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_length_tokens.rs @@ -0,0 +1,151 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, LengthToken, responsive::merge_breakpoints}; +use devup_mcp_figma::Snapshot; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +fn options(value: Value) -> CodegenOptions { + CodegenOptions { + inline_instances: true, + length_tokens: BTreeMap::from([( + "space".into(), + LengthToken { + token: "containerY".into(), + value, + }, + )]), + variable_tokens: BTreeMap::from([("space".into(), "containerY".into())]), + ..Default::default() + } +} + +fn snapshot( + mobile_bound: bool, + desktop_bound: bool, + desktop_width: u32, + desktop_padding: u32, +) -> Snapshot { + let mut nodes = serde_json::Map::new(); + for (suffix, width, padding, bound) in [ + ("mobile", 360, 40, mobile_bound), + ("desktop", desktop_width, desktop_padding, desktop_bound), + ] { + let id = format!("root-{suffix}"); + let child = format!("text-{suffix}"); + let mut fields = json!({"name":"Screen","width":width,"height":300,"layoutMode":"VERTICAL","paddingTop":0,"paddingLeft":0,"paddingRight":0,"paddingBottom":padding,"childrenIds":[child]}); + if bound { + fields["boundVariables"] = + json!({"paddingBottom":{"type":"VARIABLE_ALIAS","id":"space"}}) + } + nodes.insert(id.clone(), json!({"id":id,"type":"FRAME","fields":fields})); + nodes.insert(child.clone(),json!({"id":child,"type":"TEXT","fields":{"name":"Label","parentId":id,"characters":"Research","fontSize":16}})); + } + serde_json::from_value(json!({"fileKey":"nested-length","roots":["root-mobile","root-desktop"],"nodes":nodes,"diagnostics":[]})).unwrap() +} + +#[test] +fn responsive_prop_retains_jsx_token_and_reports_runtime_verification() { + let output = merge_breakpoints( + &snapshot(false, true, 1440, 80), + &options(json!(["40px", null, null, null, "80px"])), + ) + .unwrap() + .unwrap(); + assert!(output.verified, "{:?}", output.gaps); + assert!(output.tsx.contains("$containerY"), "{}", output.tsx); + assert!(!output.tsx.contains("var(--containerY)"), "{}", output.tsx); + assert!(output.tsx.contains("pb=\"$containerY\""), "{}", output.tsx); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_RESPONSIVE_LENGTH_TOKEN_PROJECTED") + ); +} + +#[test] +fn scalar_responsive_token_and_nested_scalar_token_remain_tokens() { + for (source, value) in [ + ( + snapshot(true, true, 1440, 80), + json!(["40px", null, null, null, "80px"]), + ), + (snapshot(false, true, 1440, 80), json!("80px")), + ] { + let output = merge_breakpoints(&source, &options(value)) + .unwrap() + .unwrap(); + assert!(output.verified, "{:?}", output.gaps); + assert!(output.tsx.contains("$containerY"), "{}", output.tsx); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_RESPONSIVE_LENGTH_TOKEN_PROJECTED") + ); + } +} + +#[test] +fn expansion_keeps_intermediate_and_trailing_theme_breakpoints() { + let values = json!(["40px", "48px", null, "64px", "80px", "96px"]); + let from_mobile = + merge_breakpoints(&snapshot(true, false, 1440, 120), &options(values.clone())) + .unwrap() + .unwrap(); + assert!(from_mobile.verified, "{:?}", from_mobile.gaps); + for value in ["$containerY", "120px"] { + assert!(from_mobile.tsx.contains(value), "{}", from_mobile.tsx) + } + let to_desktop = merge_breakpoints(&snapshot(false, true, 1920, 96), &options(values)) + .unwrap() + .unwrap(); + assert!(to_desktop.verified, "{:?}", to_desktop.gaps); + for value in ["$containerY"] { + assert!(to_desktop.tsx.contains(value), "{}", to_desktop.tsx) + } +} + +#[test] +fn captured_records_bottom_padding_is_eighty_at_desktop_not_token_base_forty() { + // Original frames/section/container fields only; unrelated children omitted. + let source: Snapshot = serde_json::from_str(include_str!( + "fixtures/chondrogen_records_padding_tokens.json" + )) + .unwrap(); + let mut opts = options(json!(["40px", null, null, null, "80px"])); + opts.length_tokens = BTreeMap::from([ + ( + "VariableID:30:632".into(), + LengthToken { + token: "containerY".into(), + value: json!(["40px", null, null, null, "80px"]), + }, + ), + ( + "VariableID:30:628".into(), + LengthToken { + token: "containerX".into(), + value: json!(["20px", null, null, null, "40px"]), + }, + ), + ]); + opts.variable_tokens = BTreeMap::from([ + ("VariableID:30:632".into(), "containerY".into()), + ("VariableID:30:628".into(), "containerX".into()), + ]); + let output = merge_breakpoints(&source, &opts).unwrap().unwrap(); + assert!(!output.tsx.contains("var(--containerY)"), "{}", output.tsx); + assert!(output.tsx.contains("$containerY"), "{}", output.tsx); + assert!(output.tsx.contains("$containerX"), "{}", output.tsx); + assert!( + output + .contract + .iter() + .filter(|p| p["generatedProperty"] + .as_str() + .is_some_and(|v| v.starts_with("pb="))) + .all(|p| p["verified"] == true), + "{:?}", + output.gaps + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_literal_text.rs b/crates/devup-mcp-devup-ui/tests/responsive_literal_text.rs new file mode 100644 index 00000000..8944c1e9 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_literal_text.rs @@ -0,0 +1,117 @@ +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, + responsive::{MergedScreen, merge_confirmed_breakpoints}, +}; +use devup_mcp_figma::Snapshot; +use serde_json::json; +fn fixture() -> Snapshot { + serde_json::from_str(include_str!("fixtures/news-detail-responsive-copy.json")).unwrap() +} +fn render(s: &Snapshot) -> MergedScreen { + merge_confirmed_breakpoints( + s, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .unwrap() +} +#[test] +fn actual_news_title_retains_each_captured_copy_and_viewport_style() { + let s = fixture(); + let o = render(&s); + assert!( + o.tsx.contains("[기업 뉴스] 뉴스 제목이 들어갑니다"), + "{}", + o.tsx + ); + assert!(o.tsx.contains("제목 영역"), "{}", o.tsx); + assert!( + !o.unrepresented + .iter() + .any(|n| n.detail.contains("reads differently")), + "{:?}", + o.unrepresented + ); + for (id, width, other) in [("73:4997", 360, 1440), ("73:4972", 1440, 360)] { + let matches = o + .elements + .iter() + .filter(|e| e.ids.iter().any(|(w, n)| *w == width && n == id)) + .collect::>(); + assert_eq!(matches.len(), 1); + assert!(matches[0].hidden_at.contains(&other)); + assert!(!matches[0].ids.iter().any(|(w, _)| *w == other)); + } + if let Ok(p) = std::env::var("DEVUP_LITERAL_TEXT_PROBE") { + std::fs::write(p, o.module("LiteralTextProbe")).unwrap(); + } +} +#[test] +fn same_copy_stays_shared_and_line_break_only_still_uses_existing_merge() { + for (desktop, mobile) in [ + ("Title content", "Title content"), + ("Titlecontent", "Title\ncontent"), + ] { + let mut s = fixture(); + for id in ["73:4972", "73:4997"] { + s.nodes + .get_mut(id) + .unwrap() + .fields + .remove("styledTextSegments"); + } + s.nodes + .get_mut("73:4972") + .unwrap() + .fields + .insert("characters".into(), json!(desktop)); + s.nodes + .get_mut("73:4997") + .unwrap() + .fields + .insert("characters".into(), json!(mobile)); + let o = render(&s); + assert!( + !o.unrepresented + .iter() + .any(|n| n.detail.contains("reads differently")) + ); + let e = o + .elements + .iter() + .find(|e| e.ids.iter().any(|(_, id)| id == "73:4997")) + .unwrap(); + assert!( + e.ids.iter().any(|(_, id)| id == "73:4972"), + "{:?}", + o.elements + ); + } +} +#[test] +fn unknown_copy_is_not_invented_and_missing_counterpart_stays_width_scoped() { + let mut s = fixture(); + s.nodes + .get_mut("73:4997") + .unwrap() + .fields + .remove("characters"); + let o = render(&s); + assert!(!o.tsx.contains("[기업 뉴스] 뉴스 제목이 들어갑니다")); + let mut s = fixture(); + let parent = s.nodes["73:4997"].fields["parentId"] + .as_str() + .unwrap() + .to_owned(); + s.nodes + .get_mut(&parent) + .unwrap() + .fields + .insert("childrenIds".into(), json!([])); + let o = render(&s); + assert!(o.tsx.contains("제목 영역")); + assert!(!o.tsx.contains("[기업 뉴스] 뉴스 제목이 들어갑니다")); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_padding_aliases.rs b/crates/devup-mcp-devup-ui/tests/responsive_padding_aliases.rs new file mode 100644 index 00000000..c59cab70 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_padding_aliases.rs @@ -0,0 +1,109 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, responsive::merge_confirmed_breakpoints}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +fn render(snapshot: &Snapshot) -> String { + let screen = merge_confirmed_breakpoints( + snapshot, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .unwrap(); + assert!( + !screen + .gaps + .iter() + .any(|g| ["p", "px", "py", "pl", "pr", "pt", "pb"] + .iter() + .any(|name| g["property"] == *name)), + "{:?}", + screen.gaps + ); + screen.module("PaddingHeaderProbe") +} + +fn synthetic(mobile: [i32; 4], desktop: [i32; 4]) -> Snapshot { + let frame = |id: &str, width: i32, p: [i32; 4]| { + json!({"id":id,"type":"FRAME","fields":{ + "name":id,"width":width,"height":64,"layoutMode":"HORIZONTAL","childrenIds":[], + "paddingLeft":p[0],"paddingRight":p[1],"paddingTop":p[2],"paddingBottom":p[3], + "layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED" + }}) + }; + serde_json::from_value( + json!({"fileKey":"padding","roots":["desktop","mobile"],"nodes":{ + "desktop":frame("desktop",1440,desktop),"mobile":frame("mobile",360,mobile) + },"diagnostics":[]}), + ) + .unwrap() +} + +fn compact(s: &str) -> String { + s.split_whitespace().collect() +} + +#[test] +fn mixed_horizontal_and_left_padding_become_independent_sides() { + let output = compact(&render(&synthetic([12, 0, 0, 0], [16, 16, 0, 0]))); + assert!( + output.contains("pl={[\"12px\",null,null,null,\"16px\"]}"), + "{output}" + ); + assert!( + output.contains("pr={[\"initial\",null,null,null,\"16px\"]}"), + "{output}" + ); + assert!(!output.contains("px="), "{output}"); +} + +#[test] +fn uniform_and_axis_padding_preserve_all_four_physical_values() { + let output = compact(&render(&synthetic([8, 8, 8, 8], [16, 16, 4, 4]))); + for (side, desktop) in [("px", 16), ("py", 4)] { + assert!( + output.contains(&format!( + "{side}={{[\"8px\",null,null,null,\"{desktop}px\"]}}" + )), + "{output}" + ); + } + assert!(!output.contains("pl=")); + assert!(!output.contains("pt=")); + assert!(!output.contains("p=")); +} + +#[test] +fn matching_axis_aliases_are_left_unchanged() { + let output = compact(&render(&synthetic([12, 12, 0, 0], [16, 16, 0, 0]))); + assert!( + output.contains("px={[\"12px\",null,null,null,\"16px\"]}"), + "{output}" + ); + assert!(!output.contains("pl=")); + assert!(!output.contains("pr=")); +} + +#[test] +fn actual_header_source_keeps_mobile_twelve_and_desktop_sixteen() { + let snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/header-padding-aliases.json")).unwrap(); + assert_eq!(snapshot.nodes["51:6610"].fields["paddingLeft"], 12); + assert_eq!(snapshot.nodes["45:2080"].fields["paddingLeft"], 16); + assert_eq!(snapshot.nodes["45:2080"].fields["paddingRight"], 16); + let output = render(&snapshot); + if let Ok(path) = std::env::var("DEVUP_PADDING_PROBE_OUTPUT") { + std::fs::write(path, &output).unwrap(); + } + let compressed = compact(&output); + assert!( + compressed.contains("pl={[\"12px\",null,null,null,\"16px\"]}"), + "{output}" + ); + assert!( + compressed.contains("pr={[\"initial\",null,null,null,\"16px\"]}"), + "{output}" + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_paint_defaults.rs b/crates/devup-mcp-devup-ui/tests/responsive_paint_defaults.rs new file mode 100644 index 00000000..4bd0ee39 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_paint_defaults.rs @@ -0,0 +1,201 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, responsive::merge_breakpoints}; +use devup_mcp_figma::Snapshot; +use serde_json::{Value, json}; + +fn merged(snapshot: Snapshot) -> String { + render(snapshot, true) +} + +fn render(snapshot: Snapshot, verified: bool) -> String { + let output = merge_breakpoints( + &snapshot, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .unwrap(); + assert_eq!(output.gaps.is_empty(), verified, "{:?}", output.gaps); + output.tsx.split_whitespace().collect() +} + +fn simple(mobile: Value, desktop: Value) -> Snapshot { + let node = |id: &str, width, paints: Value| { + let mut fields = json!({"name":if id=="m" {"mobile"} else {"desktop"}, + "width":width,"height":50,"layoutMode":"HORIZONTAL", + "layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED"}); + fields + .as_object_mut() + .unwrap() + .extend(paints.as_object().unwrap().clone()); + json!({"id":id,"type":"FRAME","fields":fields}) + }; + serde_json::from_value(json!({"fileKey":"paint","version":null,"roots":["d","m"], + "nodes":{"d":node("d",1440,desktop),"m":node("m",360,mobile)},"diagnostics":[]})) + .unwrap() +} + +fn paints() -> Value { + json!({"fills":[{"type":"SOLID","color":{"r":1,"g":0,"b":0}}], + "strokes":[{"type":"SOLID","color":{"r":0,"g":0,"b":0}}], + "strokeWeight":1,"strokeAlign":"INSIDE","strokesIncludedInLayout":true, + "paddingTop":2,"paddingBottom":2,"paddingLeft":2,"paddingRight":2}) +} + +#[test] +fn actual_oa_desktop_wrapper_does_not_inherit_mobile_paint() { + // Exact captured PageMenu subtrees; only root names/parent links are adapted + // to make this bounded collection independently discoverable as breakpoints. + let snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/oa-pagemenu-paints.json")).unwrap(); + let source = merged(snapshot); + assert!( + source.contains("bg={[\"$349\",null,null,null,\"initial\"]}"), + "{source}" + ); + assert!( + source.contains("borderBottom={[\"solid1px#E5E7EB\",null,null,null,\"initial\"]}"), + "{source}" + ); +} + +#[test] +fn absent_paints_reset_in_both_directions_and_explicit_empty_arrays_match_omission() { + for empty in [json!({}), json!({"fills":[],"strokes":[]})] { + let forward = merged(simple(paints(), empty.clone())); + assert!( + forward.contains("bg={[\"#F00\",null,null,null,\"initial\"]}"), + "{forward}" + ); + assert!( + forward.contains("border={[\"solid1px#000\",null,null,null,\"initial\"]}"), + "{forward}" + ); + let reverse = merged(simple(empty, paints())); + assert!( + reverse.contains("bg={[\"initial\",null,null,null,\"#F00\"]}"), + "{reverse}" + ); + } +} + +#[test] +fn unreadable_or_malformed_paints_are_not_asserted_empty() { + for field in [json!({"$unsupported":"mixed"}), Value::Null] { + let source = render( + simple(paints(), json!({"fills":field,"strokes":field})), + false, + ); + assert!(!source.contains("bg={["), "{source}"); + assert!(!source.contains("border={["), "{source}"); + } + let mut snapshot = simple(paints(), json!({})); + for field in ["fills", "strokes"] { + snapshot + .nodes + .get_mut("d") + .unwrap() + .field_errors + .insert(field.into(), "read failed".into()); + } + let source = render(snapshot, false); + assert!(!source.contains("bg={["), "{source}"); + assert!(!source.contains("border={["), "{source}"); +} + +#[test] +fn known_empty_effects_reset_shadows_but_keep_unrelated_text_color_policy() { + let mut painted = paints(); + painted["effects"] = json!([{"type":"DROP_SHADOW","color":{"r":0,"g":0,"b":0,"a":1},"offset":{"x":0,"y":2},"radius":4,"spread":0}]); + let source = merged(simple(painted, json!({}))); + assert!(source.contains("boxShadow={["), "{source}"); + assert!(source.contains("\"initial\"]}"), "{source}"); +} + +fn mixed_edges(right: u32, bottom: u32) -> Value { + let mut value = paints(); + value["strokeWeight"] = json!({"$unsupported":"symbol"}); + value["strokeTopWeight"] = json!(0); + value["strokeLeftWeight"] = json!(0); + value["strokeRightWeight"] = json!(right); + value["strokeBottomWeight"] = json!(bottom); + value +} + +#[test] +fn captured_product_card_resets_only_the_known_zero_stroke_edge() { + // Captured page roots keep their real1440/360 breakpoint widths; each is + // bounded to the corresponding unchanged card subtree for this regression. + let snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/product-card-mixed-strokes.json")).unwrap(); + let source = merged(snapshot); + assert!( + source.contains("borderBottom={[\"solid1px#E5E7EB\",null,null,null,\"initial\"]}"), + "{source}" + ); + assert!( + source.contains("borderRight={[\"initial\",null,null,null,\"solid1px#E5E7EB\"]}"), + "{source}" + ); +} + +#[test] +fn mixed_stroke_edges_reset_in_both_responsive_directions() { + for (mobile, desktop, first, second) in [ + ( + mixed_edges(0, 1), + mixed_edges(1, 0), + "borderBottom", + "borderRight", + ), + ( + mixed_edges(1, 0), + mixed_edges(0, 1), + "borderRight", + "borderBottom", + ), + ] { + let source = merged(simple(mobile, desktop)); + assert!( + source.contains(&format!( + "{first}={{[\"solid1px#000\",null,null,null,\"initial\"]}}" + )), + "{source}" + ); + assert!( + source.contains(&format!( + "{second}={{[\"initial\",null,null,null,\"solid1px#000\"]}}" + )), + "{source}" + ); + } +} + +#[test] +fn unreadable_zero_edge_does_not_become_a_verified_reset() { + for unknown in [Value::Null, json!({"$unsupported":"symbol"})] { + let mut desktop = mixed_edges(1, 0); + desktop["strokeBottomWeight"] = unknown; + let source = render(simple(mixed_edges(0, 1), desktop), false); + assert!(!source.contains("borderBottom={["), "{source}"); + } + let mut snapshot = simple(mixed_edges(0, 1), mixed_edges(1, 0)); + snapshot + .nodes + .get_mut("d") + .unwrap() + .field_errors + .insert("strokeBottomWeight".into(), "read failed".into()); + let source = render(snapshot, false); + assert!(!source.contains("borderBottom={["), "{source}"); + let mut snapshot = simple(mixed_edges(0, 1), mixed_edges(1, 0)); + snapshot + .nodes + .get_mut("d") + .unwrap() + .fields + .remove("strokeBottomWeight"); + let source = render(snapshot, false); + assert!(!source.contains("borderBottom={["), "{source}"); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_primitive_defaults.rs b/crates/devup-mcp-devup-ui/tests/responsive_primitive_defaults.rs new file mode 100644 index 00000000..5392da4d --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_primitive_defaults.rs @@ -0,0 +1,214 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, responsive::merge_breakpoints}; +use devup_mcp_figma::Snapshot; +use serde_json::{Value, json}; + +fn compact(source: &str) -> String { + source.split_whitespace().collect() +} + +fn frame(id: &str, name: &str, width: u32, mode: &str, main: &str, cross: &str) -> Value { + json!({"id":id,"type":"FRAME","fields":{ + "name":name,"width":width,"height":100,"visible":true,"childrenIds":[], + "layoutMode":mode,"primaryAxisAlignItems":main,"counterAxisAlignItems":cross, + "layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED", + "inferredAutoLayout":{"layoutMode":mode,"primaryAxisAlignItems":main, + "counterAxisAlignItems":cross,"primaryAxisSizingMode":"FIXED","counterAxisSizingMode":"FIXED"} + }}) +} + +fn merged(desktop: Value, mobile: Value) -> String { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"primitive-defaults","version":null,"roots":["d","m"], + "nodes":{"d":desktop,"m":mobile},"diagnostics":[] + })) + .unwrap(); + let result = merge_breakpoints( + &snapshot, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .unwrap(); + assert!(result.gaps.is_empty(), "{:?}", result.gaps); + compact(&result.tsx) +} + +#[test] +fn desktop_center_does_not_center_or_cross_align_mobile_flex() { + let source = merged( + frame("d", "desktop", 1440, "HORIZONTAL", "CENTER", "CENTER"), + frame("m", "mobile", 360, "HORIZONTAL", "MIN", "MIN"), + ); + assert!(source.contains("> = serde_json::from_str(array).unwrap(); + assert!( + values + .iter() + .flatten() + .all(|value| value.starts_with("\\2d \\2d ")) + ); + assert!( + !screen + .gaps + .iter() + .any(|gap| gap["property"] == "anchorName"), + "{:?}", + screen.gaps + ); + let metadata = overlay_anchor_metadata(&snapshot, &snapshot.roots, &screen.tsx); + assert_eq!(metadata.len(), 2); + assert!(metadata.iter().all(|record| { + values + .iter() + .flatten() + .any(|value| Some(value.as_str()) == record["cssIdentifier"].as_str()) + })); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_scalar_content.rs b/crates/devup-mcp-devup-ui/tests/responsive_scalar_content.rs new file mode 100644 index 00000000..714d3b41 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_scalar_content.rs @@ -0,0 +1,123 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, responsive::merge_breakpoints}; +use devup_mcp_figma::Snapshot; +use serde_json::json; +use std::collections::BTreeMap; + +#[test] +fn different_typography_tokens_preserve_separate_captured_text_nodes() { + let mut nodes = serde_json::Map::new(); + for (suffix, width, size, style) in [ + ("mobile", 390, 32, "style3"), + ("desktop", 1440, 48, "style2"), + ] { + let root = format!("root-{suffix}"); + let text = format!("text-{suffix}"); + for node in [ + json!({"id":root,"type":"FRAME","fields":{"name":"Screen","width":width,"height":300,"layoutMode":"VERTICAL","childrenIds":[text]}}), + json!({"id":text,"type":"TEXT","fields":{"name":"Heading","parentId":root,"characters":"Research","fontSize":size,"textStyleId":style}}), + ] { + nodes.insert(node["id"].as_str().unwrap().into(), node); + } + } + let snapshot:Snapshot=serde_json::from_value(json!({"fileKey":"responsive-typography","roots":["root-mobile","root-desktop"],"nodes":nodes,"diagnostics":[]})).unwrap(); + let options = CodegenOptions { + text_style_tokens: BTreeMap::from([ + ("style3".into(), "h3".into()), + ("style2".into(), "h2".into()), + ]), + ..Default::default() + }; + let merged = merge_breakpoints(&snapshot, &options).unwrap().unwrap(); + assert!(!merged.tsx.contains("typography={["), "{}", merged.tsx); + for expected in [ + "typography=\"h3\"", + "typography=\"h2\"", + "fontSize=\"32px\"", + "fontSize=\"48px\"", + ] { + assert!( + merged.tsx.contains(expected), + "missing {expected}: {}", + merged.tsx + ); + } + assert_eq!(merged.tsx.matches(" Snapshot { + serde_json::from_str(include_str!("fixtures/oa-media-size-aliases.json")).unwrap() +} +fn render(snapshot: &Snapshot) -> String { + let merged = merge_confirmed_breakpoints(snapshot, &CodegenOptions::default()) + .unwrap() + .unwrap(); + // This minimal captured media fixture has independent synthetic-paint + // provenance gaps. Size equivalence must pass without waiving those gaps. + assert!( + !merged.gaps.iter().any(|g| ["boxSize", "w", "h"] + .iter() + .any(|name| g["property"] == *name)), + "{:?}", + merged.gaps + ); + let axes = merged + .contract + .iter() + .filter(|c| { + c["elementIndex"] == 1 + && c["generatedProperty"] + .as_str() + .is_some_and(|s| ["w=", "h=", "boxSize="].iter().any(|p| s.starts_with(p))) + }) + .collect::>(); + assert!(!axes.is_empty(), "Size contract evidence must be present"); + assert!(axes.iter().all(|c| c["verified"] == true), "{axes:?}"); + merged.module("SizeAliasProbe") +} +fn compact(s: &str) -> String { + s.split_whitespace().collect() +} +#[test] +fn actual_oa_media_keeps_desktop_square_and_mobile_fixed_height() { + let source = fixture(); + assert_eq!(source.nodes["73:9310"].fields["width"], 560); + assert_eq!(source.nodes["73:9310"].fields["height"], 560); + let text = compact(&render(&source)); + assert!(!text.contains("boxSize="), "{text}"); + assert!( + text.contains("h={[\"240px\",null,null,null,\"560px\"]}"), + "{text}" + ); + assert!( + text.contains("w={[\"initial\",null,null,null,\"560px\"]}"), + "{text}" + ); +} +#[test] +fn reverse_square_to_rectangular_transition_keeps_both_axes() { + let mut source = fixture(); + for (id, w, h) in [("73:9401", 240, 240), ("73:9310", 560, 420)] { + let node = source.nodes.get_mut(id).unwrap(); + node.fields.insert("width".into(), json!(w)); + node.fields.insert("height".into(), json!(h)); + node.fields + .insert("layoutSizingHorizontal".into(), json!("FIXED")); + node.fields.insert( + "absoluteBoundingBox".into(), + json!({"x":0,"y":0,"width":w,"height":h}), + ); + node.fields.insert( + "absoluteRenderBounds".into(), + json!({"x":0,"y":0,"width":w,"height":h}), + ); + } + let text = compact(&render(&source)); + assert!( + text.contains("h={[\"240px\",null,null,null,\"420px\"]}"), + "{text}" + ); + assert!( + text.contains("w={[\"240px\",null,null,null,\"560px\"]}"), + "{text}" + ); + assert!(!text.contains("boxSize=")); +} +#[test] +fn identical_square_alias_family_is_not_expanded() { + let mut source = fixture(); + let mobile = source.nodes.get_mut("73:9401").unwrap(); + mobile.fields.insert("width".into(), json!(240)); + mobile + .fields + .insert("layoutSizingHorizontal".into(), json!("FIXED")); + let text = compact(&render(&source)); + assert!( + text.contains("boxSize={[\"240px\",null,null,null,\"560px\"]}"), + "{text}" + ); +} +#[test] +fn independent_rectangular_axes_remain_independent() { + let mut source = fixture(); + source + .nodes + .get_mut("73:9310") + .unwrap() + .fields + .insert("height".into(), json!(420)); + let text = compact(&render(&source)); + assert!(!text.contains("boxSize=")); + assert!( + text.contains("h={[\"240px\",null,null,null,\"420px\"]}"), + "{text}" + ); +} + +#[test] +fn physical_size_equivalence_does_not_hide_other_contract_failures() { + let mut source = fixture(); + for id in ["73:9310", "73:9401"] { + let fields = &mut source.nodes.get_mut(id).unwrap().fields; + fields.remove("fills"); + fields.remove("isAsset"); + } + let merged = merge_confirmed_breakpoints(&source, &CodegenOptions::default()) + .unwrap() + .unwrap(); + assert!(merged.verified, "{:?}", merged.gaps); + assert!(!merged.contract.is_empty()); + assert!(merged.contract.iter().all(|item| item["verified"] == true)); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_wrapper_reconciliation.rs b/crates/devup-mcp-devup-ui/tests/responsive_wrapper_reconciliation.rs new file mode 100644 index 00000000..1ab1fd13 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_wrapper_reconciliation.rs @@ -0,0 +1,220 @@ +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, + responsive::{MergedScreen, merge_confirmed_breakpoints}, +}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +// The patent page's heading is a direct mobile child and a child of a desktop +// title/summary row. The title and statistic content have identical semantics. +fn fixture() -> Snapshot { + serde_json::from_str(include_str!( + "fixtures/responsive-title-summary-wrappers.json" + )) + .unwrap() +} + +fn render(source: &Snapshot) -> MergedScreen { + merge_confirmed_breakpoints( + source, + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .unwrap() +} + +fn compact(source: &str) -> String { + source.split_whitespace().collect() +} + +#[test] +fn mobile_direct_heading_and_desktop_group_render_identical_content_once() { + let result = render(&fixture()); + let tsx = compact(&result.tsx); + for text in [ + "PATENTS", + "특허·지식재산권", + "전체3건/등록2건/출원1건", + "특허목록", + ] { + assert_eq!(tsx.matches(text).count(), 1, "{text}: {}", result.tsx); + } + assert!(tsx.find("PATENTS").unwrap() < tsx.find("전체3건").unwrap()); + assert!(tsx.find("전체3건").unwrap() < tsx.find("특허목록").unwrap()); + assert!( + tsx.contains("display={[\"contents\",null,null,null,\"flex\"]}"), + "{}", + result.tsx + ); + assert!( + tsx.contains("gap={[\"24px\",null,null,null,\"32px\"]}"), + "{}", + result.tsx + ); + assert!( + tsx.contains("gap={[\"8px\",null,null,null,\"12px\"]}"), + "{}", + result.tsx + ); + assert!( + tsx.contains("w={[\"320px\",null,null,null,\"400px\"]}"), + "{}", + result.tsx + ); + assert!(tsx.contains("justifyContent="), "{}", result.tsx); + assert!(tsx.contains("space-between"), "{}", result.tsx); + assert!(tsx.contains("alignItems="), "{}", result.tsx); + assert!(!tsx.contains("data-figma")); + assert!(!tsx.contains("__devup_responsive")); + let heading = result + .elements + .iter() + .find(|element| element.ids.iter().any(|(_, id)| id == "heading:0")) + .unwrap(); + assert_eq!( + heading.ids, + [(1440, "heading:4".into()), (360, "heading:0".into())] + ); + assert!( + result + .elements + .iter() + .all(|element| element.ids.iter().all(|(_, id)| !id.starts_with("__devup"))) + ); + // The added box has derived source evidence, never a fabricated mobile + // Figma node or an assertion that a real browser verified its geometry. + assert!(!result.verified); + assert!( + result + .gaps + .iter() + .any(|gap| gap["reason"] == "derived-responsive-wrapper-requires-runtime-review") + ); + assert!( + result + .contract + .iter() + .any(|entry| entry["derivedTopology"].is_array()) + ); + assert!( + result + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_RESPONSIVE_WRAPPER_RECONCILED") + ); +} + +#[test] +fn painted_padded_and_clipped_wrappers_are_retained_with_precise_review() { + for (name, field, value) in [ + ( + "paint", + "fills", + json!([{"type":"SOLID","color":{"r":1,"g":0,"b":0}}]), + ), + ("padding", "paddingLeft", json!(24)), + ("clipping", "clipsContent", json!(true)), + ] { + let mut source = fixture(); + source + .nodes + .get_mut("row:4") + .unwrap() + .fields + .insert(field.into(), value); + let result = render(&source); + assert_eq!( + result.tsx.matches("PATENTS").count(), + 2, + "{name}: {}", + result.tsx + ); + assert!(!result.tsx.contains("contents"), "{name}: {}", result.tsx); + assert!( + result + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_RESPONSIVE_TOPOLOGY_UNREPRESENTED"), + "{name}: {:?}", + result.diagnostics + ); + assert!(!result.unrepresented.is_empty(), "{name}"); + } +} + +#[test] +fn mobile_precedence_does_not_publish_a_synthetic_source_node() { + let mut source = fixture(); + source.roots.reverse(); + let result = render(&source); + assert_eq!(result.tsx.matches("PATENTS").count(), 1); + for entry in &result.contract { + if let Some(id) = entry["nodeId"].as_str() { + assert!(source.nodes.contains_key(id), "{entry}"); + } + } + for element in &result.elements { + assert!( + element + .ids + .iter() + .all(|(_, id)| source.nodes.contains_key(id)) + ); + } + assert!( + result + .contract + .iter() + .any(|entry| entry["nodeId"].is_null() && entry["derivedTopology"].is_array()) + ); +} + +#[test] +fn noncontiguous_or_changed_content_is_not_silently_unified() { + for case in ["noncontiguous", "outer-order", "changed", "ambiguous"] { + let mut source = fixture(); + if case == "noncontiguous" { + source.nodes.get_mut("page:0").unwrap().fields.insert( + "childrenIds".into(), + json!(["title:0", "table:0", "summary:0"]), + ); + } else if case == "outer-order" { + source.nodes.get_mut("page:0").unwrap().fields.insert( + "childrenIds".into(), + json!(["table:0", "title:0", "summary:0"]), + ); + } else if case == "changed" { + source + .nodes + .get_mut("summary-text:0") + .unwrap() + .fields + .insert("characters".into(), json!("전체 4건")); + } else { + let mut duplicate = source.nodes["title:0"].clone(); + duplicate.id = "title:duplicate".into(); + source.nodes.insert(duplicate.id.clone(), duplicate); + source.nodes.get_mut("page:0").unwrap().fields.insert( + "childrenIds".into(), + json!(["title:0", "title:duplicate", "summary:0", "table:0"]), + ); + } + let result = render(&source); + assert!(!result.tsx.contains("contents"), "{case}: {}", result.tsx); + assert!( + result + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_RESPONSIVE_TOPOLOGY_UNREPRESENTED"), + "{case}: {:?}", + result.diagnostics + ); + assert!(!result.unrepresented.is_empty(), "{case}"); + if case == "changed" { + assert!(result.tsx.contains("전체 4건")); + } + } +} diff --git a/crates/devup-mcp-devup-ui/tests/scalar_alt.rs b/crates/devup-mcp-devup-ui/tests/scalar_alt.rs new file mode 100644 index 00000000..652a9c16 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/scalar_alt.rs @@ -0,0 +1,120 @@ +use std::collections::BTreeMap; + +use devup_mcp_devup_ui::codegen::{ + AssetRepresentationPolicy, CodegenOptions, generate_component, responsive::merge_breakpoints, +}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +#[test] +fn actual_desktop_only_header_and_footer_logos_never_use_css_arrays_for_alt() { + for source in [ + include_str!("fixtures/chondrogen_header_set.json"), + include_str!("fixtures/chondrogen_footer_set.json"), + ] { + let snapshot: Snapshot = serde_json::from_str(source).unwrap(); + let output = + generate_component(&snapshot, &snapshot.roots[0], &CodegenOptions::default()).unwrap(); + assert!(!output.tsx.contains("alt={["), "{}", output.tsx); + assert!(output.tsx.contains("alt=\"Logo\""), "{}", output.tsx); + } +} + +fn responsive_snapshot(desktop_only: bool) -> (Snapshot, CodegenOptions) { + let mut nodes = serde_json::Map::new(); + let mut policies = BTreeMap::new(); + for (suffix, width, name) in [ + ("mobile", 360, "Mobile logo"), + ("desktop", 1440, "Desktop logo"), + ] { + let root = format!("root-{suffix}"); + let image = format!("image-{suffix}"); + let children = if desktop_only && suffix == "mobile" { + vec![] + } else { + vec![image.clone()] + }; + for node in [ + json!({"id":root,"type":"FRAME","fields":{"name":"Screen","width":width,"height":300,"childrenIds":children,"layoutMode":"VERTICAL"}}), + json!({"id":image,"type":"RECTANGLE","fields":{"name":name,"parentId":root,"width":200,"height":100,"isAsset":true,"fills":[{"type":"IMAGE","imageHash":"logo","scaleMode":"FILL"}]}}), + ] { + nodes.insert(node["id"].as_str().unwrap().into(), node); + } + policies.insert( + format!("{image}:fills:0"), + AssetRepresentationPolicy { + asset_path: Some("/same-logo.png".into()), + ..Default::default() + }, + ); + } + let snapshot=serde_json::from_value(json!({"fileKey":"scalar-alt","roots":["root-mobile","root-desktop"],"nodes":nodes,"diagnostics":[]})).unwrap(); + ( + snapshot, + CodegenOptions { + asset_policies: policies, + ..Default::default() + }, + ) +} + +#[test] +fn an_image_absent_at_mobile_keeps_its_captured_scalar_alternative() { + let (snapshot, options) = responsive_snapshot(true); + let merged = merge_breakpoints(&snapshot, &options).unwrap().unwrap(); + assert!(!merged.tsx.contains("alt={["), "{}", merged.tsx); + assert!( + merged.tsx.contains("alt=\"Desktop logo\""), + "{}", + merged.tsx + ); + assert!( + !merged + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_RESPONSIVE_ALT_UNREPRESENTED") + ); +} + +#[test] +fn different_visible_alternatives_are_scalar_but_explicitly_lossy() { + let (snapshot, options) = responsive_snapshot(false); + let merged = merge_breakpoints(&snapshot, &options).unwrap().unwrap(); + assert!(!merged.tsx.contains("alt={["), "{}", merged.tsx); + assert!( + merged + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_RESPONSIVE_ALT_UNREPRESENTED"), + "{:?}", + merged + ); + assert!(!merged.unrepresented.is_empty()); +} + +#[test] +fn visible_viewport_variant_alt_conflicts_have_a_loss_diagnostic() { + let mut nodes = serde_json::Map::new(); + nodes.insert("set".into(),json!({"id":"set","type":"COMPONENT_SET","fields":{"name":"Logo","childrenIds":["desktop","mobile"],"componentPropertyDefinitions":{"viewport":{"type":"VARIANT","defaultValue":"desktop","variantOptions":["desktop","mobile"]}}}})); + for (viewport, name, width) in [ + ("desktop", "Desktop logo", 200), + ("mobile", "Mobile logo", 100), + ] { + nodes.insert(viewport.into(),json!({"id":viewport,"type":"COMPONENT","fields":{"name":name,"parentId":"set","variantProperties":{"viewport":viewport},"width":width,"height":50,"isAsset":true,"fills":[{"type":"IMAGE","imageHash":"same","scaleMode":"FILL"}]}})); + } + let snapshot: Snapshot = serde_json::from_value( + json!({"fileKey":"scalar-alt-variant","roots":["set"],"nodes":nodes,"diagnostics":[]}), + ) + .unwrap(); + let output = generate_component(&snapshot, "set", &CodegenOptions::default()).unwrap(); + assert!(!output.tsx.contains("alt={["), "{}", output.tsx); + let issue = output + .diagnostics + .iter() + .find(|d| d.code == "DEVUP_CODEGEN_RESPONSIVE_ALT_UNREPRESENTED") + .expect("conflicting captured alternatives need a loss diagnostic"); + assert_eq!( + issue.fidelity_impact, + Some(devup_mcp_figma::FidelityImpact::Lossy) + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/semantic_html.rs b/crates/devup-mcp-devup-ui/tests/semantic_html.rs new file mode 100644 index 00000000..3491b614 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/semantic_html.rs @@ -0,0 +1,159 @@ +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, generate_component, responsive::merge_breakpoints, +}; +use devup_mcp_figma::Snapshot; +use serde_json::{Value, json}; + +fn snapshot(nodes: Value, roots: Value) -> Snapshot { + serde_json::from_value( + json!({"fileKey":"semantics","roots":roots,"nodes":nodes,"diagnostics":[]}), + ) + .unwrap() +} + +#[test] +fn explicit_named_regions_and_headings_keep_their_drawn_styles() { + let nodes = json!({ + "page":{"id":"page","type":"FRAME","fields":{"name":"Screen","childrenIds":["header","main","footer"],"layoutMode":"VERTICAL"}}, + "header":{"id":"header","type":"FRAME","fields":{"name":"Header","childrenIds":["nav"],"height":80}}, + "nav":{"id":"nav","type":"FRAME","fields":{"name":"GNB","layoutMode":"HORIZONTAL"}}, + "main":{"id":"main","type":"FRAME","fields":{"name":"Main content","childrenIds":["heading","title"]}}, + "heading":{"id":"heading","type":"TEXT","fields":{"name":"Page title","characters":"Research","fontSize":48,"fontWeight":700}}, + "title":{"id":"title","type":"TEXT","fields":{"name":"Card title","characters":"A card","fontSize":48}}, + "footer":{"id":"footer","type":"FRAME","fields":{"name":"Footer","height":100}} + }); + let output = generate_component( + &snapshot(nodes, json!(["page"])), + "page", + &CodegenOptions::default(), + ) + .unwrap(); + for tag in ["header", "nav", "main", "footer", "h1"] { + assert_eq!( + output.tsx.matches(&format!("as=\"{tag}\"")).count(), + 1, + "{}", + output.tsx + ); + } + assert!(output.tsx.contains("fontSize=\"48px\""), "{}", output.tsx); + assert!(output.tsx.contains("fontWeight=\"700\""), "{}", output.tsx); + assert!( + !output.tsx.contains("as=\"h2\""), + "a card title is not evidence of heading level" + ); + assert!( + output.diagnostics.iter().all(|entry| { + entry.code != "DEVUP_CODEGEN_PROPERTY_UNMAPPED" + || entry.property.as_deref() != Some("as") + }), + "{:?}", + output.diagnostics + ); +} + +#[test] +fn descriptive_asset_names_and_decorative_shapes_always_emit_alt() { + for (name, alt) in [ + ("Cartilage cell model", "Cartilage cell model"), + ("Image of cartilage", "Image of cartilage"), + ("Rectangle-shaped model", "Rectangle-shaped model"), + ("Image 3", ""), + ("Rectangle 20", ""), + ("Icon / search", ""), + ] { + let source = snapshot( + json!({"image":{"id":"image","type":"RECTANGLE","fields":{ + "name":name,"width":200,"height":100,"isAsset":true, + "fills":[{"type":"IMAGE","imageHash":"a","scaleMode":"FILL"}] + }}}), + json!(["image"]), + ); + let output = generate_component(&source, "image", &CodegenOptions::default()).unwrap(); + assert!( + output.tsx.contains(&format!("alt=\"{alt}\"")), + "{}", + output.tsx + ); + assert_eq!(output.tsx.matches("alt=").count(), 1, "{}", output.tsx); + } +} + +#[test] +fn a_variant_root_uses_its_component_sets_semantic_name() { + let source = snapshot( + json!({ + "set":{"id":"set","type":"COMPONENT_SET","fields":{"name":"Header","childrenIds":["header"]}}, + "header":{"id":"header","type":"COMPONENT","fields":{"name":"Viewport=desktop","parentId":"set","width":1440,"height":80}} + }), + json!(["header"]), + ); + let output = generate_component(&source, "header", &CodegenOptions::default()).unwrap(); + assert!(output.tsx.contains("as=\"header\""), "{}", output.tsx); +} + +#[test] +fn responsive_trees_preserve_semantics_and_image_alternatives() { + let mut nodes = serde_json::Map::new(); + for (suffix, width) in [("mobile", 360), ("desktop", 1440)] { + let root = format!("root-{suffix}"); + let heading = format!("heading-{suffix}"); + let image = format!("image-{suffix}"); + for node in [ + json!({"id":root,"type":"FRAME","fields":{"name":"Main content","width":width,"height":500,"layoutMode":"VERTICAL","childrenIds":[heading,image]}}), + json!({"id":heading,"type":"TEXT","fields":{"name":"Page title","parentId":root,"characters":"Research","fontSize":48}}), + json!({"id":image,"type":"RECTANGLE","fields":{"name":"Cartilage model","parentId":root,"width":200,"height":100,"isAsset":true,"fills":[{"type":"IMAGE","imageHash":"model","scaleMode":"FILL"}]}}), + ] { + nodes.insert(node["id"].as_str().unwrap().into(), node); + } + } + let source = snapshot(Value::Object(nodes), json!(["root-mobile", "root-desktop"])); + let merged = merge_breakpoints(&source, &CodegenOptions::default()) + .unwrap() + .unwrap(); + for prop in ["as=\"main\"", "as=\"h1\"", "alt=\"Cartilage model\""] { + assert!(merged.tsx.contains(prop), "missing {prop}: {}", merged.tsx); + } +} + +#[test] +fn responsive_inlining_retains_concrete_text_and_style_overrides() { + let mut nodes = serde_json::Map::new(); + for (suffix, width, gap) in [("mobile", 360, 12), ("desktop", 1440, 24)] { + let root = format!("root-{suffix}"); + let instance = format!("instance-{suffix}"); + let label = format!("label-{suffix}"); + let second = format!("second-{suffix}"); + for node in [ + json!({"id":root,"type":"FRAME","fields":{"name":"Screen","width":width,"height":500,"layoutMode":"VERTICAL","childrenIds":[instance]}}), + json!({"id":instance,"type":"INSTANCE","fields":{"name":"Card","parentId":root,"width":width,"height":100,"layoutMode":"VERTICAL","inferredAutoLayout":{"layoutMode":"VERTICAL","itemSpacing":gap},"itemSpacing":gap,"childrenIds":[label,second]}}), + json!({"id":label,"type":"TEXT","fields":{"name":"Label","parentId":instance,"characters":"Concrete instance override","fontSize":16}}), + json!({"id":second,"type":"TEXT","fields":{"name":"Body","parentId":instance,"characters":"Body","fontSize":16}}), + ] { + nodes.insert(node["id"].as_str().unwrap().into(), node); + } + } + let source = snapshot(Value::Object(nodes), json!(["root-mobile", "root-desktop"])); + let kept = merge_breakpoints(&source, &CodegenOptions::default()) + .unwrap() + .unwrap(); + assert!(kept.tsx.contains(" 현재 적용된 문체 : @@ -54,12 +57,14 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 담담체 - + write
    @@ -69,7 +74,9 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 : @@ -80,7 +87,9 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -125,6 +134,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" textAlign="center" typography="caption" w="100%" @@ -143,6 +153,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="22px" + overflowWrap="break-word" typography="captionSm" wordBreak="keep-all" > @@ -164,6 +175,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="26px" typography="bodyXsSemibold" + whiteSpace="pre" > TIP @@ -173,6 +185,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -216,20 +229,24 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 제목 - + write 수정 @@ -244,6 +261,7 @@ export function AStoryFProofread() { fontWeight="700" letterSpacing="-0.02em" lineHeight="31px" + overflowWrap="break-word" typography="h5" wordBreak="keep-all" > @@ -266,20 +284,24 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 1단락 - + write 수정 @@ -293,6 +315,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" w="100%" wordBreak="keep-all" @@ -316,20 +339,24 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 2단락 - + write 수정 @@ -343,6 +370,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" w="100%" wordBreak="keep-all" @@ -374,20 +402,24 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 3단락 - + write 수정 @@ -401,6 +433,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" w="100%" wordBreak="keep-all" @@ -424,20 +457,24 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 4단락 - + write 수정 @@ -451,6 +488,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" w="100%" wordBreak="keep-all" @@ -462,6 +500,7 @@ export function AStoryFProofread() { 편집 설정 @@ -499,7 +540,9 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 검토 완료 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap index a4e743cf..e0637504 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap @@ -21,6 +21,7 @@ export function AStoryFProofread() { fontWeight="700" letterSpacing="-0.02em" lineHeight="39px" + overflowWrap="break-word" textAlign="center" typography="h3" w="100%" @@ -37,7 +38,9 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 현재 적용된 문체 : @@ -48,12 +51,14 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 담담체 - + write
    @@ -63,7 +68,9 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 : @@ -74,7 +81,9 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -119,6 +128,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" textAlign="center" typography="caption" w="100%" @@ -137,6 +147,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="22px" + overflowWrap="break-word" typography="captionSm" wordBreak="keep-all" > @@ -158,6 +169,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="26px" typography="bodyXsSemibold" + whiteSpace="pre" > TIP @@ -167,6 +179,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -210,20 +223,24 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 제목 - + write 수정 @@ -238,6 +255,7 @@ export function AStoryFProofread() { fontWeight="700" letterSpacing="-0.02em" lineHeight="31px" + overflowWrap="break-word" typography="h5" wordBreak="keep-all" > @@ -260,20 +278,24 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 1단락 - + write 수정 @@ -287,6 +309,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" w="100%" wordBreak="keep-all" @@ -310,20 +333,24 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 2단락 - + write 수정 @@ -337,6 +364,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" w="100%" wordBreak="keep-all" @@ -368,20 +396,24 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 3단락 - + write 수정 @@ -395,6 +427,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" w="100%" wordBreak="keep-all" @@ -418,20 +451,24 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 4단락 - + write 수정 @@ -445,6 +482,7 @@ export function AStoryFProofread() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" w="100%" wordBreak="keep-all" @@ -456,6 +494,7 @@ export function AStoryFProofread() { 편집 설정 @@ -493,7 +534,9 @@ export function AStoryFProofread() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 검토 완료 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap index f59ac3b9..1235e6d9 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap @@ -1,6 +1,6 @@ --- source: crates/devup-mcp-devup-ui/tests/wquw_151.rs -expression: json!(output.diagnostics) +expression: diagnostics --- [ { @@ -53,7 +53,7 @@ expression: json!(output.diagnostics) "appliedValue": { "state": "emitted", "generatedNodeId": "3879:35547", - "generatedSource": " \n 작은 시장, 큰 사랑\n ", + "generatedSource": " \n 작은 시장, 큰 사랑\n ", "sourceTruncated": false, "propertyMappingVerified": false }, @@ -68,7 +68,7 @@ expression: json!(output.diagnostics) "parentLayoutMode": "HORIZONTAL", "childSizing": "FILL", "generatedParent": "
    \n \n \n 편집 설정\n \n
    \n
    \n \n 검토 완료\n \n
    \n
    " + " \n \n \n 편집 설정\n \n
    \n
    \n \n 검토 완료\n \n
    \n " ], "fallback": null, "heightPreservation": { @@ -834,7 +841,7 @@ expression: json!(output.diagnostics) "containingBlock": { "parentId": "3879:35518", "generatedParent": " 기록해주신 이야기를
    정리하고 있어요
    잠시만 기다려주세요 @@ -61,6 +63,7 @@ export function Wquw151Frame387935503() { letterSpacing="-0.05em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="mainPraiseXs" @@ -71,6 +74,7 @@ export function Wquw151Frame387935503() { Frame 26086022 현재 적용된 문체 : @@ -56,12 +59,14 @@ export function Wquw151Frame387935518() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 담담체 - + write
    @@ -71,7 +76,9 @@ export function Wquw151Frame387935518() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 : @@ -82,7 +89,9 @@ export function Wquw151Frame387935518() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -128,6 +137,7 @@ export function Wquw151Frame387935518() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="caption" @@ -148,6 +158,7 @@ export function Wquw151Frame387935518() { letterSpacing="-0.01em" lineHeight="22px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="captionSm" wordBreak="keep-all" @@ -170,6 +181,7 @@ export function Wquw151Frame387935518() { letterSpacing="-0.01em" lineHeight="26px" typography="bodyXsSemibold" + whiteSpace="pre" > TIP @@ -180,6 +192,7 @@ export function Wquw151Frame387935518() { letterSpacing="-0.01em" lineHeight="26px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="bodyXs" whiteSpace="pre-wrap" @@ -224,20 +237,24 @@ export function Wquw151Frame387935518() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 제목 - + write 수정 @@ -253,6 +270,7 @@ export function Wquw151Frame387935518() { letterSpacing="-0.02em" lineHeight="31px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="h5" wordBreak="keep-all" @@ -276,20 +294,24 @@ export function Wquw151Frame387935518() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 1단락 - + write 수정 @@ -304,6 +326,7 @@ export function Wquw151Frame387935518() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -328,20 +351,24 @@ export function Wquw151Frame387935518() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 2단락 - + write 수정 @@ -356,6 +383,7 @@ export function Wquw151Frame387935518() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -388,20 +416,24 @@ export function Wquw151Frame387935518() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 3단락 - + write 수정 @@ -416,6 +448,7 @@ export function Wquw151Frame387935518() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -440,20 +473,24 @@ export function Wquw151Frame387935518() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 4단락 - + write 수정 @@ -468,6 +505,7 @@ export function Wquw151Frame387935518() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -480,6 +518,7 @@ export function Wquw151Frame387935518() { 편집 설정 @@ -517,7 +558,9 @@ export function Wquw151Frame387935518() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 검토 완료 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap index 52eeb53c..4b5d22a1 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap @@ -28,6 +28,7 @@ export function Wquw151Frame387935569() { letterSpacing="-0.02em" lineHeight="39px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="h3" @@ -45,7 +46,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 현재 적용된 문체 : @@ -56,12 +59,14 @@ export function Wquw151Frame387935569() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 담담체 - + write
    AI 편집자에게 다듬기 맡기기 @@ -120,7 +128,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 내 문장 최대한 유지하기 @@ -136,7 +146,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 : @@ -147,7 +159,9 @@ export function Wquw151Frame387935569() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -193,6 +207,7 @@ export function Wquw151Frame387935569() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="caption" @@ -213,6 +228,7 @@ export function Wquw151Frame387935569() { letterSpacing="-0.01em" lineHeight="22px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="captionSm" wordBreak="keep-all" @@ -235,6 +251,7 @@ export function Wquw151Frame387935569() { letterSpacing="-0.01em" lineHeight="26px" typography="bodyXsSemibold" + whiteSpace="pre" > TIP @@ -245,6 +262,7 @@ export function Wquw151Frame387935569() { letterSpacing="-0.01em" lineHeight="26px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="bodyXs" whiteSpace="pre-wrap" @@ -289,20 +307,24 @@ export function Wquw151Frame387935569() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 제목 - + write 수정 @@ -318,6 +340,7 @@ export function Wquw151Frame387935569() { letterSpacing="-0.02em" lineHeight="31px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="h5" wordBreak="keep-all" @@ -341,20 +364,24 @@ export function Wquw151Frame387935569() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 1단락 - + write 수정 @@ -369,6 +396,7 @@ export function Wquw151Frame387935569() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -393,20 +421,24 @@ export function Wquw151Frame387935569() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 2단락 - + write 수정 @@ -421,6 +453,7 @@ export function Wquw151Frame387935569() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -453,20 +486,24 @@ export function Wquw151Frame387935569() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 3단락 - + write 수정 @@ -481,6 +518,7 @@ export function Wquw151Frame387935569() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -505,20 +543,24 @@ export function Wquw151Frame387935569() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 4단락 - + write 수정 @@ -533,6 +575,7 @@ export function Wquw151Frame387935569() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -545,6 +588,7 @@ export function Wquw151Frame387935569() { 문체 변경 @@ -576,7 +622,9 @@ export function Wquw151Frame387935569() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 검토 완료 @@ -613,7 +661,9 @@ export function Wquw151Frame387935569() { fontWeight="700" letterSpacing="-0.02em" lineHeight="28px" + overflowWrap="break-word" typography="h6" + whiteSpace="pre" wordBreak="keep-all" > 문체 변경 @@ -624,7 +674,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > @@ -671,7 +723,9 @@ export function Wquw151Frame387935569() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 담담체 @@ -682,7 +736,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 차분하고 담백한 문체 @@ -707,7 +763,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" + whiteSpace="pre" wordBreak="keep-all" > 서정체 @@ -718,7 +776,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 감성적이고 부드러운 문체 @@ -743,7 +803,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" + whiteSpace="pre" wordBreak="keep-all" > 강건체 @@ -754,7 +816,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 단호하고 힘찬 문체 @@ -762,6 +826,8 @@ export function Wquw151Frame387935569() { + + 교정 설정 @@ -781,7 +849,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > @@ -809,7 +879,9 @@ export function Wquw151Frame387935569() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > AI 담당자와 함께 문체 다듬기 @@ -820,7 +892,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > AI 담당자가 문맥을 자연스럽게 수정해요 @@ -845,7 +919,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" + whiteSpace="pre" wordBreak="keep-all" > 내 문장 그대로 맞춤법만 다듬기 @@ -856,7 +932,9 @@ export function Wquw151Frame387935569() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 입력하신 문장 그대로의 느낌을 담아내요 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap index 69bfca9a..09d15bf6 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap @@ -28,6 +28,7 @@ export function Wquw151Frame387935652() { letterSpacing="-0.02em" lineHeight="39px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="h3" @@ -45,7 +46,9 @@ export function Wquw151Frame387935652() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 현재 적용된 문체 : @@ -56,12 +59,14 @@ export function Wquw151Frame387935652() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 담담체 - + write AI 편집자에게 다듬기 맡기기 @@ -120,7 +128,9 @@ export function Wquw151Frame387935652() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 내 문장 최대한 유지하기 @@ -136,7 +146,9 @@ export function Wquw151Frame387935652() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 : @@ -147,7 +159,9 @@ export function Wquw151Frame387935652() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -193,6 +207,7 @@ export function Wquw151Frame387935652() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="caption" @@ -213,6 +228,7 @@ export function Wquw151Frame387935652() { letterSpacing="-0.01em" lineHeight="22px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="captionSm" wordBreak="keep-all" @@ -235,6 +251,7 @@ export function Wquw151Frame387935652() { letterSpacing="-0.01em" lineHeight="26px" typography="bodyXsSemibold" + whiteSpace="pre" > TIP @@ -245,6 +262,7 @@ export function Wquw151Frame387935652() { letterSpacing="-0.01em" lineHeight="26px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="bodyXs" whiteSpace="pre-wrap" @@ -289,20 +307,24 @@ export function Wquw151Frame387935652() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 제목 - + write 수정 @@ -318,6 +340,7 @@ export function Wquw151Frame387935652() { letterSpacing="-0.02em" lineHeight="31px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="h5" wordBreak="keep-all" @@ -341,20 +364,24 @@ export function Wquw151Frame387935652() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 1단락 - + write 수정 @@ -369,6 +396,7 @@ export function Wquw151Frame387935652() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -393,20 +421,24 @@ export function Wquw151Frame387935652() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 2단락 - + write 수정 @@ -421,6 +453,7 @@ export function Wquw151Frame387935652() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -453,20 +486,24 @@ export function Wquw151Frame387935652() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 3단락 - + write 수정 @@ -481,6 +518,7 @@ export function Wquw151Frame387935652() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -505,20 +543,24 @@ export function Wquw151Frame387935652() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 4단락 - + write 수정 @@ -533,6 +575,7 @@ export function Wquw151Frame387935652() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -545,6 +588,7 @@ export function Wquw151Frame387935652() { 문체 변경 @@ -576,7 +622,9 @@ export function Wquw151Frame387935652() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 검토 완료 @@ -613,7 +661,9 @@ export function Wquw151Frame387935652() { fontWeight="700" letterSpacing="-0.02em" lineHeight="28px" + overflowWrap="break-word" typography="h6" + whiteSpace="pre" wordBreak="keep-all" > 문체 변경 @@ -624,7 +674,9 @@ export function Wquw151Frame387935652() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > @@ -671,7 +723,9 @@ export function Wquw151Frame387935652() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 담담체 @@ -682,7 +736,9 @@ export function Wquw151Frame387935652() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 차분하고 담백한 문체 @@ -707,7 +763,9 @@ export function Wquw151Frame387935652() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" + whiteSpace="pre" wordBreak="keep-all" > 서정체 @@ -718,7 +776,9 @@ export function Wquw151Frame387935652() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 감성적이고 부드러운 문체 @@ -743,7 +803,9 @@ export function Wquw151Frame387935652() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" + whiteSpace="pre" wordBreak="keep-all" > 강건체 @@ -754,7 +816,9 @@ export function Wquw151Frame387935652() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 단호하고 힘찬 문체 @@ -762,6 +826,8 @@ export function Wquw151Frame387935652() { + + diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap index 139c17b1..c5266104 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap @@ -28,6 +28,7 @@ export function Wquw151Frame387935729() { letterSpacing="-0.02em" lineHeight="39px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="h3" @@ -45,7 +46,9 @@ export function Wquw151Frame387935729() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 현재 적용된 문체 : @@ -56,12 +59,14 @@ export function Wquw151Frame387935729() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 담담체 - + write AI 편집자에게 다듬기 맡기기 @@ -120,7 +128,9 @@ export function Wquw151Frame387935729() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 내 문장 최대한 유지하기 @@ -136,7 +146,9 @@ export function Wquw151Frame387935729() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 : @@ -147,7 +159,9 @@ export function Wquw151Frame387935729() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -193,6 +207,7 @@ export function Wquw151Frame387935729() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="caption" @@ -213,6 +228,7 @@ export function Wquw151Frame387935729() { letterSpacing="-0.01em" lineHeight="22px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="captionSm" wordBreak="keep-all" @@ -235,6 +251,7 @@ export function Wquw151Frame387935729() { letterSpacing="-0.01em" lineHeight="26px" typography="bodyXsSemibold" + whiteSpace="pre" > TIP @@ -245,6 +262,7 @@ export function Wquw151Frame387935729() { letterSpacing="-0.01em" lineHeight="26px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="bodyXs" whiteSpace="pre-wrap" @@ -289,20 +307,24 @@ export function Wquw151Frame387935729() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 제목 - + write 수정 @@ -318,6 +340,7 @@ export function Wquw151Frame387935729() { letterSpacing="-0.02em" lineHeight="31px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="h5" wordBreak="keep-all" @@ -341,20 +364,24 @@ export function Wquw151Frame387935729() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 1단락 - + write 수정 @@ -369,6 +396,7 @@ export function Wquw151Frame387935729() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -393,20 +421,24 @@ export function Wquw151Frame387935729() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 2단락 - + write 수정 @@ -421,6 +453,7 @@ export function Wquw151Frame387935729() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -453,20 +486,24 @@ export function Wquw151Frame387935729() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 3단락 - + write 수정 @@ -481,6 +518,7 @@ export function Wquw151Frame387935729() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -505,20 +543,24 @@ export function Wquw151Frame387935729() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 4단락 - + write 수정 @@ -533,6 +575,7 @@ export function Wquw151Frame387935729() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -545,6 +588,7 @@ export function Wquw151Frame387935729() { 문체 변경 @@ -576,7 +622,9 @@ export function Wquw151Frame387935729() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 검토 완료 @@ -613,7 +661,9 @@ export function Wquw151Frame387935729() { fontWeight="700" letterSpacing="-0.02em" lineHeight="28px" + overflowWrap="break-word" typography="h6" + whiteSpace="pre" wordBreak="keep-all" > 교정 설정 @@ -624,7 +674,9 @@ export function Wquw151Frame387935729() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > @@ -671,7 +723,9 @@ export function Wquw151Frame387935729() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > AI 담당자와 함께 문체 다듬기 @@ -682,7 +736,9 @@ export function Wquw151Frame387935729() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > AI 담당자가 문맥을 자연스럽게 수정해요 @@ -707,7 +763,9 @@ export function Wquw151Frame387935729() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" + whiteSpace="pre" wordBreak="keep-all" > 내 문장 그대로 맞춤법만 다듬기 @@ -718,7 +776,9 @@ export function Wquw151Frame387935729() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 입력하신 문장 그대로의 느낌을 담아내요 @@ -726,6 +786,8 @@ export function Wquw151Frame387935729() { + + diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap index 87683d55..89733251 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap @@ -28,6 +28,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.02em" lineHeight="39px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="h3" @@ -45,7 +46,9 @@ export function Wquw151Frame387935887() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 현재 적용된 문체 : @@ -56,12 +59,14 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 담담체 - + write AI 편집자에게 다듬기 맡기기 @@ -120,7 +128,9 @@ export function Wquw151Frame387935887() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 내 문장 최대한 유지하기 @@ -136,7 +146,9 @@ export function Wquw151Frame387935887() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 : @@ -147,7 +159,9 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -193,6 +207,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="caption" @@ -213,6 +228,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="22px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="captionSm" wordBreak="keep-all" @@ -235,6 +251,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="26px" typography="bodyXsSemibold" + whiteSpace="pre" > TIP @@ -245,6 +262,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="26px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="bodyXs" whiteSpace="pre-wrap" @@ -289,20 +307,24 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 제목 - + write 수정 @@ -318,6 +340,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.02em" lineHeight="31px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="h5" wordBreak="keep-all" @@ -341,20 +364,24 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 1단락 - + write 수정 @@ -369,6 +396,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -393,20 +421,24 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 2단락 - + write 수정 @@ -421,6 +453,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -453,20 +486,24 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 3단락 - + write 수정 @@ -481,6 +518,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -505,20 +543,24 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 4단락 - + write 수정 @@ -533,6 +575,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -545,6 +588,7 @@ export function Wquw151Frame387935887() { 문체 변경 @@ -576,7 +622,9 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 검토 완료 @@ -613,7 +661,9 @@ export function Wquw151Frame387935887() { fontWeight="700" letterSpacing="-0.02em" lineHeight="28px" + overflowWrap="break-word" typography="h6" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 @@ -624,7 +674,9 @@ export function Wquw151Frame387935887() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 작성하신 이야기를 누구와 함께 나눌지 선택해 주세요. @@ -668,7 +720,9 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 동행인 @@ -680,6 +734,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="caption" wordBreak="keep-all" @@ -706,7 +761,9 @@ export function Wquw151Frame387935887() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -718,6 +775,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="caption" wordBreak="keep-all" @@ -727,6 +785,8 @@ export function Wquw151Frame387935887() { + + 교정 설정 @@ -746,7 +808,9 @@ export function Wquw151Frame387935887() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > @@ -774,7 +838,9 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > AI 담당자와 함께 문체 다듬기 @@ -785,7 +851,9 @@ export function Wquw151Frame387935887() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > AI 담당자가 문맥을 자연스럽게 수정해요 @@ -810,7 +878,9 @@ export function Wquw151Frame387935887() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" + whiteSpace="pre" wordBreak="keep-all" > 내 문장 그대로 맞춤법만 다듬기 @@ -821,7 +891,9 @@ export function Wquw151Frame387935887() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 입력하신 문장 그대로의 느낌을 담아내요 @@ -858,7 +930,9 @@ export function Wquw151Frame387935887() { fontWeight="700" letterSpacing="-0.02em" lineHeight="28px" + overflowWrap="break-word" typography="h6" + whiteSpace="pre" wordBreak="keep-all" > 글 다듬기 방식을 변경할까요? @@ -871,6 +945,7 @@ export function Wquw151Frame387935887() { letterSpacing="-0.01em" lineHeight="29px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="bodySm" @@ -890,7 +965,9 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 취소 @@ -909,7 +986,9 @@ export function Wquw151Frame387935887() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 다시 교정하기 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap index ca4c98d8..f2d4f7a1 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap @@ -28,6 +28,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.02em" lineHeight="39px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="h3" @@ -45,7 +46,9 @@ export function Wquw151Frame387935973() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 현재 적용된 문체 : @@ -56,12 +59,14 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 담담체 - + write AI 편집자에게 다듬기 맡기기 @@ -120,7 +128,9 @@ export function Wquw151Frame387935973() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 내 문장 최대한 유지하기 @@ -136,7 +146,9 @@ export function Wquw151Frame387935973() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 : @@ -147,7 +159,9 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -193,6 +207,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="caption" @@ -213,6 +228,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="22px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="captionSm" wordBreak="keep-all" @@ -235,6 +251,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="26px" typography="bodyXsSemibold" + whiteSpace="pre" > TIP @@ -245,6 +262,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="26px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="bodyXs" whiteSpace="pre-wrap" @@ -289,20 +307,24 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 제목 - + write 수정 @@ -318,6 +340,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.02em" lineHeight="31px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="h5" wordBreak="keep-all" @@ -341,20 +364,24 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 1단락 - + write 수정 @@ -369,6 +396,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -393,20 +421,24 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 2단락 - + write 수정 @@ -421,6 +453,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -453,20 +486,24 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 3단락 - + write 수정 @@ -481,6 +518,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -505,20 +543,24 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 4단락 - + write 수정 @@ -533,6 +575,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -545,6 +588,7 @@ export function Wquw151Frame387935973() { 문체 변경 @@ -576,7 +622,9 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 검토 완료 @@ -613,7 +661,9 @@ export function Wquw151Frame387935973() { fontWeight="700" letterSpacing="-0.02em" lineHeight="28px" + overflowWrap="break-word" typography="h6" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 @@ -624,7 +674,9 @@ export function Wquw151Frame387935973() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 작성하신 이야기를 누구와 함께 나눌지 선택해 주세요. @@ -668,7 +720,9 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 동행인 @@ -680,6 +734,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="caption" wordBreak="keep-all" @@ -706,7 +761,9 @@ export function Wquw151Frame387935973() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -718,6 +775,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="caption" wordBreak="keep-all" @@ -727,6 +785,8 @@ export function Wquw151Frame387935973() { + + 교정 설정 @@ -746,7 +808,9 @@ export function Wquw151Frame387935973() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > @@ -774,7 +838,9 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > AI 담당자와 함께 문체 다듬기 @@ -785,7 +851,9 @@ export function Wquw151Frame387935973() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > AI 담당자가 문맥을 자연스럽게 수정해요 @@ -810,7 +878,9 @@ export function Wquw151Frame387935973() { fontWeight="400" letterSpacing="-0.01em" lineHeight="32px" + overflowWrap="break-word" typography="body" + whiteSpace="pre" wordBreak="keep-all" > 내 문장 그대로 맞춤법만 다듬기 @@ -821,7 +891,9 @@ export function Wquw151Frame387935973() { fontWeight="400" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="caption" + whiteSpace="pre" wordBreak="keep-all" > 입력하신 문장 그대로의 느낌을 담아내요 @@ -859,7 +931,9 @@ export function Wquw151Frame387935973() { fontWeight="700" letterSpacing="-0.02em" lineHeight="28px" + overflowWrap="break-word" typography="h6" + whiteSpace="pre" wordBreak="keep-all" > 문체를 적용합니다. @@ -872,6 +946,7 @@ export function Wquw151Frame387935973() { letterSpacing="-0.01em" lineHeight="29px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="bodySm" @@ -895,7 +970,9 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 취소 @@ -914,7 +991,9 @@ export function Wquw151Frame387935973() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 적용하기 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap index 18c58e17..44589f81 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap @@ -15,22 +15,25 @@ export function Wquw151Frame387936059() { > - +
    + +
    초기화 @@ -70,6 +75,7 @@ export function Wquw151Frame387936059() { letterSpacing="-0.02em" lineHeight="34px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="h4" w="100%" @@ -100,7 +106,9 @@ export function Wquw151Frame387936059() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 이야기가 막힐 때 눌러보세요 @@ -114,6 +122,7 @@ export function Wquw151Frame387936059() { letterSpacing="-0.02em" lineHeight="31px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="recordText" w="100%" @@ -129,12 +138,20 @@ export function Wquw151Frame387936059() { fontWeight="600" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="captionSemibold" + whiteSpace="pre" wordBreak="keep-all" > 직접 입력 - + 플레이 버튼
    녹음하기 - + 플레이 버튼
    작성 완료 - + 플레이 버튼
    임시 저장하고 다음에 이어서 하기 @@ -205,7 +240,9 @@ export function Wquw151Frame387936059() { fontWeight="700" letterSpacing="-0.02em" lineHeight="28px" + overflowWrap="break-word" typography="h6" + whiteSpace="pre" wordBreak="keep-all" > 이야기 다듬기 시작 @@ -218,6 +255,7 @@ export function Wquw151Frame387936059() { letterSpacing="-0.01em" lineHeight="29px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="bodySm" @@ -241,6 +279,7 @@ export function Wquw151Frame387936059() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="caption" wordBreak="keep-all" @@ -263,7 +302,9 @@ export function Wquw151Frame387936059() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > AI 담당자와 함께 문체 다듬기 @@ -284,7 +325,9 @@ export function Wquw151Frame387936059() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 내 문장 그대로 맞춤법만 다듬기 @@ -302,7 +345,9 @@ export function Wquw151Frame387936059() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 더 이야기 할게요 @@ -321,7 +366,9 @@ export function Wquw151Frame387936059() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 마무리 할게요 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36108.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36108.snap index bbe01ccc..6bfe1259 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36108.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36108.snap @@ -9,22 +9,25 @@ export function Wquw151Frame387936108() { - +
    + +
    초기화 @@ -64,6 +69,7 @@ export function Wquw151Frame387936108() { letterSpacing="-0.02em" lineHeight="34px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="h4" w="100%" @@ -94,7 +100,9 @@ export function Wquw151Frame387936108() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 이야기가 막힐 때 눌러보세요 @@ -108,6 +116,7 @@ export function Wquw151Frame387936108() { letterSpacing="-0.02em" lineHeight="31px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="recordText" w="100%" @@ -123,12 +132,20 @@ export function Wquw151Frame387936108() { fontWeight="600" letterSpacing="-0.01em" lineHeight="24px" + overflowWrap="break-word" typography="captionSemibold" + whiteSpace="pre" wordBreak="keep-all" > 직접 입력 - + 플레이 버튼
    녹음하기 - + 플레이 버튼
    작성 완료 - + 플레이 버튼
    임시 저장하고 다음에 이어서 하기 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36144.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36144.snap index 69e99d0b..f7eeb228 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36144.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36144.snap @@ -28,6 +28,7 @@ export function Wquw151Frame387936144() { letterSpacing="-0.02em" lineHeight="39px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="h3" @@ -46,7 +47,9 @@ export function Wquw151Frame387936144() { fontWeight="400" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXs" + whiteSpace="pre" wordBreak="keep-all" > 공개 설정 : @@ -57,7 +60,9 @@ export function Wquw151Frame387936144() { fontWeight="600" letterSpacing="-0.01em" lineHeight="29px" + overflowWrap="break-word" typography="bodySmSemibold" + whiteSpace="pre" wordBreak="keep-all" > 나만 보기 @@ -103,6 +108,7 @@ export function Wquw151Frame387936144() { letterSpacing="-0.01em" lineHeight="24px" overflow="hidden" + overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" typography="caption" @@ -123,6 +129,7 @@ export function Wquw151Frame387936144() { letterSpacing="-0.01em" lineHeight="22px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="captionSm" wordBreak="keep-all" @@ -145,6 +152,7 @@ export function Wquw151Frame387936144() { letterSpacing="-0.01em" lineHeight="26px" typography="bodyXsSemibold" + whiteSpace="pre" > TIP @@ -155,6 +163,7 @@ export function Wquw151Frame387936144() { letterSpacing="-0.01em" lineHeight="26px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="bodyXs" whiteSpace="pre-wrap" @@ -199,20 +208,24 @@ export function Wquw151Frame387936144() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 제목
    - + write 수정 @@ -228,6 +241,7 @@ export function Wquw151Frame387936144() { letterSpacing="-0.02em" lineHeight="31px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="h5" wordBreak="keep-all" @@ -251,20 +265,24 @@ export function Wquw151Frame387936144() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 1단락 - + write 수정 @@ -279,6 +297,7 @@ export function Wquw151Frame387936144() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -303,20 +322,24 @@ export function Wquw151Frame387936144() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 2단락 - + write 수정 @@ -331,6 +354,7 @@ export function Wquw151Frame387936144() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -363,20 +387,24 @@ export function Wquw151Frame387936144() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 3단락 - + write 수정 @@ -391,6 +419,7 @@ export function Wquw151Frame387936144() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -415,20 +444,24 @@ export function Wquw151Frame387936144() { fontWeight="600" letterSpacing="-0.01em" lineHeight="26px" + overflowWrap="break-word" typography="bodyXsSemibold" + whiteSpace="pre" wordBreak="keep-all" > 4단락 - + write 수정 @@ -443,6 +476,7 @@ export function Wquw151Frame387936144() { letterSpacing="-0.01em" lineHeight="32px" overflow="hidden" + overflowWrap="break-word" textOverflow="ellipsis" typography="body" w="100%" @@ -455,6 +489,7 @@ export function Wquw151Frame387936144() {
    교정 설정 @@ -492,7 +529,9 @@ export function Wquw151Frame387936144() { fontWeight="600" letterSpacing="-0.01em" lineHeight="31px" + overflowWrap="break-word" typography="bodySemibold" + whiteSpace="pre" wordBreak="keep-all" > 검토 완료 diff --git a/crates/devup-mcp-devup-ui/tests/strokes_included_layout.rs b/crates/devup-mcp-devup-ui/tests/strokes_included_layout.rs new file mode 100644 index 00000000..719a5497 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/strokes_included_layout.rs @@ -0,0 +1,165 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::{Value, json}; + +fn source(flag: Option, padding: u32) -> Snapshot { + let mut snapshot: Snapshot = serde_json::from_value(json!({"fileKey":"stroke-layout","roots":["input"],"diagnostics":[],"nodes":{ + "input":{"id":"input","type":"FRAME","fields":{ + "name":"input","layoutMode":"HORIZONTAL","layoutPositioning":"AUTO","counterAxisAlignItems":"CENTER", + "layoutSizingHorizontal":"FIXED","layoutSizingVertical":"HUG","width":300,"height":50, + "paddingTop":padding,"paddingRight":padding,"paddingBottom":padding,"paddingLeft":padding, + "strokeWeight":1,"strokes":[{"type":"SOLID","visible":true,"color":{"r":0,"g":0,"b":0}}],"childrenIds":["icon","text"] + }}, + "icon":{"id":"icon","type":"FRAME","fields":{"parentId":"input","width":24,"height":24,"layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED"}}, + "text":{"id":"text","type":"TEXT","fields":{"parentId":"input","characters":"Search","fontSize":16,"lineHeight":{"unit":"PIXELS","value":24},"textAutoResize":"WIDTH_AND_HEIGHT","layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG"}} + }})).unwrap(); + if let Some(flag) = flag { + snapshot + .nodes + .get_mut("input") + .unwrap() + .fields + .insert("strokesIncludedInLayout".into(), json!(flag)); + } + snapshot +} + +fn render(snapshot: &Snapshot) -> String { + generate_component( + snapshot, + "input", + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap() + .tsx +} + +#[test] +fn captured_strokes_in_layout_keep_padding_and_css_border() { + let output = render(&source(Some(true), 12)); + assert!(output.contains("p=\"12px\""), "{output}"); + assert!(output.contains("border=\"solid 1px #000\""), "{output}"); + assert!(!output.contains("outline="), "{output}"); + if let Ok(path) = std::env::var("DEVUP_STROKE_LAYOUT_PROBE_OUTPUT") { + std::fs::write(path, output).unwrap(); + } +} + +#[test] +fn explicit_false_and_missing_legacy_capture_keep_existing_inside_stroke_compensation() { + for flag in [Some(false), None] { + let output = render(&source(flag, 12)); + assert!(output.contains("p=\"11px\""), "{flag:?}: {output}"); + } +} + +#[test] +fn explicit_true_with_zero_padding_must_not_turn_layout_border_into_outline() { + let output = render(&source(Some(true), 0)); + assert!(output.contains("border=\"solid 1px #000\""), "{output}"); + assert!(!output.contains("outline="), "{output}"); + let legacy = render(&source(Some(false), 0)); + assert!(legacy.contains("outline=\"solid 1px #000\""), "{legacy}"); +} + +#[test] +fn nested_inferred_flag_is_used_only_when_direct_flag_is_absent() { + for (direct, expected) in [(None, "12px"), (Some(false), "11px"), (Some(true), "12px")] { + let mut snapshot = source(direct, 12); + snapshot.nodes.get_mut("input").unwrap().fields.insert( + "inferredAutoLayout".into(), + json!({"strokesIncludedInLayout":true}), + ); + let output = render(&snapshot); + assert!( + output.contains(&format!("p=\"{expected}\"")), + "{direct:?}: {output}" + ); + } +} + +#[test] +fn outside_strokes_retain_the_existing_padding_rule() { + let mut snapshot = source(Some(true), 12); + snapshot + .nodes + .get_mut("input") + .unwrap() + .fields + .insert("strokeAlign".into(), Value::String("OUTSIDE".into())); + let output = render(&snapshot); + assert!(output.contains("p=\"12px\""), "{output}"); +} + +#[test] +fn missing_flag_reports_legacy_rendering_as_unverified_without_calling_it_false() { + for flag in [None, Some(false), Some(true)] { + let output = + generate_component(&source(flag, 12), "input", &CodegenOptions::default()).unwrap(); + let unknown = output + .diagnostics + .iter() + .find(|d| d.code == "DEVUP_CODEGEN_STROKE_LAYOUT_UNVERIFIED"); + assert_eq!( + unknown.is_some(), + flag.is_none(), + "{flag:?}: {:?}", + output.diagnostics + ); + if let Some(diagnostic) = unknown { + assert_eq!( + diagnostic.details.as_ref().unwrap()["verification"]["state"], + "unverified" + ); + } + } +} + +#[test] +fn explicit_free_layout_ignores_stale_true_flag_and_inferred_auto_mode() { + let mut explicit = source(Some(true), 12); + let fields = &mut explicit.nodes.get_mut("input").unwrap().fields; + fields.insert("layoutMode".into(), json!("NONE")); + fields.insert( + "inferredAutoLayout".into(), + json!({"layoutMode":"HORIZONTAL","strokesIncludedInLayout":true}), + ); + let mut absent = explicit.clone(); + absent + .nodes + .get_mut("input") + .unwrap() + .fields + .remove("strokesIncludedInLayout"); + absent.nodes.get_mut("input").unwrap().fields["inferredAutoLayout"] + .as_object_mut() + .unwrap() + .remove("strokesIncludedInLayout"); + assert_eq!(render(&explicit), render(&absent)); + assert!(render(&explicit).contains("p=\"11px\"")); + let output = generate_component(&absent, "input", &CodegenOptions::default()).unwrap(); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_STROKE_LAYOUT_UNVERIFIED"), + "free layout has no applicable flag contract" + ); +} + +#[test] +fn actual_auto_modes_honor_the_captured_true_flag() { + for mode in ["HORIZONTAL", "VERTICAL", "GRID"] { + let mut snapshot = source(Some(true), 12); + snapshot + .nodes + .get_mut("input") + .unwrap() + .fields + .insert("layoutMode".into(), json!(mode)); + assert!(render(&snapshot).contains("p=\"12px\""), "{mode}"); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/unnamed_state_axis.rs b/crates/devup-mcp-devup-ui/tests/unnamed_state_axis.rs new file mode 100644 index 00000000..3b545c66 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/unnamed_state_axis.rs @@ -0,0 +1,191 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; +fn fixture() -> Snapshot { + serde_json::from_str(include_str!( + "fixtures/dropdown-option-pure-state-axis.json" + )) + .unwrap() +} +fn render(s: &Snapshot) -> devup_mcp_devup_ui::codegen::CodegenOutput { + generate_component(s, "45:2034", &CodegenOptions::default()).unwrap() +} +#[test] +fn actual_dropdown_unnamed_axis_becomes_root_hover_active_without_runtime_prop() { + let source = fixture(); + let output = render(&source); + assert!(output.tsx.contains("_hover={{"), "{}", output.tsx); + assert!(output.tsx.contains("_active={{"), "{}", output.tsx); + assert!(!output.tsx.contains("property1:"), "{}", output.tsx); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_UNNAMED_STATE_AXIS") + ); + if let Ok(file) = std::env::var("DEVUP_DROPDOWN_STATE_PROBE") { + std::fs::write(file, &output.tsx).unwrap(); + } +} +#[test] +fn business_name_or_missing_hover_remains_explicit_runtime_state() { + for name in ["status", "selection"] { + let mut s = fixture(); + rename_axis(&mut s, name); + let o = render(&s); + assert!(!o.tsx.contains("_hover={{")); + assert!(o.tsx.contains(&format!("{name}:")), "{}", o.tsx); + } + let mut s = fixture(); + let field = axis(&s); + s.nodes.get_mut("45:2034").unwrap().fields["componentPropertyDefinitions"][&field]["variantOptions"] = + json!(["default", "active"]); + s.nodes.get_mut("45:2034").unwrap().fields["childrenIds"] = json!(["45:2033", "45:2031"]); + let o = render(&s); + assert!(!o.tsx.contains("_active={{")); + assert!(o.tsx.contains("property1:")); +} +#[test] +fn mixed_mobile_axis_stays_explicit_with_actionable_diagnostic() { + let mut s = fixture(); + rename_axis(&mut s, "effect"); + s.nodes.get_mut("45:2034").unwrap().fields["componentPropertyDefinitions"]["effect"]["variantOptions"] = + json!(["default", "hover", "active", "mobile"]); + let o = render(&s); + assert!(!o.tsx.contains("_hover={{")); + assert!(o.tsx.contains("effect:")); + assert!( + o.diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_MIXED_STATE_AXIS") + ); +} +#[test] +fn unknown_or_descendant_changing_state_is_not_silently_inferred() { + for case in [ + "missing-state", + "descendant-content", + "descendant-style", + "topology", + ] { + let mut s = fixture(); + let children = s.nodes["45:2032"].fields["childrenIds"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_owned()) + .collect::>(); + match case { + "missing-state" => { + s.nodes.get_mut("45:2034").unwrap().fields["childrenIds"] = + json!(["45:2033", "45:2031"]); + } + "descendant-content" => { + s.nodes + .get_mut(&children[0]) + .unwrap() + .fields + .insert("characters".into(), json!("different")); + s.nodes + .get_mut(&children[0]) + .unwrap() + .fields + .remove("styledTextSegments"); + } + "descendant-style" => { + s.nodes + .get_mut(&children[0]) + .unwrap() + .fields + .insert("fontSize".into(), json!(27)); + s.nodes + .get_mut(&children[0]) + .unwrap() + .fields + .remove("styledTextSegments"); + } + _ => { + s.nodes + .get_mut("45:2032") + .unwrap() + .fields + .insert("childrenIds".into(), json!([])); + } + } + let o = render(&s); + assert!(!o.tsx.contains("_hover={{"), "{case}: {}", o.tsx); + assert!(o.tsx.contains("property1:"), "{case}: {}", o.tsx); + } +} +fn axis(s: &Snapshot) -> String { + s.nodes["45:2034"].fields["componentPropertyDefinitions"] + .as_object() + .unwrap() + .keys() + .find(|k| { + s.nodes["45:2034"].fields["componentPropertyDefinitions"][*k]["type"] == "VARIANT" + }) + .unwrap() + .clone() +} +fn rename_axis(s: &mut Snapshot, name: &str) { + let key = axis(s); + let value = s.nodes.get_mut("45:2034").unwrap().fields["componentPropertyDefinitions"] + .as_object_mut() + .unwrap() + .remove(&key) + .unwrap(); + s.nodes.get_mut("45:2034").unwrap().fields["componentPropertyDefinitions"][name] = value; + for id in ["45:2033", "45:2032", "45:2031"] { + let value = s.nodes.get_mut(id).unwrap().fields["variantProperties"] + .as_object_mut() + .unwrap() + .remove(&key) + .unwrap(); + s.nodes.get_mut(id).unwrap().fields["variantProperties"][name] = value; + } +} + +#[test] +fn actual_list_filter_mobile_layout_remains_a_runtime_variant() { + let source: Snapshot = + serde_json::from_str(include_str!("fixtures/list-filter-mixed-state-axis.json")).unwrap(); + let output = generate_component(&source, "73:6571", &CodegenOptions::default()).unwrap(); + assert!(output.tsx.contains("effect:")); + assert!(output.tsx.contains("mobile")); + assert!(!output.tsx.contains("_hover={{")); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_MIXED_STATE_AXIS") + ); +} +#[test] +fn existing_explicit_effect_axis_keeps_its_selectors() { + let mut source = fixture(); + rename_axis(&mut source, "effect"); + let output = render(&source); + assert!(output.tsx.contains("_hover={{") && output.tsx.contains("_active={{")); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_UNNAMED_STATE_AXIS") + ); +} +#[test] +fn multiple_generic_candidates_are_not_resolved_by_guessing() { + let mut source = fixture(); + let key = axis(&source); + let definition = source.nodes["45:2034"].fields["componentPropertyDefinitions"][&key].clone(); + source.nodes.get_mut("45:2034").unwrap().fields["componentPropertyDefinitions"]["Property 2"] = + definition; + for id in ["45:2033", "45:2032", "45:2031"] { + let value = source.nodes[id].fields["variantProperties"][&key].clone(); + source.nodes.get_mut(id).unwrap().fields["variantProperties"]["Property 2"] = value; + } + let output = render(&source); + assert!(!output.tsx.contains("_hover={{")); + assert!(output.tsx.contains("property1:") && output.tsx.contains("Property2:")); +} diff --git a/crates/devup-mcp-devup-ui/tests/variant_structural_branches.rs b/crates/devup-mcp-devup-ui/tests/variant_structural_branches.rs new file mode 100644 index 00000000..074cf966 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/variant_structural_branches.rs @@ -0,0 +1,149 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, CodegenOutput, generate_component}; +use devup_mcp_figma::{FidelityImpact, Snapshot}; +use serde_json::json; + +fn generate(source: &str) -> CodegenOutput { + let snapshot: Snapshot = serde_json::from_str(source).unwrap(); + generate_component(&snapshot, &snapshot.roots[0], &CodegenOptions::default()).unwrap() +} + +fn emitted(output: &CodegenOutput, id: &str) -> bool { + output.source_map.entries.iter().any(|entry| { + entry.node_id.as_deref() == Some(id) + && entry.property.is_none() + && entry.resolution == "node" + }) +} + +#[test] +fn actual_footer_keeps_mobile_column_and_both_visible_logo_instances() { + let output = generate(include_str!("fixtures/chondrogen_footer_set.json")); + assert!(!output.tsx.contains("{/*"), "{}", output.tsx); + for id in ["30:1119", "43:1187", "41:901", "43:1189"] { + assert!( + emitted(&output, id), + "missing captured node {id}: {}", + output.tsx + ); + } + assert_eq!(output.tsx.matches("as=\"footer\"").count(), 2); + assert!( + output.tsx.contains("[\"none\",null,null,null,\"flex\"]"), + "{}", + output.tsx + ); + assert!( + output.tsx.contains("[\"flex\",null,null,null,\"none\"]"), + "{}", + output.tsx + ); + let mobile = output + .source_map + .entries + .iter() + .find(|e| e.node_id.as_deref() == Some("43:1188") && e.resolution == "node") + .unwrap() + .generated_range + .as_ref() + .unwrap(); + assert!(output.tsx[mobile.start..mobile.end].contains(">(); + let mut ids = uncertain + .iter() + .map(|d| { + assert_eq!(d.code, "DEVUP_CODEGEN_STROKE_LAYOUT_UNVERIFIED"); + assert_eq!(d.property.as_deref(), Some("strokesIncludedInLayout")); + d.node_id.as_deref().unwrap() + }) + .collect::>(); + ids.sort(); + assert_eq!(ids, ["3879:35526", "3879:35533", "3879:35565"]); + for id in &ids { + assert!( + !payload.snapshot.nodes[*id] + .fields + .contains_key("strokesIncludedInLayout") + ); + } + assert_eq!(output.fidelity_report.impacts.approximated, 3); assert!(!output.fidelity_report.strict_compatible()); let fill_issue = output .diagnostics @@ -228,10 +252,28 @@ fn actual_wquw_151_screen_preserves_children_tokens_and_typography() { insta::assert_snapshot!("wquw_151_proofread_devup_ui", output.tsx); insta::assert_snapshot!("wquw_151_proofread_devup_ui_embedded", embedded.tsx); insta::assert_json_snapshot!("wquw_151_proofread_used_tokens", output.used_tokens); - insta::assert_json_snapshot!("wquw_151_proofread_diagnostics", json!(output.diagnostics)); + let mut diagnostics = json!(output.diagnostics); + stable_diagnostic_numbers(&mut diagnostics); + insta::assert_json_snapshot!("wquw_151_proofread_diagnostics", diagnostics); insta::assert_json_snapshot!("wquw_151_proofread_source_map", output.source_map); } +// Dependency feature unification can enable serde_json's float_roundtrip +// parser. Keep evidence snapshots stable across both parsers at subpixel +// precision; production diagnostics and all direct numeric assertions stay raw. +fn stable_diagnostic_numbers(value: &mut Value) { + match value { + Value::Array(values) => values.iter_mut().for_each(stable_diagnostic_numbers), + Value::Object(values) => values.values_mut().for_each(stable_diagnostic_numbers), + Value::Number(number) if number.is_f64() => { + if let Some(number) = number.as_f64() { + *value = json!((number * 1e12).round() / 1e12); + } + } + _ => {} + } +} + fn component_root_opening(tsx: &str) -> &str { let start = tsx.find(" <").expect("component root"); let end = tsx[start..].find('>').expect("root opening close") + start; diff --git a/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs b/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs index 4a4b1a42..ee1aaaa3 100644 --- a/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +++ b/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs @@ -255,3 +255,16 @@ fn resource_ids(value: Option<&Value>) -> BTreeSet { .map(str::to_owned) .collect() } + +#[test] +fn actual_header_hit_area_and_smaller_rotated_icon_remain_distinct() { + let fixture = frame_fixture("3879:36108"); + let outer = &fixture.snapshot.nodes["I3879:36111;65:3062"].fields; + let inner = &fixture.snapshot.nodes["I3879:36111;65:3063"].fields; + assert_eq!(outer["width"], 50); + assert_eq!(outer["layoutSizingVertical"], "FILL"); + assert_eq!(outer["isAsset"], false); + assert_eq!(inner["width"], 32); + assert_eq!(inner["height"], 32); + assert_eq!(inner["rotation"], -180); +} diff --git a/crates/devup-mcp-devup-ui/tests/wrapped_ascii_whitespace.rs b/crates/devup-mcp-devup-ui/tests/wrapped_ascii_whitespace.rs new file mode 100644 index 00000000..42cf3dd3 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/wrapped_ascii_whitespace.rs @@ -0,0 +1,140 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +fn source(text: &str) -> Snapshot { + serde_json::from_value(json!({"fileKey":"wrap-spaces","roots":["text"],"nodes":{"text":{"id":"text","type":"TEXT","fields":{"characters":text,"textAutoResize":"HEIGHT","layoutSizingHorizontal":"FIXED","width":220,"fontSize":16,"textTruncation":"DISABLED"}}},"diagnostics":[]})).unwrap() +} + +#[test] +fn fixed_inline_text_keeps_repeated_and_edge_spaces_while_soft_wrapping() { + for text in [ + "Company · Address", + " leading space", + "trailing space ", + "A wrapped phrase whose words retain their captured spaces", + ] { + let snapshot = source(text); + let output = generate_component(&snapshot, "text", &CodegenOptions::default()).unwrap(); + assert!( + output.tsx.contains("whiteSpace=\"pre-wrap\""), + "{}", + output.tsx + ); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE") + ); + assert!( + output + .source_map + .entries + .iter() + .any( + |e| e.generated_property.as_deref() == Some("whiteSpace=\"pre-wrap\"") + && e.property.as_deref() == Some("characters") + && e.resolution == "derived-wrapped-ascii-whitespace" + ) + ); + } +} + +#[test] +fn captured_desktop_and_mobile_footer_copy_preserves_actual_ascii_spacing() { + let snapshot: Snapshot = serde_json::from_str(include_str!( + "fixtures/chondrogen_footer_ascii_spacing.json" + )) + .unwrap(); + for id in &snapshot.roots { + assert!( + snapshot.nodes[id] + .typed_view() + .string("characters") + .unwrap() + .contains(" ") + ); + let output = generate_component(&snapshot, id, &CodegenOptions::default()).unwrap(); + assert!( + output.tsx.contains("whiteSpace=\"pre-wrap\""), + "{}", + output.tsx + ); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE") + ); + assert!( + output + .source_map + .entries + .iter() + .any(|e| e.node_id.as_deref() == Some(id) + && e.resolution == "derived-wrapped-ascii-whitespace") + ); + } +} + +#[test] +fn ordinary_single_spaces_and_unproven_widths_keep_existing_policy() { + for (case, mut snapshot) in [ + source("A normally wrapped phrase"), + source("Repeated spaces"), + source("Repeated spaces"), + ] + .into_iter() + .enumerate() + { + let f = &mut snapshot.nodes.get_mut("text").unwrap().fields; + if case == 1 { + f.remove("width"); + } else if case == 2 { + f.insert("layoutSizingHorizontal".into(), json!("HUG")); + } + let output = generate_component(&snapshot, "text", &CodegenOptions::default()).unwrap(); + assert!( + !output.tsx.contains("whiteSpace=\"pre-wrap\""), + "{}", + output.tsx + ); + } +} + +#[test] +fn clamp_list_and_tab_cases_keep_existing_loss_reporting() { + for (key, value) in [ + ("maxLines", json!(1)), + ("maxLines", json!(2)), + ( + "styledTextSegments", + json!([{"characters":"A B","listOptions":{"type":"UNORDERED"}}]), + ), + ("characters", json!("A\tB")), + ] { + let mut snapshot = source("A B"); + snapshot + .nodes + .get_mut("text") + .unwrap() + .fields + .insert(key.into(), value); + let output = generate_component(&snapshot, "text", &CodegenOptions::default()).unwrap(); + assert!( + !output.tsx.contains("whiteSpace=\"pre-wrap\""), + "{}", + output.tsx + ); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE") + ); + if key == "maxLines" && snapshot.nodes["text"].fields[key] == 1 { + assert!(output.tsx.contains("whiteSpace=\"nowrap\"")); + } + } +} diff --git a/crates/devup-mcp-figma/src/asset_render_info.rs b/crates/devup-mcp-figma/src/asset_render_info.rs index 8f7924a2..46b5397e 100644 --- a/crates/devup-mcp-figma/src/asset_render_info.rs +++ b/crates/devup-mcp-figma/src/asset_render_info.rs @@ -25,6 +25,9 @@ fn size(width: Option, height: Option) -> Value { } fn filename_part(name: &str) -> String { + if name.len() >= 24 && name.chars().all(|c| c.is_ascii_hexdigit()) { + return "image".into(); + } let decoded = name .replace("&", "&") .replace("<", "<") @@ -577,21 +580,9 @@ pub fn suggested_asset_filename(snapshot: &Snapshot, asset: &AssetManifestEntry) } } parts.reverse(); - let identity = hash(asset.asset_id.as_bytes()); Some(format!( - "{}-{}-{}.{}", + "{}.{}", parts.join("-"), - &identity[..16], - if asset.render_mode.is_original() - || asset - .render_info - .as_ref() - .is_some_and(|i| i["representation"] == "original-fill") - { - "fill" - } else { - "node" - }, asset.format.unwrap_or(AssetFormat::Png).extension() )) } diff --git a/crates/devup-mcp-figma/src/assets.rs b/crates/devup-mcp-figma/src/assets.rs index eb402413..f4c17457 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -1,3 +1,6 @@ // Source sections share this module to preserve privacy and test names. include!("assets/asset_manifest.rs"); +include!("assets/scope_diagnostics.rs"); include!("assets/asset_export_from_result.rs"); + +include!("assets/live_layout.rs"); diff --git a/crates/devup-mcp-figma/src/assets/asset_manifest.rs b/crates/devup-mcp-figma/src/assets/asset_manifest.rs index 963e2a66..8879d1cf 100644 --- a/crates/devup-mcp-figma/src/assets/asset_manifest.rs +++ b/crates/devup-mcp-figma/src/assets/asset_manifest.rs @@ -486,6 +486,8 @@ fn compute_asset_node(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Opti return compute_leaf_asset(node, nested); } + if single_child_layout_must_remain_live(snapshot, node) { return None; } + if child_ids.len() == 1 { if ["paddingLeft", "paddingRight", "paddingTop", "paddingBottom"] .into_iter() @@ -797,7 +799,7 @@ pub fn validate_asset_requests( .find(|asset| asset.asset_id == request.asset_id) .or(direct.as_ref()) else { - return Err(invalid("The requested asset is not in the snapshot.")); + return Err(asset_scope_error(snapshot, &request.asset_id)); }; if let Some(node) = snapshot.nodes.get(&request.node_id) { if !request.render_mode.is_original() @@ -823,9 +825,7 @@ pub fn validate_asset_requests( } } if !in_scope(snapshot, &request.node_id) { - return Err(invalid( - "The requested node is outside the acquired snapshot scope.", - )); + return Err(asset_scope_error(snapshot, &request.node_id)); } if candidate.node_id != request.node_id || candidate.field != request.field @@ -918,9 +918,7 @@ pub fn resolve_asset_selections( .iter() .find(|asset| asset.asset_id == selection.asset_id) .or(direct.as_ref()) - .ok_or_else(|| { - invalid("The selected asset or nodeId is not in the acquired snapshot scope.") - })?; + .ok_or_else(|| asset_scope_error(snapshot, &selection.asset_id))?; // nodeId is a convenient target, not permission to flatten a hero's // live heading and controls into its background photograph. let source_fill = (selection.render_mode == AssetRenderMode::OriginalSource diff --git a/crates/devup-mcp-figma/src/assets/live_layout.rs b/crates/devup-mcp-figma/src/assets/live_layout.rs new file mode 100644 index 00000000..acbab248 --- /dev/null +++ b/crates/devup-mcp-figma/src/assets/live_layout.rs @@ -0,0 +1,36 @@ +/// A one-child layout is not a scalable picture when its child has independent +/// captured responsive sizing. Keep that existing Figma container in the DOM so +/// a FILL header row cannot scale its fixed-size logo as an SVG viewport. +pub fn single_child_layout_must_remain_live(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + if !matches!(node.node_type.as_str(), "FRAME" | "INSTANCE" | "COMPONENT") + || view.bool("isAsset") == Some(true) + || !matches!( + view.string("layoutMode").or_else(|| view + .value("inferredAutoLayout")? + .get("layoutMode")? + .as_str()), + Some("HORIZONTAL" | "VERTICAL") + ) + { + return false; + } + let children = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") != Some(false)) + .collect::>(); + let [child] = children.as_slice() else { + return false; + }; + let cv = child.typed_view(); + if cv.string("layoutPositioning") == Some("ABSOLUTE") { + return false; + } + [ + ("layoutSizingHorizontal", "layoutSizingHorizontal"), + ("layoutSizingVertical", "layoutSizingVertical"), + ] + .iter() + .any(|(outer, inner)| view.string(outer) == Some("FILL") && cv.string(inner) == Some("FIXED")) +} diff --git a/crates/devup-mcp-figma/src/assets/scope_diagnostics.rs b/crates/devup-mcp-figma/src/assets/scope_diagnostics.rs new file mode 100644 index 00000000..f4faf070 --- /dev/null +++ b/crates/devup-mcp-figma/src/assets/scope_diagnostics.rs @@ -0,0 +1,20 @@ +/// A rejected selection describes the acquired scope, not whether Figma has +/// the node. Section indexes and flattened captures can omit real descendants. +fn asset_scope_error(snapshot: &Snapshot, requested: &str) -> DevupError { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "The selected asset or nodeId is not in the acquired snapshot scope. Select its containing screen, or acquire the explicit node before exporting it.", + false, + json!({ + "reason": "asset-outside-acquired-scope", + "requestedAssetId": requested, + "acquiredRootIds": snapshot.roots, + "acquiredNodeCount": snapshot.nodes.len(), + "nodeExistence": "unverified", + "recovery": { + "tool": "devup_figma_export", + "how": "Use the original Figma URL with the exact screen frameIds containing this node, or a URL selecting the node itself. Read assetManifest from that acquisition and retry with its assetId. A Section index is not a captured screen subtree. Do not reuse an artifact that omitted the node or invent a nearby node ID." + } + }), + ) +} diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index 461741b5..8986752a 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -97,7 +97,8 @@ pub use assets::{ AssetManifestEntry, AssetRenderMode, AssetRequest, AssetSelection, AssetStatus, AssetVariant, MAX_ASSET_BYTES, PNG_EXPORT_FIELD, SVG_EXPORT_FIELD, asset_exclusion_reason, asset_export_from_result, discover_asset_manifest, discover_image_fill_assets, - discover_web_ready_assets, exported_asset_from_bytes, resolve_asset_selections, source_kind_of, - validate_asset_options, validate_asset_requests, + discover_web_ready_assets, exported_asset_from_bytes, resolve_asset_selections, + single_child_layout_must_remain_live, source_kind_of, validate_asset_options, + validate_asset_requests, }; pub use original_image::{OriginalImage, original_image_from_result}; diff --git a/crates/devup-mcp-figma/src/plugin_api_manifest.json b/crates/devup-mcp-figma/src/plugin_api_manifest.json index bda91500..1e8c1580 100644 --- a/crates/devup-mcp-figma/src/plugin_api_manifest.json +++ b/crates/devup-mcp-figma/src/plugin_api_manifest.json @@ -1,7 +1,7 @@ [ "absoluteBoundingBox", "absoluteRenderBounds", "arcData", "backgroundStyleId", "blendMode", "bottomLeftRadius", "bottomRightRadius", "boundVariables", "characters", "clipsContent", "componentProperties", "componentPropertyDefinitions", - "componentPropertyReferences", "constraints", "cornerRadius", "counterAxisAlignItems", "dashPattern", "defaultVariant", + "componentPropertyReferences", "constraints", "cornerRadius", "counterAxisAlignContent", "counterAxisAlignItems", "counterAxisSpacing", "dashPattern", "defaultVariant", "effectStyleId", "effects", "fillStyleId", "fills", "fontName", "fontSize", "gridColumnAnchorIndex", "gridColumnCount", "gridColumnGap", "gridColumnSizes", "gridColumnSpan", "gridRowAnchorIndex", "gridRowCount", "gridRowGap", "gridRowSizes", "gridRowSpan", "gridStyleId", "height", @@ -9,8 +9,8 @@ "layoutPositioning", "layoutSizingHorizontal", "layoutSizingVertical", "letterSpacing", "lineHeight", "maxHeight", "maxLines", "maxWidth", "minHeight", "minWidth", "name", "opacity", "overflowDirection", "overrides", "paddingBottom", "paddingLeft", "paddingRight", "paddingTop", "primaryAxisAlignItems", "reactions", - "rotation", "strokeAlign", "strokeBottomWeight", "strokeLeftWeight", "strokeRightWeight", "strokeStyleId", - "strokeTopWeight", "strokeWeight", "strokes", "targetAspectRatio", "textAlignHorizontal", "textAlignVertical", + "relativeTransform", "rotation", "strokeAlign", "strokeBottomWeight", "strokeLeftWeight", "strokeRightWeight", "strokeStyleId", + "strokeTopWeight", "strokeWeight", "strokes", "strokesIncludedInLayout", "targetAspectRatio", "textAlignHorizontal", "textAlignVertical", "textAutoResize", "textCase", "textDecoration", "textStyleId", "textTruncation", "topLeftRadius", "topRightRadius", "variantProperties", "visible", "width", "x", "y" ] diff --git a/crates/devup-mcp-figma/src/plugin_scripts.rs b/crates/devup-mcp-figma/src/plugin_scripts.rs index cc75b46c..e290ca48 100644 --- a/crates/devup-mcp-figma/src/plugin_scripts.rs +++ b/crates/devup-mcp-figma/src/plugin_scripts.rs @@ -1,5 +1,5 @@ pub const PLUGIN_SCRIPTS_HASH: &str = - "a5e954ac7dea2bf7b253b558b8821439181cf56477c0a293fa2689e18131edc0"; + "65a2275bcca2589f0381f2ef1c4b529a6eff43282703996e68376314a4a01850"; pub const PLUGIN_PROTOCOL: u32 = 1; /// The scripts of [`PLUGIN_SCRIPTS_HASH`] as one evaluable expression. The bridge @@ -95,7 +95,7 @@ mod tests { ), ( include_bytes!("plugin_api_manifest.json").as_slice(), - "a171365479755c4cd31a0e3759afe52ae8645da1a2a0446a75568f0c96c78074", + "182f925e84c26d1763174d6ed7574948a87762fa0731e00d2b1e0d05a54ee5c6", ), ( include_bytes!("text_segment_manifest.json").as_slice(), @@ -103,7 +103,7 @@ mod tests { ), ( include_bytes!("plugin_scripts_bundle.js").as_slice(), - "57a4c256d1001820eaddcd4aec52460ccd30d07b8b6f9f5c5ad1977bc9bf050d", + "56a0fed0dc5e0fe31ce8133de5f1e17409afe57b98d28161af4a1b7ce4bcef10", ), ] { let actual = Sha256::digest(source) diff --git a/crates/devup-mcp-figma/src/plugin_scripts_bundle.js b/crates/devup-mcp-figma/src/plugin_scripts_bundle.js index c5f59248..77e966ab 100644 --- a/crates/devup-mcp-figma/src/plugin_scripts_bundle.js +++ b/crates/devup-mcp-figma/src/plugin_scripts_bundle.js @@ -1149,7 +1149,7 @@ const envelopeRootId = p.nodeId; const manifest = [ "absoluteBoundingBox", "absoluteRenderBounds", "arcData", "backgroundStyleId", "blendMode", "bottomLeftRadius", "bottomRightRadius", "boundVariables", "characters", "clipsContent", "componentProperties", "componentPropertyDefinitions", - "componentPropertyReferences", "constraints", "cornerRadius", "counterAxisAlignItems", "dashPattern", "defaultVariant", + "componentPropertyReferences", "constraints", "cornerRadius", "counterAxisAlignContent", "counterAxisAlignItems", "counterAxisSpacing", "dashPattern", "defaultVariant", "effectStyleId", "effects", "fillStyleId", "fills", "fontName", "fontSize", "gridColumnAnchorIndex", "gridColumnCount", "gridColumnGap", "gridColumnSizes", "gridColumnSpan", "gridRowAnchorIndex", "gridRowCount", "gridRowGap", "gridRowSizes", "gridRowSpan", "gridStyleId", "height", @@ -1157,8 +1157,8 @@ const manifest = [ "layoutPositioning", "layoutSizingHorizontal", "layoutSizingVertical", "letterSpacing", "lineHeight", "maxHeight", "maxLines", "maxWidth", "minHeight", "minWidth", "name", "opacity", "overflowDirection", "overrides", "paddingBottom", "paddingLeft", "paddingRight", "paddingTop", "primaryAxisAlignItems", "reactions", - "rotation", "strokeAlign", "strokeBottomWeight", "strokeLeftWeight", "strokeRightWeight", "strokeStyleId", - "strokeTopWeight", "strokeWeight", "strokes", "targetAspectRatio", "textAlignHorizontal", "textAlignVertical", + "relativeTransform", "rotation", "strokeAlign", "strokeBottomWeight", "strokeLeftWeight", "strokeRightWeight", "strokeStyleId", + "strokeTopWeight", "strokeWeight", "strokes", "strokesIncludedInLayout", "targetAspectRatio", "textAlignHorizontal", "textAlignVertical", "textAutoResize", "textCase", "textDecoration", "textStyleId", "textTruncation", "topLeftRadius", "topRightRadius", "variantProperties", "visible", "width", "x", "y" ] @@ -3470,7 +3470,7 @@ if (!root) throw new Error("DEVUP_NODE_NOT_FOUND"); const manifest = [ "absoluteBoundingBox", "absoluteRenderBounds", "arcData", "backgroundStyleId", "blendMode", "bottomLeftRadius", "bottomRightRadius", "boundVariables", "characters", "clipsContent", "componentProperties", "componentPropertyDefinitions", - "componentPropertyReferences", "constraints", "cornerRadius", "counterAxisAlignItems", "dashPattern", "defaultVariant", + "componentPropertyReferences", "constraints", "cornerRadius", "counterAxisAlignContent", "counterAxisAlignItems", "counterAxisSpacing", "dashPattern", "defaultVariant", "effectStyleId", "effects", "fillStyleId", "fills", "fontName", "fontSize", "gridColumnAnchorIndex", "gridColumnCount", "gridColumnGap", "gridColumnSizes", "gridColumnSpan", "gridRowAnchorIndex", "gridRowCount", "gridRowGap", "gridRowSizes", "gridRowSpan", "gridStyleId", "height", @@ -3478,8 +3478,8 @@ const manifest = [ "layoutPositioning", "layoutSizingHorizontal", "layoutSizingVertical", "letterSpacing", "lineHeight", "maxHeight", "maxLines", "maxWidth", "minHeight", "minWidth", "name", "opacity", "overflowDirection", "overrides", "paddingBottom", "paddingLeft", "paddingRight", "paddingTop", "primaryAxisAlignItems", "reactions", - "rotation", "strokeAlign", "strokeBottomWeight", "strokeLeftWeight", "strokeRightWeight", "strokeStyleId", - "strokeTopWeight", "strokeWeight", "strokes", "targetAspectRatio", "textAlignHorizontal", "textAlignVertical", + "relativeTransform", "rotation", "strokeAlign", "strokeBottomWeight", "strokeLeftWeight", "strokeRightWeight", "strokeStyleId", + "strokeTopWeight", "strokeWeight", "strokes", "strokesIncludedInLayout", "targetAspectRatio", "textAlignHorizontal", "textAlignVertical", "textAutoResize", "textCase", "textDecoration", "textStyleId", "textTruncation", "topLeftRadius", "topRightRadius", "variantProperties", "visible", "width", "x", "y" ] diff --git a/crates/devup-mcp-figma/src/snapshot.rs b/crates/devup-mcp-figma/src/snapshot.rs index f2aae71a..1315acc2 100644 --- a/crates/devup-mcp-figma/src/snapshot.rs +++ b/crates/devup-mcp-figma/src/snapshot.rs @@ -188,6 +188,10 @@ impl<'a> TypedNode<'a> { self.value(field).and_then(Value::as_f64) } + pub fn field_error(&self, field: &str) -> Option<&'a str> { + self.node.field_errors.get(field).map(String::as_str) + } + pub fn bool(&self, field: &str) -> Option { self.value(field).and_then(Value::as_bool) } diff --git a/crates/devup-mcp-figma/tests/asset_scope_diagnostics.rs b/crates/devup-mcp-figma/tests/asset_scope_diagnostics.rs new file mode 100644 index 00000000..39b762c6 --- /dev/null +++ b/crates/devup-mcp-figma/tests/asset_scope_diagnostics.rs @@ -0,0 +1,25 @@ +use devup_mcp_figma::{AssetSelection, ErrorCode, Snapshot, resolve_asset_selections}; +use serde_json::json; + +#[test] +fn missing_asset_reports_acquired_scope_without_claiming_node_absent_from_figma() { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"fixture", "version":null, "roots":["section"], + "nodes":{"section":{"id":"section","type":"SECTION", + "fields":{"name":"Products"},"extra":{},"fieldErrors":{}}}, + "diagnostics":[] + })) + .unwrap(); + let selection: AssetSelection = serde_json::from_value(json!({ + "assetId":"I81:11810;81:11808:node", "format":"png", + "scale":1, "renderMode":"node" + })) + .unwrap(); + let error = resolve_asset_selections(&snapshot, &[selection]).unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + assert!(!error.retryable); + assert_eq!(error.details["acquiredRootIds"], json!(["section"])); + assert_eq!(error.details["nodeExistence"], "unverified"); + assert_eq!(error.details["requestedAssetId"], "I81:11810;81:11808:node"); + assert_eq!(error.details["recovery"]["tool"], "devup_figma_export"); +} diff --git a/crates/devup-mcp-figma/tests/feedback_scripts.test.mjs b/crates/devup-mcp-figma/tests/feedback_scripts.test.mjs index ae0c5d9b..9771c08b 100644 --- a/crates/devup-mcp-figma/tests/feedback_scripts.test.mjs +++ b/crates/devup-mcp-figma/tests/feedback_scripts.test.mjs @@ -56,6 +56,16 @@ test('native SVG remains authoritative', async () => { assert.equal(result.exportDiagnostics.method, 'native'); }); for (const name of ['snapshot', 'fast_snapshot']) { + test(`${name} preserves true and false strokesIncludedInLayout including inferred layout`, async () => { + for (const flag of [true, false]) { + const root = { id: '1:1', type: 'FRAME', name: 'stroke-layout', children: [], + strokesIncludedInLayout: flag, inferredAutoLayout: { layoutMode: 'HORIZONTAL', strokesIncludedInLayout: flag } }; + const result = await script(name, { __DEVUP_NODE_ID__: '1:1', __DEVUP_ROOT_IDS__: ['1:1'], __DEVUP_SNAPSHOT__: {} })({ fileKey: 'fixture', getNodeByIdAsync: async () => root, variables: {} }); + const node = (result.snapshot ?? result).nodes.find(node => node.id === '1:1'); + assert.equal(node.fields.strokesIncludedInLayout, flag); + assert.equal(node.fields.inferredAutoLayout.strokesIncludedInLayout, flag); + } + }); test(`${name} acquires async component identities and preserves actual lookup failures`, async () => { const root = { id: '1:1', type: 'FRAME', name: 'screen', children: [] }; const main = { id: '10:1', name: 'Header', parent: { id: '10:0', type: 'COMPONENT_SET' } }; diff --git a/crates/devup-mcp-figma/tests/image_fill_render.rs b/crates/devup-mcp-figma/tests/image_fill_render.rs index fd0502b3..5ef627f9 100644 --- a/crates/devup-mcp-figma/tests/image_fill_render.rs +++ b/crates/devup-mcp-figma/tests/image_fill_render.rs @@ -341,9 +341,10 @@ fn duplicate_names_have_safe_stable_hierarchy_suggestions_and_enriched_measureme .iter() .map(|a| a.suggested_filename.clone().unwrap()) .collect::>(); - assert_ne!(names[0], names[1]); + // Suggestions describe meaning; the project registry resolves real content collisions. + assert_eq!(names[0], names[1]); for name in &names { - assert!(name.starts_with("section-con-rectangle-32-")); + assert!(name.starts_with("section-con-rectangle-32.")); assert!( name.bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.')) @@ -422,7 +423,7 @@ fn unicode_parent_names_remain_readable_and_cannot_introduce_write_paths() { let assets = discover_asset_manifest(&snapshot).assets; for asset in assets { let name = asset.suggested_filename.unwrap(); - assert!(name.starts_with("예시-화면-안전-rectangle-32-")); + assert!(name.starts_with("예시-화면-안전-rectangle-32.")); assert!(!name.contains(['/', '\\', ':'])); assert!(!name.contains("..")); assert!(name.len() < 160); @@ -459,7 +460,7 @@ fn direct_leaf_filename_uses_runtime_parent_names_when_snapshot_has_no_ancestors annotate_assets(&snapshot, &mut assets); let name = assets[0].suggested_filename.as_ref().unwrap(); assert!( - name.starts_with("예시-화면-gallery-con-rectangle-32-"), + name.starts_with("예시-화면-gallery-con-rectangle-32."), "{name}" ); assert!(!name.contains(['/', '\\', ':'])); diff --git a/crates/devup-mcp-figma/tests/manifest_covers_readers.rs b/crates/devup-mcp-figma/tests/manifest_covers_readers.rs index 43c02109..485cdce1 100644 --- a/crates/devup-mcp-figma/tests/manifest_covers_readers.rs +++ b/crates/devup-mcp-figma/tests/manifest_covers_readers.rs @@ -19,8 +19,10 @@ const NOT_NODE_FIELDS: &[&str] = &[ "childrenIds", "styledTextSegments", "mainComponentId", // Written by component_identity_helpers.js using getMainComponentAsync. + "mainComponentName", // Written by component_identity_helpers.js from the resolved main component. "mainComponentSetId", // Written by component_identity_helpers.js from the main component parent. "componentId", // REST snapshot identity alias; not a synchronous plugin node property. + "preserveRatio", // Legacy REST-only aspect lock; plugin snapshots use captured targetAspectRatio. "nodeScreenIds", // Computed by section_index.js from visited ancestry, not a Figma property. "navigationContentWidth", // Computed from visible child widths by explore.js/section_index.js; not a Figma property. // Envelope, pagination and probe records, not nodes. diff --git a/crates/devup-mcp/src/server/asset_guard.rs b/crates/devup-mcp/src/server/asset_guard.rs index 6fcd599c..3968ecb1 100644 --- a/crates/devup-mcp/src/server/asset_guard.rs +++ b/crates/devup-mcp/src/server/asset_guard.rs @@ -106,14 +106,40 @@ pub(super) fn coverage_violations(sources: &[(&str, &str)], expected: &[String]) .filter(|id| !present.contains(id.as_str())) .map(|id| Violation { rule: COVERAGE_RULE, - severity: Severity::Error, + severity: if present.is_empty() { Severity::Warning } else { Severity::Error }, byte_range: [0, 0], context: Map::from_iter([("nodeId".into(), json!(id))]), - message: format!("Captured section or asset {id} is missing from the supplied JSX identity coverage."), - suggestion: Some("Restore the generated section/image and its data-figma-node-id from the original export. Static identity coverage does not verify runtime visibility.".into()), + message: format!("Captured section or asset {id} has no verified source mapping in the supplied code."), + suggestion: Some("Supply the original export's elementMappings and file mapping; keep node metadata out of application JSX. Runtime visibility requires explicit semantic rendered targets.".into()), }).collect() } +pub(super) fn mapped_coverage_violations( + sources: &[(&str, &str)], + expected: &[String], + mappings: &[super::tools::SectionElementMapping], +) -> Vec { + if mappings.is_empty() { return coverage_violations(sources, expected); } + expected.iter().collect::>().into_iter().filter_map(|id| { + let found = mappings.iter().filter(|entry| entry.node_id == *id).any(|entry| { + let matching = sources.iter().filter(|(file, _)| entry.source_file.as_deref().map_or(sources.len() == 1, |mapped| mapped == *file)).collect::>(); + matching.len() == 1 && matching.first().is_some_and(|(_, source)| { + devup_mcp_devup_ui::provenance::attributes::opening_elements(source).get(entry.element_index).is_some_and(|opening| { + source[opening.start + 1..opening.end].split(|c: char| c.is_whitespace() || c == '/' || c == '>').next() == Some(entry.tag.as_str()) + }) + }) + }); + (!found).then(|| Violation { + rule: COVERAGE_RULE, + severity: Severity::Warning, + byte_range: [0, 0], + context: Map::from_iter([("nodeId".into(), json!(id)), ("verification".into(), json!("unverified"))]), + message: format!("Source structure for {id} cannot be established from its original file/element mapping."), + suggestion: Some("Update explicit source-file mappings after component extraction and verify semantic rendered targets. JSX order alone does not prove DOM identity.".into()), + }) + }).collect() +} + /// Expected visible sections and assets come from the captured tree, even if /// generation omitted them. Asset boundaries deliberately hide their internals. pub(super) fn export_coverage( @@ -121,7 +147,7 @@ pub(super) fn export_coverage( root: &str, output: &mut devup_mcp_devup_ui::codegen::CodegenOutput, inline_instances: bool, - annotate: bool, + _annotate: bool, ) -> serde_json::Value { let mut scoped = snapshot.clone(); scoped.roots = vec![root.into()]; @@ -170,39 +196,6 @@ pub(super) fn export_coverage( } pending.extend(view.child_ids().map(|child| (child, depth + 1))); } - if annotate { - devup_mcp_devup_ui::codegen::node_ids::annotate_node_ids(output, &wanted); - // Identity attributes are derived from captured IDs. Record that - // derivation so the final attribute audit can verify them too. - for attr in devup_mcp_devup_ui::provenance::attributes::generated_attributes(&output.tsx) { - if attr.name != "data-figma-node-id" { - continue; - } - let Some((_, value)) = attr.source.split_once('=') else { - continue; - }; - let id = value.trim().trim_matches('"'); - output - .source_map - .entries - .push(devup_mcp_devup_ui::provenance::ProvenanceEntry { - generated_range: Some(devup_mcp_devup_ui::provenance::GeneratedRange { - start: attr.start, - end: attr.end, - }), - calculation: None, - generated_property: Some(attr.source.clone()), - json_pointer: None, - node_id: Some(id.into()), - property: Some("id".into()), - variable_id: None, - style_id: None, - asset_id: None, - resolution: "exact".into(), - element_index: Some(attr.element), - }); - } - } let node_ids = wanted.into_iter().collect::>(); let mapped = output .source_map @@ -244,15 +237,18 @@ pub(super) fn export_coverage( ..Default::default() }); } - if annotate { - let asset_urls = asset_urls(&output.tsx) - .into_iter() - .map(|(url, _)| url) - .collect::>(); - json!({"nodeIds":node_ids,"assetUrls":asset_urls}) - } else { - serde_json::Value::Null + let asset_urls = asset_urls(&output.tsx).into_iter().map(|(url, _)| url).collect::>(); + let openings = devup_mcp_devup_ui::provenance::attributes::opening_elements(&output.tsx); + let mut mappings = Vec::new(); + for entry in &output.source_map.entries { + let (Some(id), Some(index)) = (entry.node_id.as_ref(), entry.element_index) else { continue; }; + if !node_ids.contains(id) { continue; } + let Some(opening) = openings.get(index) else { continue; }; + let tag = output.tsx[opening.start + 1..opening.end].split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or_default(); + let row = json!({"nodeId":id,"elementIndex":index,"tag":tag}); + if !mappings.contains(&row) { mappings.push(row); } } + json!({"nodeIds":node_ids,"assetUrls":asset_urls,"elementMappings":mappings,"identityStrategy":"source-map-sidecar","runtimeVerified":false}) } /// Extensions treated as files a page serves from `public`. @@ -511,7 +507,7 @@ mod tests { assert!(ids.contains(&"picture".into()), "{contract}"); assert!(!ids.contains(&"hiddenPicture".into())); assert!( - coverage_violations(&[("", &output.tsx)], &ids).is_empty(), + mapped_coverage_violations(&[("", &output.tsx)], &ids, &serde_json::from_value::>(contract["elementMappings"].clone()).unwrap()).is_empty(), "{}", output.tsx ); diff --git a/crates/devup-mcp/src/server/audit_plan.rs b/crates/devup-mcp/src/server/audit_plan.rs index 2b559e95..09d8bc4d 100644 --- a/crates/devup-mcp/src/server/audit_plan.rs +++ b/crates/devup-mcp/src/server/audit_plan.rs @@ -5,8 +5,8 @@ //! every section, card and asset, containment and clipped text between the //! widths, interaction states, and the view transition to the next URL. //! -//! A plan per URL, because a page comparison needs the viewport as tall as -//! that URL's frame, and because the work goes URL by URL. +//! A plan per URL keeps the workflow route-scoped. Frame height is reference +//! metadata; the actual browser window height is independent. // Source sections share this module to preserve privacy and test names. include!("audit_plan/planning_and_capture.rs"); diff --git a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs index a6f26544..abed101f 100644 --- a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs +++ b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs @@ -284,21 +284,18 @@ pub(super) fn url_plan( let mut missing = Vec::new(); let mut viewports = Vec::::new(); for (width, node_id, height) in &target.frames { - let height = height.map(|height| height.round() as u32); - let height = match height { - Some(height) if (1..=MAX_VIEWPORT_SIDE).contains(&height) => height, - Some(height) => { - notes.push(format!( - include_str!("../text/audit_plan-url_plan-a1031a26.md"), - MAX_VIEWPORT_SIDE = MAX_VIEWPORT_SIDE, - height = height, - node_id = node_id, - width = width - )); - DEFAULT_HEIGHT - } - None => DEFAULT_HEIGHT, - }; + // The design frame is not evidence of a browser window height. + // Keep its height in Figma reference metadata, not the CSS viewport. + if let Some(height) = height.filter(|value| *value > f64::from(MAX_VIEWPORT_SIDE)) { + notes.push(format!( + include_str!("../text/audit_plan-url_plan-a1031a26.md"), + MAX_VIEWPORT_SIDE = MAX_VIEWPORT_SIDE, + height = height, + node_id = node_id, + width = width + )); + } + let height = DEFAULT_HEIGHT; if !viewports.iter().any(|viewport| viewport.width == *width) { viewports.push(viewport(*width, height)); } @@ -333,6 +330,9 @@ pub(super) fn url_plan( check(json!({"kind": "sequence-order", "label": "numbered cards in reading order"})), check(json!({"kind": "section-layout", "label": "responsive sections and text columns"})), check(json!({"kind": "page-health", "label": "page health"})), + check( + json!({"kind": "markup", "label": "semantic landmarks, headings and image alternatives"}), + ), check(json!({"kind": "styles-applied", "label": "page styles delivered"})), check(json!({"kind": "no-low-contrast-text", "label": "visible text contrast"})), check(json!({"kind": "image-loaded", "label": "images and backgrounds loaded"})), diff --git a/crates/devup-mcp/src/server/audit_plan/prerequisites.rs b/crates/devup-mcp/src/server/audit_plan/prerequisites.rs index e4f18d33..b9799ef4 100644 --- a/crates/devup-mcp/src/server/audit_plan/prerequisites.rs +++ b/crates/devup-mcp/src/server/audit_plan/prerequisites.rs @@ -1,12 +1,50 @@ fn gate_source_anchors(built: &mut UrlPlan, app: &Path) { + // Consumer-declared mappings are evidence; DOM order or component names + // alone cannot establish which Figma node a rendered element represents. + let mapping_path = app.join(".devup/rendered-targets.json"); + let mappings = std::fs::read(&mapping_path).ok().and_then(|bytes| { + if bytes.len() > 1024 * 1024 { + return None; + } + serde_json::from_slice::(&bytes).ok() + }); + let route = built.plan.screens[0] + .route_path + .as_deref() + .unwrap_or_default(); + let selectors: BTreeMap = mappings + .as_ref() + .and_then(|document| document["routes"][route]["nodes"].as_object()) + .into_iter() + .flatten() + .filter_map(|(id, value)| { + let css = value.as_str()?; + (!id.is_empty() + && id.len() <= 300 + && !css.trim().is_empty() + && css.chars().count() <= 300) + .then(|| (id.clone(), css.to_owned())) + }) + .collect(); + if !selectors.is_empty() { + built.notes.push(format!("Explicit rendered targets from {} replace node-attribute selectors; missing or ambiguous DOM matches remain inconclusive.", mapping_path.display())); + } fn scan(path: &Path, text: &mut String) { - let Ok(entries) = std::fs::read_dir(path) else { return }; + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; for entry in entries.flatten() { let path = entry.path(); - if entry.file_type().is_ok_and(|kind| kind.is_symlink()) { continue; } - if path.is_dir() { scan(&path, text); } - else if matches!(path.extension().and_then(|ext| ext.to_str()), Some("tsx" | "jsx" | "ts" | "js")) - && let Ok(source) = std::fs::read_to_string(path) { + if entry.file_type().is_ok_and(|kind| kind.is_symlink()) { + continue; + } + if path.is_dir() { + scan(&path, text); + } else if matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("tsx" | "jsx" | "ts" | "js") + ) && let Ok(source) = std::fs::read_to_string(path) + { text.push_str(&source); text.push('\n'); } @@ -20,27 +58,77 @@ fn gate_source_anchors(built: &mut UrlPlan, app: &Path) { } let mut anchors = BTreeSet::new(); for tail in source.split("data-figma-node-id").skip(1) { - let (plural, tail) = tail.strip_prefix('s').map_or((false, tail), |tail| (true, tail)); - let Some(tail) = tail.trim_start().strip_prefix('=') else { continue }; + let (plural, tail) = tail + .strip_prefix('s') + .map_or((false, tail), |tail| (true, tail)); + let Some(tail) = tail.trim_start().strip_prefix('=') else { + continue; + }; let tail = tail.trim_start(); - let Some(quote @ ('\'' | '"')) = tail.chars().next() else { continue }; + let Some(quote @ ('\'' | '"')) = tail.chars().next() else { + continue; + }; if let Some(value) = tail[1..].split(quote).next() { for value in value.split_whitespace() { - let id = if plural { value.split_once(':').map_or(value, |(_, id)| id) } else { value }; + let id = if plural { + value.split_once(':').map_or(value, |(_, id)| id) + } else { + value + }; anchors.insert(id.to_owned()); } } } for check in &mut built.plan.screens[0].checks { - let anchored = [&check.subject, &check.container].into_iter().flatten() - .filter_map(|locator| locator.css.as_deref()).any(|css| css.contains("data-figma-node-id")); - let missing = [&check.subject, &check.container].into_iter().flatten().filter_map(|locator| locator.css.as_deref()).any(|css| { - css.split('"').enumerate().filter(|(index, _)| index % 2 == 1) - .all(|(_, id)| !anchors.contains(id) && !id.split_once(':').is_some_and(|(_, node)| anchors.contains(node))) + for locator in [&mut check.subject, &mut check.container] + .into_iter() + .flatten() + { + if let Some(css) = locator.css.as_deref() + && css.contains("data-figma-node-id") + && let Some(id) = css.split('"').nth(1) + && let Some(mapped) = selectors.get(id) + { + locator.css = Some(mapped.clone()); + } + } + if let Some(complete) = &mut check.completeness { + for id in complete.sections.iter().chain(&complete.images) { + if let Some(css) = selectors.get(id) { + complete.node_selectors.insert(id.clone(), css.clone()); + } + } + } + let anchored = [&check.subject, &check.container] + .into_iter() + .flatten() + .filter_map(|locator| locator.css.as_deref()) + .any(|css| css.contains("data-figma-node-id")); + let missing = [&check.subject, &check.container] + .into_iter() + .flatten() + .filter_map(|locator| locator.css.as_deref()) + .filter(|css| css.contains("data-figma-node-id")) + .any(|css| { + css.split('"') + .enumerate() + .filter(|(index, _)| index % 2 == 1) + .all(|(_, id)| { + !anchors.contains(id) + && !id + .split_once(':') + .is_some_and(|(_, node)| anchors.contains(node)) + }) + }); + let completeness_missing = check.completeness.as_ref().is_some_and(|complete| { + complete + .sections + .iter() + .chain(&complete.images) + .any(|id| !anchors.contains(id) && !complete.node_selectors.contains_key(id)) }); - let completeness_missing = check.completeness.as_ref().is_some_and(|complete| complete.sections.iter().chain(&complete.images).any(|id| !anchors.contains(id))); if (anchored && missing) || completeness_missing { - check.unavailable_reason = Some("not-run: source anchors absent; data-figma-node-id is off by default. Export with figmaNodeIds:true and preserve anchors, or use explicit role/testId locators.".into()); + check.unavailable_reason = Some("not-run: semantic rendered targets absent; map nodes explicitly in the app's .devup/rendered-targets.json. Keep application JSX free of Figma attributes. DOM order is not a verified mapping.".into()); check.subject = None; check.container = None; } @@ -49,17 +137,33 @@ fn gate_source_anchors(built: &mut UrlPlan, app: &Path) { fn dev_server_command(root: &Path, app: &str, base_url: &str) -> Value { let cwd = root.join(app); - let package = std::fs::read(cwd.join("package.json")).ok().and_then(|bytes| serde_json::from_slice::(&bytes).ok()); - let Some(script) = package.as_ref().and_then(|package| package["scripts"]["dev"].as_str()) else { + let package = std::fs::read(cwd.join("package.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + let Some(script) = package + .as_ref() + .and_then(|package| package["scripts"]["dev"].as_str()) + else { return json!({"available": false, "cwd": cwd, "reason": "The route app has no package.json dev script; no start command was guessed."}); }; - let manager = package.as_ref().and_then(|package| package["packageManager"].as_str()) - .and_then(|value| value.split('@').next()).filter(|manager| matches!(*manager, "bun" | "npm" | "pnpm" | "yarn")) + let manager = package + .as_ref() + .and_then(|package| package["packageManager"].as_str()) + .and_then(|value| value.split('@').next()) + .filter(|manager| matches!(*manager, "bun" | "npm" | "pnpm" | "yarn")) .unwrap_or_else(|| { - if ["bun.lock", "bun.lockb"].iter().any(|name| cwd.join(name).exists() || root.join(name).exists()) { "bun" } - else if cwd.join("pnpm-lock.yaml").exists() || root.join("pnpm-lock.yaml").exists() { "pnpm" } - else if cwd.join("yarn.lock").exists() || root.join("yarn.lock").exists() { "yarn" } - else { "npm" } + if ["bun.lock", "bun.lockb"] + .iter() + .any(|name| cwd.join(name).exists() || root.join(name).exists()) + { + "bun" + } else if cwd.join("pnpm-lock.yaml").exists() || root.join("pnpm-lock.yaml").exists() { + "pnpm" + } else if cwd.join("yarn.lock").exists() || root.join("yarn.lock").exists() { + "yarn" + } else { + "npm" + } }); json!({"available": true, "command": [manager, "run", "dev"], "cwd": cwd, "script": script, "windowsCommand": format!("cmd.exe /d /s /c \"{manager} run dev\""), diff --git a/crates/devup-mcp/src/server/audit_plan/static_params.rs b/crates/devup-mcp/src/server/audit_plan/static_params.rs index 446bf8f7..83a013de 100644 --- a/crates/devup-mcp/src/server/audit_plan/static_params.rs +++ b/crates/devup-mcp/src/server/audit_plan/static_params.rs @@ -204,17 +204,12 @@ pub(super) fn dynamic_urls(root: &str, route: &Value, links: &Value, sources: &S } } -const STRIP_PLUGIN: &[u8] = - include_bytes!("../../../../../harness/runtime-audit/strip-figma-node-ids.mjs"); -const STRIP_FILE: &str = "vite-plugin-strip-figma-node-ids.mjs"; - -/// Puts the plugin that strips the audit attributes from production builds -/// next to each app's vite.config, once, and says whether the config uses -/// it yet. +/// Compatibility response for older clients. Source is pure in every build; +/// auditing never writes plugins or changes a consumer's compiler configuration. fn production_strip( routes: &[&Value], - root: &str, - policy: &OutputPolicy, + _root: &str, + _policy: &OutputPolicy, ) -> Result, DevupError> { let apps = routes .iter() @@ -228,40 +223,11 @@ fn production_strip( .collect::>(); let mut out = Vec::new(); for app in apps { - let dir = Path::new(root).join(&app); - let file = dir.join(STRIP_FILE); - let written = if file.exists() { - false - } else { - let target = policy.resolve(&file.to_string_lossy())?; - let mut transaction = super::output::OutputTransaction::new(); - transaction.stage("stripPlugin", target, STRIP_PLUGIN)?; - transaction.commit()?; - true - }; - let config = [ - "vite.config.ts", - "vite.config.mts", - "vite.config.js", - "vite.config.mjs", - ] - .iter() - .map(|name| dir.join(name)) - .find(|path| path.is_file()); - let wired = config - .as_ref() - .and_then(|path| std::fs::read_to_string(path).ok()) - .is_some_and(|text| text.contains(STRIP_FILE)); out.push(json!({ "app": app, - "plugin": file.display().to_string(), - "written": written, - "viteConfig": config.map(|path| path.display().to_string()), - "wired": wired, - "add": format!("import stripFigmaNodeIds from './{STRIP_FILE}' // then plugins: [stripFigmaNodeIds(), vinext()]"), - "nextCli": "On the next CLI instead: compiler.reactRemoveProperties: process.env.DEVUP_AUDIT === '1' ? false : { properties: ['^data-figma-node-ids?$'] }", - "audit": "Audit against `bun run dev` or an audit build: DEVUP_AUDIT=1 bun run build.", - "verify": "After `bun run build`, `grep -rl data-figma-node-id dist` (or the app's build output) must find nothing.", + "needed": false, "written": false, "wired": false, + "audit": "Use source-map/element-mapping sidecars and explicit semantic runtime targets. Missing targets remain unverified.", + "verify": "Check application source and production output for absent provenance attributes and metadata comments; no debug exception or strip plugin is needed.", })); } Ok(out) diff --git a/crates/devup-mcp/src/server/audit_plan_tests.rs b/crates/devup-mcp/src/server/audit_plan_tests.rs index 1aaedc69..888f5261 100644 --- a/crates/devup-mcp/src/server/audit_plan_tests.rs +++ b/crates/devup-mcp/src/server/audit_plan_tests.rs @@ -140,7 +140,7 @@ fn absent_source_anchors_leave_design_checks_not_run() { && check .unavailable_reason .as_deref() - .is_some_and(|reason| reason.contains("anchors absent")) + .is_some_and(|reason| reason.contains("semantic rendered targets absent")) })); assert!( checks @@ -187,6 +187,99 @@ fn partial_source_anchors_do_not_enable_unmatched_nodes() { ); } +#[test] +fn explicit_rendered_targets_replace_only_declared_nodes() { + let project = Project::new("explicit-targets"); + project.write( + "src/app/page.tsx", + "export default function Page() { return
    }", + ); + project.write( + ".devup/rendered-targets.json", + r##"{"routes":{"/":{"nodes":{"10:1":"#page","10:12":"#hero"}}}}"##, + ); + let mut built = url_plan( + &home(), + "http://localhost:3000", + &sources(), + &VisualThresholds::default(), + ) + .unwrap(); + gate_source_anchors(&mut built, Path::new(project.root())); + let checks = &built.plan.screens[0].checks; + let hero = checks + .iter() + .find(|check| check.kind == "bounds" && check.node_id.as_deref() == Some("10:12")) + .unwrap(); + assert_eq!(hero.subject.as_ref().unwrap().css.as_deref(), Some("#hero")); + assert_eq!( + hero.container.as_ref().unwrap().css.as_deref(), + Some("#page") + ); + assert!(hero.unavailable_reason.is_none()); + assert!( + checks + .iter() + .any(|check| check.node_id.as_deref() == Some("10:131") + && check.unavailable_reason.is_some()) + ); + assert!( + checks + .iter() + .any(|check| check.kind == "markup" && check.unavailable_reason.is_none()) + ); + let complete = checks + .iter() + .find_map(|check| { + check + .completeness + .as_ref() + .filter(|complete| complete.sections.contains(&"10:12".into())) + }) + .unwrap(); + assert_eq!( + complete.node_selectors.get("10:12").map(String::as_str), + Some("#hero") + ); +} + +#[test] +fn mapped_subject_keeps_an_existing_attribute_container() { + let project = Project::new("mixed-targets"); + project.write( + "src/app/page.tsx", + "
    ", + ); + project.write( + ".devup/rendered-targets.json", + r##"{"routes":{"/":{"nodes":{"10:12":"#hero"}}}}"##, + ); + let mut built = url_plan( + &home(), + "http://localhost:3000", + &sources(), + &VisualThresholds::default(), + ) + .unwrap(); + gate_source_anchors(&mut built, Path::new(project.root())); + let hero = built.plan.screens[0] + .checks + .iter() + .find(|check| check.kind == "bounds" && check.node_id.as_deref() == Some("10:12")) + .unwrap(); + assert_eq!(hero.subject.as_ref().unwrap().css.as_deref(), Some("#hero")); + assert!( + hero.container + .as_ref() + .unwrap() + .css + .as_deref() + .unwrap() + .contains("10:1") + ); + assert!(hero.unavailable_reason.is_none()); +} + fn payload(target: &str, nodes: Value, assets: Value, reference: bool) -> CollectedPayload { let mut value = json!({ "target": {"fileKey": "audit", "nodeId": target}, @@ -476,7 +569,7 @@ fn a_two_url_audit_plan_checks_the_page_its_sections_assets_geometry_overflow_an "the Header section asks for the nav containment check" ); - // Every width, the drawn ones as tall as their frames. + // Actual browser window height is independent of both captured frame heights. let viewports = home .plan .viewports @@ -485,7 +578,7 @@ fn a_two_url_audit_plan_checks_the_page_its_sections_assets_geometry_overflow_an .collect::>(); assert_eq!( viewports, - [(1440, 1200), (1100, 900), (858, 900), (390, 2400)] + [(1440, 900), (1100, 900), (858, 900), (390, 900)] ); let figma = home.plan.screens[0].figma.as_ref().unwrap(); assert_eq!(figma.drawn_widths, [1440, 390]); diff --git a/crates/devup-mcp/src/server/behavior_contracts.rs b/crates/devup-mcp/src/server/behavior_contracts.rs new file mode 100644 index 00000000..46450f9a --- /dev/null +++ b/crates/devup-mcp/src/server/behavior_contracts.rs @@ -0,0 +1,206 @@ +//! Implementation handoff inferred from visual structure, kept outside executable TSX. +//! +//! A captured layout is evidence for a review, not proof that an animation or an +//! API binding exists. Every candidate retains that distinction through delivery. +use std::collections::{BTreeMap, BTreeSet}; + +use devup_mcp_figma::{RawNode, Snapshot}; +use serde_json::{Value, json}; + +#[path = "behavior_contracts/detect.rs"] +mod detect; +#[cfg(test)] +#[path = "behavior_contracts/tests.rs"] +mod tests; + +const MAX_NODES: usize = 8_192; +const MAX_CANDIDATES: usize = 128; +const GUIDE: &str = "devup://guide/usage#behavior-and-data-contracts"; + +fn string<'a>(node: &'a RawNode, field: &str) -> &'a str { + node.typed_view().string(field).unwrap_or("") +} + +fn has(value: &str, labels: &[&str]) -> bool { + let lower = value.to_lowercase(); + labels.iter().any(|label| lower.contains(label)) +} + +fn node_label(node: &RawNode) -> String { + format!("{} {}", string(node, "name"), string(node, "characters")) +} + +fn scoped_nodes<'a>(snapshot: &'a Snapshot, roots: &[String]) -> (Vec<&'a RawNode>, bool) { + let mut pending = roots.iter().map(String::as_str).collect::>(); + let mut visited = BTreeSet::new(); + let mut nodes = Vec::new(); + while let Some(id) = pending.pop() { + if !visited.insert(id) { + continue; + } + if nodes.len() == MAX_NODES { + return (nodes, true); + } + let Some(node) = snapshot.nodes.get(id) else { + continue; + }; + if node.typed_view().bool("visible") == Some(false) + || node.typed_view().number("opacity") == Some(0.0) + { + continue; + } + pending.extend(node.typed_view().child_ids()); + nodes.push(node); + } + (nodes, false) +} + +fn parents<'a>(nodes: &[&'a RawNode]) -> BTreeMap<&'a str, &'a RawNode> { + let mut parents = BTreeMap::new(); + for node in nodes { + for child in node.typed_view().child_ids() { + parents.entry(child).or_insert(*node); + } + } + parents +} + +fn nearby_text(node: &RawNode, snapshot: &Snapshot, depth: usize) -> String { + let mut text = node_label(node); + if depth > 0 { + for child in node.typed_view().child_ids().take(32) { + if let Some(child) = snapshot.nodes.get(child) + && child.typed_view().bool("visible") != Some(false) + { + text.push(' '); + text.push_str(&nearby_text(child, snapshot, depth - 1)); + } + } + } + text.chars().take(1_024).collect() +} + +fn candidate(kind: &str, node: &RawNode, evidence: Value, implementation: Value) -> Value { + let reactions = node + .typed_view() + .value("reactions") + .and_then(Value::as_array) + .map(|items| items.iter().take(16).cloned().collect::>()) + .unwrap_or_default(); + json!({ + "id":format!("{kind}:{}",node.id), "kind":kind, "nodeId":node.id, + "name":string(node,"name"), "confidence":"heuristic", + "classification":"implementation-recommendation", "evidence":evidence, + "intentBasis":if reactions.is_empty(){"visual-inference"}else{"captured-prototype-plus-visual-inference"}, + "capturedPrototypeActions":reactions, + "figmaAnimationVerified":false, "implemented":false, "runtimeVerified":false, + "implementation":implementation, "guide":GUIDE + }) +} + +fn roots(result: &Value, snapshot: &Snapshot) -> Vec { + if let Some(id) = result["nodeId"] + .as_str() + .or(result["source"]["nodeId"].as_str()) + { + vec![id.into()] + } else if let Some(frames) = result["frames"].as_array() { + frames + .iter() + .filter_map(|frame| frame["nodeId"].as_str().map(str::to_owned)) + .collect() + } else { + snapshot.roots.clone() + } +} + +fn data_binding(node: &RawNode, role: &str, evidence: Value) -> Value { + json!({ + "nodeId":node.id,"role":role,"evidence":evidence, + "backendMatched":false,"apiAnchor":null,"databaseAnchor":null, + "status":"source-required","literalIsOperationalData":false, + "labelPolicy":"Keep fixed captions such as total/registered/applied as labels. Bind the numeric value and unit separately; a numeric value in a label-named node requires review, not automatic substitution.", + "orderingPolicy":"Preserve captured display ordinals and the API's documented sort order. A descending 3/2/1 row label is presentation order, not a database primary key; do not replace it with an ID or infer ascending order.", + "nextAction":"Read devup_project_context api/db, identify the owning fields, and reuse the same source in landing and detail views. Do not infer an endpoint or schema from this design.", + "aggregation":"Compute totals and status/category breakdowns from the same source records; do not duplicate today's total in TSX or CMS.", + "failurePolicy":"Propagate unavailable/malformed data; never return design fixtures as successful live data.", + "verification":["Change records and values in an isolated DB and check every rendering","Empty data and backend outage","Preserve existing edits and verify idempotent backfills","Distinguish source wiring from API-to-browser and write-to-DB verification"] + }) +} + +/// Add a bounded implementation plan to every requested code/frame delivery. +/// This never mutates TSX or asserts that recommendations have been implemented. +pub(super) fn attach(result: &mut Value, snapshot: &Snapshot) { + if !result.is_object() + || ![ + "tsx", + "componentTsx", + "responsiveTsx", + "deliverable", + "frames", + "nodeId", + ] + .iter() + .any(|field| result.get(*field).is_some()) + { + return; + } + let scope = roots(result, snapshot); + let (nodes, node_limit) = scoped_nodes(snapshot, &scope); + let parents = parents(&nodes); + let mut candidates = Vec::new(); + let mut bindings = Vec::new(); + for node in &nodes { + if let Some(count) = detect::statistic(node, snapshot, &parents) { + bindings.push(data_binding( + node, + "presentation-statistic", + count["evidence"].clone(), + )); + candidates.push(count); + } + if let Some(marquee) = detect::marquee(node, snapshot, &parents, result) { + candidates.push(marquee); + } + if let Some(progress) = detect::progress(node, snapshot) { + candidates.push(progress); + } + if let Some(tabs) = detect::tabs(node, snapshot, result) { + candidates.push(tabs); + } + if let Some(view) = detect::view_switch(node, snapshot) { + candidates.push(view); + } + if detect::editable_content(node) { + bindings.push(data_binding( + node, + "editable-business-content", + json!({"name":string(node,"name")}), + )); + } + } + detect::palette(result, snapshot, &mut candidates); + let page_contracts = detect::page_contracts(&scope, snapshot, &nodes, &mut candidates); + candidates.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str())); + candidates.dedup_by(|a, b| a["id"] == b["id"]); + let candidate_limit = candidates.len() > MAX_CANDIDATES; + let binding_limit = bindings.len() > MAX_CANDIDATES; + candidates.truncate(MAX_CANDIDATES); + bindings.truncate(MAX_CANDIDATES); + result["behaviorPlan"] = json!({ + "version":1,"scopeNodeIds":scope,"candidateCount":candidates.len(), + "candidates":candidates,"dataBindings":bindings,"pageContracts":page_contracts, + "implemented":false,"runtimeVerified":false,"requiresReview":true, + "evidenceLimits":{"nodesScanned":nodes.len(),"maxNodes":MAX_NODES,"maxCandidates":MAX_CANDIDATES,"truncated":node_limit||candidate_limit||binding_limit}, + "intentPolicy":"Visual shape and repeated structure suggest behavior; they do not establish Figma motion or business semantics. Follow explicit user requirements and captured interactions; label all chosen timing and inferred behavior as implementation decisions.", + "clientBoundary":"Preserve generated responsive TSX and Server Component layouts/data pages. Add only small shared client controllers for actual browser state or animation.", + "sourcePurity":"Keep this plan, node identities and override evidence in JSON sidecars. Do not add metadata comments or source-node attributes to application TSX.", + "verificationRequirements":["Match content, selected background, text, icon and ARIA to one normalized state","Test both directions, pointer and keyboard, direct URL, reload, history and resize","Test mobile/desktop and actual CSS viewport/zoom; compare geometry after accounting for viewport differences","Keep SSR and no-JS content readable; honor reduced motion and focus","Report candidates, wired behavior and browser-verified behavior separately"], + "guide":GUIDE + }); + if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { + for frame in frames { + attach(frame, snapshot); + } + } +} diff --git a/crates/devup-mcp/src/server/behavior_contracts/detect.rs b/crates/devup-mcp/src/server/behavior_contracts/detect.rs new file mode 100644 index 00000000..40bc98d1 --- /dev/null +++ b/crates/devup-mcp/src/server/behavior_contracts/detect.rs @@ -0,0 +1,376 @@ +use super::*; + +pub(super) fn statistic( + node: &RawNode, + snapshot: &Snapshot, + parents: &BTreeMap<&str, &RawNode>, +) -> Option { + if node.node_type != "TEXT" { + return None; + } + let text = string(node, "characters").trim(); + let split = text + .find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | ','))) + .unwrap_or(text.len()); + let (number, unit) = text.split_at(split); + if number.is_empty() + || number.ends_with('.') + || number.starts_with('0') && number.len() > 1 && !number.starts_with("0.") + { + return None; + } + let integer = number.split('.').next().unwrap_or_default(); + if integer.contains(',') { + let groups = integer.split(',').collect::>(); + if !(1..=3).contains(&groups[0].len()) + || groups.iter().skip(1).any(|group| group.len() != 3) + { + return None; + } + } + let target = number.replace(',', "").parse::().ok()?; + if !target.is_finite() + || target < 0.0 + || (1900.0..=2100.0).contains(&target) && !unit.contains('%') + { + return None; + } + let parent = parents.get(node.id.as_str()).copied().unwrap_or(node); + let context = parents + .get(parent.id.as_str()) + .copied() + .filter(|ancestor| { + !snapshot.roots.contains(&ancestor.id) + || has( + string(ancestor, "name"), + &["stat", "metric", "summary", "counter", "통계"], + ) + }) + .unwrap_or(parent); + let label = nearby_text(context, snapshot, 2); + let identity = format!( + "{} {} {}", + node_label(node), + string(parent, "name"), + string(context, "name") + ); + let lower_identity = identity.to_lowercase(); + let calendar_name = lower_identity + .split(|c: char| !c.is_alphanumeric()) + .any(|word| matches!(word, "date" | "year")) + && !has( + &identity, + &["experience", "research", "career", "경력", "연구"], + ); + if has( + &identity, + &[ + "pagination", + "page number", + "page-number", + "identifier", + "patent number", + "patent-number", + "registration number", + "application number", + "record id", + "등록번호", + "출원번호", + "페이지 번호", + "연도", + "날짜", + ], + ) || calendar_name + || has(string(node, "name"), &["step", "순서", "index", "번호"]) + { + return None; + } + let statistic_context = has( + &label, + &[ + "stat", + "metric", + "count", + "통계", + "경력", + "보유", + "개발 중", + "시장", + "비율", + "성장률", + "총계", + "summary", + "patent", + "pipeline", + ], + ); + let unit_context = has(&label, &["%", "건", "개", "년", "억", "명", "달러"]); + let emphasized = node + .typed_view() + .number("fontSize") + .is_some_and(|size| size >= 28.0); + if !statistic_context && !(emphasized && unit_context) { + return None; + } + let precision = number + .split_once('.') + .map_or(0, |(_, fraction)| fraction.len()); + Some(candidate( + "count-up", + node, + json!({"text":text,"context":label,"target":target,"precision":precision,"unit":unit,"numericLabelRequiresReview":has(string(node,"name"), &["label","caption","라벨"])}), + json!({ + "pattern":"shared-count-up","start":"first-viewport-entry","repeat":"once","valueSource":"validated API/DB value when business-owned","preserve":["decimal precision","unit","final value's responsive width","accessible final value"], + "serverMarkup":"Render the final value on the server; use a client span/ref controller only for its animation.", + "reducedMotion":"show final value","exclude":["dates","identifiers","step indices","pagination"], + "labelPolicy":"Keep total/registered/applied and other captions fixed; verify which slot is a numeric value before binding.", + "verify":["final integer and decimal values","live changed/empty values","entry/re-entry","resize from hidden responsive branches","no adjacent-label displacement"] + }), + )) +} + +pub(super) fn marquee( + node: &RawNode, + _snapshot: &Snapshot, + parents: &BTreeMap<&str, &RawNode>, + result: &Value, +) -> Option { + let captured = result["interactiveCandidates"] + .as_array() + .into_iter() + .flatten() + .find(|candidate| candidate["nodeId"] == node.id && candidate["role"] == "marquee"); + let view = node.typed_view(); + let text = string(node, "characters"); + let single_line = node.node_type == "TEXT" + && !text.contains(['\n', '\r']) + && (view.number("maxLines") == Some(1.0) + || view.string("textAutoResize") == Some("WIDTH_AND_HEIGHT")); + let mut current = node; + let mut seen = BTreeSet::new(); + let mut clipping = None; + while seen.insert(current.id.as_str()) { + let Some(parent) = parents.get(current.id.as_str()).copied() else { + break; + }; + if parent.typed_view().bool("clipsContent") == Some(true) + && matches!((view.number("width"),parent.typed_view().number("width")),(Some(width),Some(clip)) if width>clip+1.0) + { + clipping = Some(parent.id.clone()); + break; + } + current = parent; + } + if captured.is_none() && !(single_line && clipping.is_some()) { + return None; + } + Some(candidate( + "marquee", + node, + json!({"singleLineOverflow":single_line&&clipping.is_some(),"clippingAncestorId":clipping,"interactionEvidence":captured}), + json!({ + "pattern":"seamless-left-marquee","playback":"continuous linear loop when requested or accepted","duplicateContent":"one repeat group with aria-hidden on duplicates","seam":"measure one group's width; no gap or jump at wrap","pauseControls":"only when required by explicit policy or user preference; do not invent mandatory controls","reducedMotion":"honor the user's motion preference unless an explicit requirement overrides it","verify":["leftward movement","wrap continuity","short/long viewport","no-JS readable content","duplicate content excluded from accessible name"] + }), + )) +} + +pub(super) fn progress(node: &RawNode, snapshot: &Snapshot) -> Option { + if !matches!( + node.node_type.as_str(), + "FRAME" | "GROUP" | "INSTANCE" | "COMPONENT" + ) { + return None; + } + let children = node + .typed_view() + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .collect::>(); + let steps = children + .iter() + .filter(|child| has(string(child, "name"), &["step", "단계"])) + .collect::>(); + if steps.len() < 2 { + return None; + } + let (descendants, _) = scoped_nodes(snapshot, std::slice::from_ref(&node.id)); + let progress=descendants.iter().filter(|child|{ + if has(string(child,"name"), &["progress", "진행선", "진행률"]) {return true} + let view=child.typed_view(); + if !matches!((view.number("width"),view.number("height")),(Some(width),Some(height)) if width>=40.0 && height>0.0 && height<=6.0) {return false} + view.child_ids().filter_map(|id|snapshot.nodes.get(id)).any(|fill| { + let filled=fill.typed_view(); + matches!((view.number("width"),filled.number("width"),view.number("height"),filled.number("height")),(Some(track),Some(width),Some(height),Some(fill_height)) if width>=1.0 && width>(); + if progress.is_empty() { + return None; + } + let variants = steps + .iter() + .filter_map(|step| { + step.typed_view() + .value("variantProperties") + .map(|properties| json!({"nodeId":step.id,"properties":properties})) + }) + .collect::>(); + Some(candidate( + "progress-step-cycle", + node, + json!({"stepNodeIds":steps.iter().map(|child|&child.id).collect::>(),"progressNodeIds":progress,"capturedStepVariants":variants}), + json!({ + "pattern":"shared-progress-step-controller","cycle":"on progress completion advance to the next captured step and wrap using the actual step count","stateSource":"one active index owns highlight, description, image, progress and ARIA","content":"collect every captured active variant and preserve its image/text overrides","manualSelection":"pointer/keyboard selection restarts that step consistently","controls":"follow user playback requirements; do not invent pause/resume controls","transitions":"fade/height transitions without changing generated layout","timing":"choose and disclose an implementation duration unless captured motion defines it","verify":["progress fills before advance","last-to-first wrap","manual selection","different data lengths","cleanup and remount","reduced motion policy"] + }), + )) +} + +pub(super) fn tabs(node: &RawNode, snapshot: &Snapshot, result: &Value) -> Option { + let child_ids = node.typed_view().child_ids().collect::>(); + let captured = result["interactiveCandidates"] + .as_array() + .into_iter() + .flatten() + .filter(|candidate| { + candidate["role"] == "tab" + && candidate["nodeId"] + .as_str() + .is_some_and(|id| child_ids.contains(id)) + }) + .count(); + let named = has( + string(node, "name"), + &["tabs", "tabmenu", "tab menu", "탭 메뉴", "탭메뉴"], + ); + if captured < 2 && !(named && child_ids.len() >= 2) { + return None; + } + Some(candidate( + "sliding-tab-indicator", + node, + json!({"capturedTabChildren":captured,"name":string(node,"name"),"childCount":child_ids.len(),"text":nearby_text(node,snapshot,1)}), + json!({ + "pattern":"persistent-route-tabs","ownership":"nearest shared server layout (route group when only some siblings share the menu)","clientBoundary":"small pathname/query-aware tab indicator only","dom":"one set of links and one moving indicator; preserve their identity across route navigation","selection":"React owns aria-current; animation cleanup never restores a stale selection","routing":"real links and public URLs; never replace App Router with local screen state","motion":"measure active link bounds and transition indicator position/width","verify":["same DOM node survives navigation","actual intermediate animation frame","both directions","keyboard","history","direct refresh","resize"] + }), + )) +} + +pub(super) fn view_switch(node: &RawNode, snapshot: &Snapshot) -> Option { + let children = node.typed_view().child_ids().count(); + if !(2..=6).contains(&children) { + return None; + } + let text = nearby_text(node, snapshot, 2); + let pair = has(&text, &["썸네일", "thumbnail", "grid"]) && has(&text, &["목록", "list"]); + if !pair { + return None; + } + Some(candidate( + "view-mode-state", + node, + json!({"text":text}), + json!({ + "pattern":"single-source-view-toggle","stateSource":"same normalized URL query or application state that renders content","synchronize":["content","background","text","icon","opacity","aria-current or aria-pressed"],"responsive":"one responsive control; never keep fixed selected mobile/desktop copies","navigation":"preserve filters/sort and define page-reset policy","verify":["both modes and directions","exactly one selection","computed background/text/icon colours","keyboard focus and Enter","reload and browser history","resize"] + }), + )) +} + +pub(super) fn editable_content(node: &RawNode) -> bool { + node.node_type == "TEXT" + && !string(node, "characters").trim().is_empty() + && has( + string(node, "name"), + &[ + "product title", + "product description", + "product name", + "제품명", + "제품 설명", + "development stage", + "개발 단계", + ], + ) +} + +pub(super) fn palette(result: &Value, snapshot: &Snapshot, candidates: &mut Vec) { + for record in result["instanceOverrides"].as_array().into_iter().flatten() { + let fields = record["overriddenFields"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + if !fields + .iter() + .any(|field| has(field, &["fill", "stroke", "color"])) + { + continue; + } + let Some(node) = record["instanceNodeId"] + .as_str() + .and_then(|id| snapshot.nodes.get(id)) + else { + continue; + }; + candidates.push(candidate("palette-state-review",node,json!({"overrideFields":fields,"mainComponentId":record["mainComponentId"]}),json!({ + "review":["default","hover","active","focus-visible","disabled","border","text","icon"],"source":"captured variant states and actual project theme tokens","rule":"a colour override must not inherit the master's unrelated colour family","derivation":"do not invent a matching shade suffix; record any chosen fallback policy separately from Figma evidence","verify":["default/hover/press/keyboard focus computed colours","disabled/pending","instance colour overrides"] + }))); + } +} + +pub(super) fn page_contracts( + scope: &[String], + snapshot: &Snapshot, + nodes: &[&RawNode], + candidates: &mut Vec, +) -> Vec { + let mut contracts = Vec::new(); + for root in scope + .iter() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|node| matches!(node.node_type.as_str(), "FRAME" | "SECTION")) + { + let (local, _) = scoped_nodes(snapshot, std::slice::from_ref(&root.id)); + let footer = local + .iter() + .find(|node| has(string(node, "name"), &["footer", "푸터", "하단 정보"])); + if let Some(footer) = footer { + contracts.push(json!({"kind":"viewport-page-shell","rootNodeId":root.id,"footerNodeId":footer.id,"implemented":false,"verified":false,"implementation":{"minHeight":"100dvh","layout":"column flex with growing content / footer margin-top:auto","footer":"normal document flow; never fixed or based on today's measured header/footer height"},"verification":["short and empty content","long content without overlap","mobile/desktop including tall viewport","actual CSS viewport and zoom"]})); + } + let sections = root + .typed_view() + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|node| { + matches!(node.node_type.as_str(), "FRAME" | "SECTION" | "INSTANCE") + && !has( + string(node, "name"), + &[ + "header", + "footer", + "gnb", + "navigation", + "breadcrumb", + "form", + "헤더", + "푸터", + "메뉴", + "폼", + ], + ) + }) + .collect::>(); + for section in sections.iter().skip(1).filter(|node| { + node.typed_view() + .number("height") + .is_some_and(|height| height >= 180.0) + }) { + if !nodes.iter().any(|node| node.id == section.id) { + continue; + } + candidates.push(candidate("section-entrance",section,json!({"sectionHeight":section.typed_view().number("height"),"belowFirstContentSection":true}),json!({ + "pattern":"shared-scroll-reveal-controller","scope":"selected major below-fold sections, not every text or control","serverMarkup":"render readable content immediately; never prehide SSR content","observer":"one shared observer plus new content from client navigation; disconnect/cleanup correctly","motion":"short upward translation and fade once per element, avoiding focused controls","reducedMotion":"leave content in place","timing":"implementation choice, not captured Figma motion","verify":["intermediate frame","once on entry","client navigation","observer cleanup","focus","reduced motion","JavaScript disabled"] + }))); + } + } + contracts +} diff --git a/crates/devup-mcp/src/server/behavior_contracts/tests.rs b/crates/devup-mcp/src/server/behavior_contracts/tests.rs new file mode 100644 index 00000000..7ac1a497 --- /dev/null +++ b/crates/devup-mcp/src/server/behavior_contracts/tests.rs @@ -0,0 +1,368 @@ +use super::*; + +fn snapshot(nodes: Value, roots: &[&str]) -> Snapshot { + let nodes = nodes + .as_array() + .unwrap() + .iter() + .map(|value| serde_json::from_value::(value.clone()).unwrap()) + .map(|node| (node.id.clone(), node)) + .collect(); + Snapshot { + file_key: "behavior-fixture".into(), + version: None, + roots: roots.iter().map(|id| (*id).into()).collect(), + nodes, + diagnostics: Vec::new(), + } +} +fn kinds(result: &Value) -> Vec<&str> { + result["behaviorPlan"]["candidates"] + .as_array() + .unwrap() + .iter() + .filter_map(|candidate| candidate["kind"].as_str()) + .collect() +} +fn kind<'a>(result: &'a Value, name: &str) -> &'a Value { + result["behaviorPlan"]["candidates"] + .as_array() + .unwrap() + .iter() + .find(|candidate| candidate["kind"] == name) + .unwrap() +} + +#[test] +fn presentation_decimals_keep_units_and_require_real_backend_sources() { + let design = snapshot( + json!([ + {"id":"root","type":"FRAME","fields":{"childrenIds":["metric","year","page"]}}, + {"id":"metric","type":"INSTANCE","fields":{"name":"StatItem","childrenIds":["value","caption"]}}, + {"id":"value","type":"TEXT","fields":{"name":"Statistic value","characters":"20.3%","fontSize":48}}, + {"id":"caption","type":"TEXT","fields":{"characters":"고령인구 비율"}}, + {"id":"year","type":"TEXT","fields":{"name":"Year","characters":"2026","fontSize":56}}, + {"id":"page","type":"TEXT","fields":{"name":"pagination","characters":"3","fontSize":48}} + ]), + &["root"], + ); + let mut result = json!({"tsx":"export const C=()=> 20.3%;"}); + attach(&mut result, &design); + assert_eq!(kinds(&result), ["count-up"]); + let statistic = kind(&result, "count-up"); + assert_eq!(statistic["evidence"]["target"], 20.3); + assert_eq!(statistic["evidence"]["precision"], 1); + assert_eq!(statistic["evidence"]["unit"], "%"); + assert_eq!(statistic["classification"], "implementation-recommendation"); + assert_eq!(statistic["figmaAnimationVerified"], false); + let binding = &result["behaviorPlan"]["dataBindings"][0]; + assert_eq!(binding["backendMatched"], false); + assert!(binding["apiAnchor"].is_null()); + assert!( + binding["nextAction"] + .as_str() + .unwrap() + .contains("devup_project_context") + ); + assert!( + binding["labelPolicy"] + .as_str() + .unwrap() + .contains("fixed captions") + ); + assert_eq!(result["tsx"], "export const C=()=> 20.3%;"); + assert!( + binding["orderingPolicy"] + .as_str() + .unwrap() + .contains("3/2/1") + ); +} + +#[test] +fn statistic_names_do_not_confuse_candidates_or_research_years_with_calendar_dates() { + let design = snapshot( + json!([ + {"id":"root","type":"FRAME","fields":{"name":"Statistics","childrenIds":["candidate","research","identifier","malformed"]}}, + {"id":"candidate","type":"TEXT","fields":{"name":"Candidate count","characters":"4","fontSize":48}}, + {"id":"research","type":"TEXT","fields":{"name":"Research year experience","characters":"30년","fontSize":48}}, + {"id":"identifier","type":"TEXT","fields":{"name":"Patent number","characters":"11045498","fontSize":48}}, + {"id":"malformed","type":"TEXT","fields":{"name":"Statistic count","characters":"1,2%","fontSize":48}} + ]), + &["root"], + ); + let mut result = json!({"tsx":"Statistics"}); + attach(&mut result, &design); + let mut ids = result["behaviorPlan"]["candidates"] + .as_array() + .unwrap() + .iter() + .filter_map(|candidate| candidate["nodeId"].as_str()) + .collect::>(); + ids.sort_unstable(); + assert_eq!(ids, ["candidate", "research"]); +} + +#[test] +fn a_single_responsive_delivery_does_not_acquire_a_synthetic_frames_field() { + let design = snapshot(json!([{"id":"root","type":"FRAME","fields":{}}]), &["root"]); + let mut result = json!({"responsiveTsx":""}); + attach(&mut result, &design); + assert!(result.get("frames").is_none()); + assert!(result["behaviorPlan"].is_object()); +} + +#[test] +fn numeric_labels_are_reviewed_and_identifiers_or_hidden_values_are_not_animated() { + let design = snapshot( + json!([ + {"id":"root","type":"FRAME","fields":{"childrenIds":["card","hidden","outside"]}}, + {"id":"card","type":"INSTANCE","fields":{"name":"Stat summary","childrenIds":["number","identifier"]}}, + {"id":"number","type":"TEXT","fields":{"name":"Label","characters":"8.52","fontSize":48}}, + {"id":"identifier","type":"TEXT","fields":{"name":"등록번호","characters":"001","fontSize":48}}, + {"id":"hidden","type":"FRAME","fields":{"visible":false,"childrenIds":["secret"]}}, + {"id":"secret","type":"TEXT","fields":{"name":"Statistic","characters":"99","fontSize":48}}, + {"id":"outside","type":"TEXT","fields":{"name":"Paragraph","characters":"One item"}} + ]), + &["root"], + ); + let mut result = json!({"componentTsx":"const C=()=> "}); + attach(&mut result, &design); + assert_eq!(kinds(&result), ["count-up"]); + let number = kind(&result, "count-up"); + assert_eq!(number["evidence"]["precision"], 2); + assert_eq!(number["evidence"]["numericLabelRequiresReview"], true); +} + +#[test] +fn single_line_overflow_suggests_marquee_without_treating_wrapped_text_as_motion() { + let design = snapshot( + json!([ + {"id":"root","type":"FRAME","fields":{"width":400,"clipsContent":true,"childrenIds":["strip","paragraph"]}}, + {"id":"strip","type":"TEXT","fields":{"width":1800,"maxLines":1,"characters":"Regenerating Joints ✦ Restoring Lives"}}, + {"id":"paragraph","type":"TEXT","fields":{"width":1800,"characters":"A heading\nA paragraph","maxLines":1}} + ]), + &["root"], + ); + let mut result = json!({"tsx":"const C=()=> "}); + attach(&mut result, &design); + assert_eq!(kinds(&result), ["marquee"]); + assert_eq!( + kind(&result, "marquee")["evidence"]["clippingAncestorId"], + "root" + ); + assert!( + kind(&result, "marquee")["implementation"]["pauseControls"] + .as_str() + .unwrap() + .contains("only when required") + ); +} + +#[test] +fn step_cycle_requires_multiple_steps_and_progress_evidence() { + let mut design = snapshot( + json!([ + {"id":"root","type":"FRAME","fields":{"name":"Technology","childrenIds":["one","two"],"reactions":[{"trigger":{"type":"AFTER_TIMEOUT"},"actions":[{"type":"NODE","destinationId":"next"}]}]}}, + {"id":"one","type":"INSTANCE","fields":{"name":"TechStep","variantProperties":{"Active":"true"},"childrenIds":["bar"]}}, + {"id":"two","type":"INSTANCE","fields":{"name":"TechStep","variantProperties":{"Active":"false"}}}, + {"id":"bar","type":"RECTANGLE","fields":{"name":"Progress"}} + ]), + &["root"], + ); + let mut result = json!({"tsx":"const C=()=> "}); + attach(&mut result, &design); + let cycle = kind(&result, "progress-step-cycle"); + assert_eq!( + cycle["evidence"]["capturedStepVariants"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + cycle["intentBasis"], + "captured-prototype-plus-visual-inference" + ); + assert_eq!( + cycle["capturedPrototypeActions"][0]["trigger"]["type"], + "AFTER_TIMEOUT" + ); + assert_eq!(cycle["figmaAnimationVerified"], false); + design + .nodes + .get_mut("bar") + .unwrap() + .fields + .insert("name".into(), json!("Divider")); + attach(&mut result, &design); + assert!(!kinds(&result).contains(&"progress-step-cycle")); +} + +#[test] +fn route_tabs_and_view_controls_have_one_state_and_persistent_layout_contracts() { + let design = snapshot( + json!([ + {"id":"root","type":"FRAME","fields":{"childrenIds":["tabs","toggle"]}}, + {"id":"tabs","type":"FRAME","fields":{"name":"Tabs menu","childrenIds":["a","b"]}}, + {"id":"a","type":"TEXT","fields":{"characters":"기업 뉴스"}}, + {"id":"b","type":"TEXT","fields":{"characters":"공지사항"}}, + {"id":"toggle","type":"FRAME","fields":{"name":"ViewToggle","childrenIds":["thumbnail","list"]}}, + {"id":"thumbnail","type":"TEXT","fields":{"characters":"썸네일형"}}, + {"id":"list","type":"TEXT","fields":{"characters":"목록형"}} + ]), + &["root"], + ); + let mut result = json!({"tsx":"const C=()=> "}); + attach(&mut result, &design); + assert!( + kind(&result, "sliding-tab-indicator")["implementation"]["ownership"] + .as_str() + .unwrap() + .contains("shared server layout") + ); + assert!( + kind(&result, "view-mode-state")["implementation"]["stateSource"] + .as_str() + .unwrap() + .contains("same normalized URL") + ); + assert!( + kind(&result, "view-mode-state")["implementation"]["synchronize"] + .as_array() + .unwrap() + .contains(&json!("icon")) + ); +} + +#[test] +fn generic_step_layout_with_partial_track_is_a_review_candidate_not_captured_motion() { + let design = snapshot( + json!([ + {"id":"root","type":"FRAME","fields":{"name":"Frame 45","childrenIds":["one","two"]}}, + {"id":"one","type":"INSTANCE","fields":{"name":"TechStep","childrenIds":["track"]}}, + {"id":"two","type":"INSTANCE","fields":{"name":"TechStep"}}, + {"id":"track","type":"FRAME","fields":{"name":"Rectangle","width":330,"height":3,"childrenIds":["fill"]}}, + {"id":"fill","type":"RECTANGLE","fields":{"name":"Rectangle","width":110,"height":3}} + ]), + &["root"], + ); + let mut result = json!({"tsx":"const C=()=> "}); + attach(&mut result, &design); + let cycle = kind(&result, "progress-step-cycle"); + assert_eq!(cycle["intentBasis"], "visual-inference"); + assert_eq!(cycle["evidence"]["progressNodeIds"], json!(["track"])); + assert_eq!(cycle["figmaAnimationVerified"], false); +} + +#[test] +fn page_footer_and_reveal_recommendations_preserve_readable_server_content() { + let design = snapshot( + json!([ + {"id":"root","type":"FRAME","fields":{"name":"Home","childrenIds":["header","hero","content","form","footer"]}}, + {"id":"header","type":"FRAME","fields":{"name":"Header","height":200}}, + {"id":"hero","type":"FRAME","fields":{"name":"Hero","height":600}}, + {"id":"content","type":"FRAME","fields":{"name":"Research section","height":500}}, + {"id":"form","type":"FRAME","fields":{"name":"Contact form","height":500}}, + {"id":"footer","type":"FRAME","fields":{"name":"Footer","height":100}} + ]), + &["root"], + ); + let mut result = json!({"tsx":"const C=()=> "}); + attach(&mut result, &design); + assert_eq!(kinds(&result), ["section-entrance"]); + assert_eq!(kind(&result, "section-entrance")["nodeId"], "content"); + assert!( + kind(&result, "section-entrance")["implementation"]["serverMarkup"] + .as_str() + .unwrap() + .contains("never prehide SSR") + ); + assert_eq!( + result["behaviorPlan"]["pageContracts"][0]["implementation"]["minHeight"], + "100dvh" + ); + assert_eq!(result["behaviorPlan"]["implemented"], false); + assert_eq!(result["behaviorPlan"]["runtimeVerified"], false); +} + +#[test] +fn paint_overrides_request_complete_state_review_without_inventing_tokens() { + let design = snapshot( + json!([ + {"id":"root","type":"INSTANCE","fields":{"name":"Secondary CTA"}} + ]), + &["root"], + ); + let mut result = json!({"tsx":"const C=()=> ","instanceOverrides":[{"instanceNodeId":"root","mainComponentId":"primary","overriddenFields":["fills"]}]}); + attach(&mut result, &design); + let review = kind(&result, "palette-state-review"); + assert!( + review["implementation"]["review"] + .as_array() + .unwrap() + .contains(&json!("active")) + ); + assert!( + review["implementation"]["derivation"] + .as_str() + .unwrap() + .contains("do not invent") + ); + assert_eq!(result["tsx"], "const C=()=> "); +} + +#[test] +fn each_frame_gets_a_local_plan_and_other_snapshot_roots_do_not_leak() { + let design = snapshot( + json!([ + {"id":"first","type":"FRAME","fields":{"name":"Stats","childrenIds":["a"]}}, + {"id":"a","type":"TEXT","fields":{"name":"Metric","characters":"30"}}, + {"id":"second","type":"FRAME","fields":{"name":"Stats","childrenIds":["b"]}}, + {"id":"b","type":"TEXT","fields":{"name":"Metric","characters":"4"}}, + {"id":"unselected","type":"TEXT","fields":{"name":"Metric","characters":"900"}} + ]), + &["first", "second", "unselected"], + ); + let mut result = json!({"frames":[{"nodeId":"first","tsx":"const A=()=> "},{"nodeId":"second","tsx":"const B=()=> "}]}); + attach(&mut result, &design); + assert_eq!(result["behaviorPlan"]["candidateCount"], 2); + assert_eq!(result["frames"][0]["behaviorPlan"]["candidateCount"], 1); + assert_eq!(result["frames"][1]["behaviorPlan"]["candidateCount"], 1); + assert_eq!( + result["frames"][0]["behaviorPlan"]["candidates"][0]["nodeId"], + "a" + ); + assert_eq!( + result["frames"][1]["behaviorPlan"]["candidates"][0]["nodeId"], + "b" + ); +} + +#[test] +fn evidence_only_responses_do_not_claim_an_application_plan() { + let design = snapshot(json!([]), &[]); + let mut result = json!({"rawSnapshot":{}}); + attach(&mut result, &design); + assert!(result.get("behaviorPlan").is_none()); +} + +#[test] +fn bounded_candidates_report_incomplete_coverage_without_changing_source() { + let mut nodes = Vec::new(); + let children = (0..(MAX_CANDIDATES + 1)) + .map(|index| format!("n{index}")) + .collect::>(); + nodes.push( + json!({"id":"root","type":"FRAME","fields":{"name":"Statistics","childrenIds":children}}), + ); + for id in children { + nodes.push(json!({"id":id,"type":"TEXT","fields":{"name":"Metric","characters":"30"}})); + } + let design = snapshot(json!(nodes), &["root"]); + let mut result = json!({"tsx":"const C=()=> "}); + attach(&mut result, &design); + assert_eq!(result["behaviorPlan"]["candidateCount"], MAX_CANDIDATES); + assert_eq!(result["behaviorPlan"]["evidenceLimits"]["truncated"], true); + assert_eq!(result["tsx"], "const C=()=> "); +} diff --git a/crates/devup-mcp/src/server/commands/opencode-devup.md b/crates/devup-mcp/src/server/commands/opencode-devup.md index 149bebf8..bdbed9db 100644 --- a/crates/devup-mcp/src/server/commands/opencode-devup.md +++ b/crates/devup-mcp/src/server/commands/opencode-devup.md @@ -154,10 +154,8 @@ For Figma work, continue with the following branch after project intake: 12. Done = zero fails at the drawn widths and no health or overflow fail at any width, or a filed converter bug, or listed as unresolved with evidence. Also every URL implemented, no data-figma-node-id in the - production build (DevupUI strips attributes by default; `DEVUP_AUDIT=1` - keeps them). Do not change app build config for auditing or register a - strip plugin. Node attributes are off by default; `figmaNodeIds: true` - is opt-in only where supported by the export schema. `bun run build`, `bun run lint`, `bunx oxlint + production build. Identity mappings stay in JSON sidecars, including debug builds; + use semantic runtime targets and never add strip plugins. `bun run build`, `bun run lint`, `bunx oxlint --deny-warnings`, `bun tsc --noEmit` in each changed app and `bun test` pass (`bun run test` adds cargo tarpaulin, which does not work on Windows), devup_ui_validate has no errors, site completeness filled. @@ -171,3 +169,6 @@ numbers in chat. Record the chosen answer, decision id, affected model/endpoint/ and rationale with existing defer/assume actions, not an invented ask action. Update source models and re-confirm ERD changes before dependent implementation. Without a question tool, report the limitation and leave that decision unresolved. + +Apply the embedded developmentContract and behaviorPlan, including delivery-quality +rules for live landing data, consistent selected states, motion and semantic assets. diff --git a/crates/devup-mcp/src/server/compact.rs b/crates/devup-mcp/src/server/compact.rs index d949924e..5b7a1ce0 100644 --- a/crates/devup-mcp/src/server/compact.rs +++ b/crates/devup-mcp/src/server/compact.rs @@ -305,6 +305,7 @@ pub fn select( )); } let evidence_fields = [ + "assetRegistry", "projectionIssues", "instanceOverrides", "projectionEvidence", @@ -326,6 +327,8 @@ pub fn select( "widgetCandidates", "sharedComponentCandidates", "breakpointCompletion", + "behaviorPlan", + "developmentContract", ]; let mut details = serde_json::Map::new(); for field in evidence_fields { diff --git a/crates/devup-mcp/src/server/development_contract.rs b/crates/devup-mcp/src/server/development_contract.rs new file mode 100644 index 00000000..71236bae --- /dev/null +++ b/crates/devup-mcp/src/server/development_contract.rs @@ -0,0 +1,43 @@ +//! Shipping rules accompany tool use even when a client never installs skills. +use serde_json::{Value, json}; + +#[cfg(test)] +const RULES: &str = include_str!("skills/devfive-frontend/references/delivery-quality.md"); + +pub(super) fn attach(result: &mut Value) { + if !result.is_object() { return; } + result["developmentContract"] = json!({ + "version": 1, + "guide": "devup://guide/usage#behavior-and-data-contracts", + "installedReference": "devfive-frontend/references/delivery-quality.md", + "alwaysApplies": true, + "requirements": [ + "pure-tsx-sidecar-provenance", "jsx-theme-tokens-and-axis-shorthands", + "single-responsive-tree-and-korean-word-boundaries", "persistent-server-layout", + "semantic-native-controls-and-complete-pseudo-states", "variant-palette-consistency", + "single-source-url-content-selection", "viewport-footer-shell", + "motion-candidates-and-safe-progressive-enhancement", "canonical-semantic-assets", + "landing-detail-api-db-single-source", "runtime-evidence-before-completion" + ], + "completionEvidence": ["both-control-directions-and-history", "all-widths-and-zoom-context", + "computed-style-and-animation-intermediate-state", "changed-empty-and-failing-api", + "all-public-assets-and-persisted-url-references"], + "staticExportProvesApplicationBehavior": false, + "nextAction": "Apply this contract and behaviorPlan when wiring generated TSX. Resolve each required binding and test its runtime behavior; report unverified work explicitly." + }); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn contract_is_present_independently_of_installed_skills() { + let mut result = json!({"found": true}); + attach(&mut result); + assert_eq!(result["developmentContract"]["alwaysApplies"], true); + assert_eq!(result["developmentContract"]["staticExportProvesApplicationBehavior"], false); + for term in ["keep-all", "grayscale", "layout.tsx", "count", "footer", "DB", "aria-current", "sidecar"] { + assert!(RULES.contains(term), "missing delivery policy: {term}"); + } + } +} diff --git a/crates/devup-mcp/src/server/export_record.rs b/crates/devup-mcp/src/server/export_record.rs index 55664fe9..9f39bdef 100644 --- a/crates/devup-mcp/src/server/export_record.rs +++ b/crates/devup-mcp/src/server/export_record.rs @@ -4,9 +4,9 @@ //! matches it and renders wrong anyway (a converter bug, reported with a //! repro, never hand-tuned in silence). //! -//! `.devup/exports/.json` holds, for every element the export -//! marked with `data-figma-node-id(s)`, its tag, its attributes as written -//! and the source map entries of its node. +//! `.devup/exports/.json` keeps source-map identities, generated +//! code and design evidence outside application source. Source ordinals can +//! identify an unchanged generated file; they never prove runtime DOM identity. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -171,6 +171,45 @@ pub(super) fn elements(tsx: &str) -> BTreeMap { out } +fn source_elements(tsx: &str) -> Vec { + let mut attributes = BTreeMap::>::new(); + for attribute in generated_attributes(tsx).into_iter().filter(|attribute| !is_identity(&attribute.name)) { + attributes.entry(attribute.element).or_default().push(normalized(&attribute.source)); + } + devup_mcp_devup_ui::provenance::attributes::opening_elements(tsx).into_iter().map(|opening| { + let tag = tsx[opening.start + 1..opening.end].split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or_default(); + json!({"elementIndex":opening.ordinal,"tag":tag,"attributes":attributes.remove(&opening.ordinal).unwrap_or_default()}) + }).collect() +} + +fn mapped_elements(tsx: &str, response: &Value, field: &str) -> BTreeMap { + let elements = source_elements(tsx); + let tags = elements.iter().map(|entry| entry["tag"].clone()).collect::>(); + let entries = response["outputResults"][field]["_sourceMap"].as_array().into_iter().flatten() + .filter_map(|entry| Some((entry["nodeId"].as_str()?.to_owned(), entry["elementIndex"].as_u64()? as usize))) + .chain(response["outputResults"][field]["sectionCoverage"]["elementMappings"].as_array().into_iter().flatten() + .filter_map(|entry| Some((entry["nodeId"].as_str()?.to_owned(),entry["elementIndex"].as_u64()? as usize)))) + .chain(response["sourceIdentity"]["responsive"].as_array().into_iter().flatten() + .filter(|_| field == "responsiveTsx") + .flat_map(|entry| entry["ids"].as_array().into_iter().flatten().filter_map(|id| Some((id["nodeId"].as_str()?.to_owned(),entry["elementIndex"].as_u64()? as usize))))).collect::>(); + let mut nodes = BTreeMap::new(); + for (node,index) in entries { + let Some(element) = elements.get(index) else { continue; }; + let mut element = element.clone(); + element["sourceTags"] = json!(tags); + nodes.entry(node).or_insert(element); + } + nodes +} + +fn source_evidence(response: &Value) -> Value { + let outputs = response["outputResults"].as_object().into_iter().flat_map(|outputs| outputs.iter()) + .filter(|(_, output)| output["_sourceMap"].is_array() || output["sectionCoverage"].is_object()) + .map(|(field, output)| (field.clone(), json!({"sourceMap":output["_sourceMap"],"sectionCoverage":output["sectionCoverage"]}))) + .collect::>(); + json!({"sourceMap":response["sourceMap"],"outputs":outputs}) +} + /// Writes the record of one export into a project. Returns where. pub(super) fn write( policy: &OutputPolicy, @@ -181,9 +220,10 @@ pub(super) fn write( ) -> Result, DevupError> { let mut nodes = BTreeMap::new(); let mut output = None; - for field in ["responsiveTsx", "tsx"] { + for field in ["responsiveTsx", "tsx", "componentTsx"] { if let Some(tsx) = response[field].as_str() { - let found = elements(tsx); + let mut found = elements(tsx); + found.extend(mapped_elements(tsx, response, field)); if !found.is_empty() && output.is_none() { output = Some(field); } @@ -205,10 +245,7 @@ pub(super) fn write( .collect::>(); if frame_ids.is_empty() && !root_node_id.is_empty() { frame_ids.insert(root_node_id.to_owned()); } let mut source_map = BTreeMap::>::new(); - for entry in response["outputResults"]["tsx"]["_sourceMap"] - .as_array() - .into_iter() - .flatten() + for entry in ["tsx", "componentTsx", "responsiveTsx"].into_iter().flat_map(|field| response["outputResults"][field]["_sourceMap"].as_array().into_iter().flatten()) { if let Some(node) = entry["nodeId"].as_str() && nodes.contains_key(node) @@ -226,15 +263,27 @@ pub(super) fn write( } let record = json!({ "kind": RECORD_KIND, - "schemaVersion": 1, + "schemaVersion": 2, "fileKey": file_key, "rootNodeId": root_node_id, "output": output, "nodes": nodes, "responsive": response["figmaNodeIds"]["responsive"], + "sourceIdentity": response["sourceIdentity"], + "instanceOverrides": response["instanceOverrides"], + "interactionHints": response["interactionHints"], + "overlayAnchors": response["overlayAnchors"], + "sourceEvidence": source_evidence(response), + "frames": response["frames"].as_array().into_iter().flatten().map(|frame| json!({ + "nodeId":frame["nodeId"],"sourceEvidence":source_evidence(frame), + "generatedSource":frame["tsx"],"componentSource":frame["componentTsx"], + "instanceOverrides":frame["instanceOverrides"],"interactionHints":frame["interactionHints"], + "overlayAnchors":frame["overlayAnchors"],"sourceIdentity":frame["sourceIdentity"] + })).collect::>(), "frameIds": frame_ids, "artifactId": response["artifactId"], "generatedStyles": generated.map(style_elements), + "generatedSource": generated, }); let path = Path::new(project_root) .join(EXPORTS_DIR) @@ -312,17 +361,24 @@ pub(super) fn geometry_cause(project_root: &Path, node_id: &str, sources: &[Stri let Ok(text) = std::fs::read_to_string(&path) else { continue; }; - if !text.contains(node_id) { - continue; - } if let Some(element) = elements(&text).remove(node_id) { code = Some((source.clone(), element)); break; } + if let Some(index) = exported["elementIndex"].as_u64() { + let current = source_elements(&text); + let tags = current.iter().map(|entry| entry["tag"].clone()).collect::>(); + // A changed tree or an ambiguous multi-file extraction invalidates ordinal matching. + if sources.len() == 1 && record["generatedSource"].as_str() == Some(text.as_str()) && exported["sourceTags"] == json!(tags) + && let Some(element) = current.get(index as usize) { + code = Some((source.clone(), element.clone())); + break; + } + } } let Some((file, element)) = code else { return Cause::Unknown(format!( - "No source file marks an element with data-figma-node-id {node_id}; the exported element was removed or renamed." + "No unambiguous source-map/file mapping identifies {node_id}; refresh the sidecar after restructuring or component extraction. DOM or JSX order alone is not identity proof." )); }; let attributes = |value: &Value| { @@ -398,7 +454,7 @@ pub(super) fn write_repro( "page": finding["routePath"], "screenshot": finding["evidence"]["screenshot"], "why": include_str!("text/export_record-write_repro-ab60ad0a.md"), - "file": "Report it to devup-mcp with this file. A local override may finish the site, but it must carry a comment naming this file.", + "file": "Report it to devup-mcp with this file. Record any local correction in this sidecar; keep application source free of export metadata comments.", }); let path = Path::new(project_root) .join(CONVERTER_BUGS_DIR) @@ -419,3 +475,31 @@ pub(super) fn write_repro( } include!("export_record/generated_drift.rs"); + +#[cfg(test)] +mod pure_record_tests { + use super::*; + + #[test] + fn pure_exports_retain_node_and_override_evidence_outside_source() { + let root = std::env::temp_dir().join(format!("devup-pure-sidecar-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); + std::fs::create_dir_all(&root).unwrap(); + let code = "export function Screen() { return Content; }"; + let response = json!({"tsx":code,"outputResults":{"tsx":{"_sourceMap":[{"nodeId":"frame","elementIndex":0}]}},"instanceOverrides":[{"instanceNodeId":"instance","generatedProps":["flex=\"1\""]}],"interactionHints":[{"nodeId":"action","targetResolved":false}],"frames":[{"nodeId":"other-width","tsx":code,"sourceMap":{"entries":[{"nodeId":"nested","elementIndex":1}]}}]}); + let policy = OutputPolicy::from_roots(vec![root.clone()]).unwrap(); + let path = write(&policy, &root.to_string_lossy(), "file", "frame", &response).unwrap().unwrap(); + let record: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(record["nodes"]["frame"]["elementIndex"], 0); + assert_eq!(record["nodes"]["frame"]["tag"], "Box"); + assert_eq!(record["instanceOverrides"], response["instanceOverrides"]); + assert_eq!(record["interactionHints"], response["interactionHints"]); + assert_eq!(record["sourceEvidence"]["outputs"]["tsx"]["sourceMap"], response["outputResults"]["tsx"]["_sourceMap"]); + assert_eq!(record["frames"][0]["sourceEvidence"]["sourceMap"], response["frames"][0]["sourceMap"]); + std::fs::write(root.join("Screen.tsx"), code).unwrap(); + assert!(matches!(geometry_cause(&root, "frame", &["Screen.tsx".into()]), Cause::ConverterBug { .. })); + std::fs::write(root.join("Screen.tsx"), code.replace("40px", "80px")).unwrap(); + assert!(matches!(geometry_cause(&root, "frame", &["Screen.tsx".into()]), Cause::Unknown(_))); + drop(policy); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/devup-mcp/src/server/export_record/generated_drift.rs b/crates/devup-mcp/src/server/export_record/generated_drift.rs index 6993cb84..c0b3bb4d 100644 --- a/crates/devup-mcp/src/server/export_record/generated_drift.rs +++ b/crates/devup-mcp/src/server/export_record/generated_drift.rs @@ -28,12 +28,16 @@ pub(super) fn generated_drift(record: &Value, root: &Path, sources: &[String]) - } let mut actual = Vec::new(); let mut files = Vec::new(); + let mut exact_source_matches = 0usize; for source in sources { let path = root.join(source); if !path.starts_with(root) || source.split(['/', '\\']).any(|part| part == "..") { continue; } if !dunce::canonicalize(&path).ok().zip(dunce::canonicalize(root).ok()).is_some_and(|(path, root)| path.starts_with(root)) { continue; } if std::fs::metadata(&path).map_or(true, |metadata| metadata.len() > MAX_RECORD_BYTES) { continue; } let Ok(text) = std::fs::read_to_string(&path) else { continue; }; + if record["generatedSource"].as_str() == Some(text.as_str()) { + exact_source_matches += 1; + } files.push(source.clone()); actual.extend(style_elements(&text).into_iter().map(|element| (source, element))); } @@ -65,8 +69,11 @@ pub(super) fn generated_drift(record: &Value, root: &Path, sources: &[String]) - for (file, element) in actual.iter().skip(expected.len()) { findings.push(json!({"kind": "styled-element-added", "file": file, "actual": element})); } - json!({"state": if findings.is_empty() { "matched-heuristic" } else { "drift" }, "confidence": "medium", - "method": "Ordered style-bearing JSX elements across mapped URL files; wiring-only wrappers and attributes are ignored. Component extraction or reordering can require mapping review; not a runtime proof.", + let source_matched = files.len() == 1 && exact_source_matches == 1; + json!({"state": if source_matched { "source-matched" } else { "unverified" }, "confidence": if source_matched { "high" } else { "low" }, + "candidateOnly": !source_matched,"runtimeVerified":false, + "reason":if source_matched { "The complete source is identical to the recorded generated source; browser behavior remains unverified." } else { "Source identity is unavailable after edits or component extraction. Findings are positional review candidates, not verified node drift." }, + "method": "Ordered style-bearing JSX comparison; wiring-only wrappers and attributes are ignored. Only an identical complete generated file establishes source identity. Review candidate mapping after extraction or reordering; never infer runtime DOM identities from ordinals.", "files": files, "findings": findings}) } @@ -81,4 +88,22 @@ mod generated_drift_tests { assert_eq!(generated, wired); assert_ne!(generated, style_elements("")); } + + #[test] + fn changed_source_is_a_review_candidate_without_verified_node_identity() { + let root = std::env::temp_dir().join(format!("devup-drift-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); + std::fs::create_dir_all(&root).unwrap(); + let code = ""; + let record = json!({"generatedSource":code,"generatedStyles":style_elements(code)}); + let sources = vec!["Screen.tsx".into()]; + std::fs::write(root.join("Screen.tsx"), code).unwrap(); + assert_eq!(generated_drift(&record, &root, &sources)["state"], "source-matched"); + std::fs::write(root.join("Screen.tsx"), code.replace("40px", "80px")).unwrap(); + let drift = generated_drift(&record, &root, &sources); + assert_eq!(drift["state"], "unverified"); + assert_eq!(drift["candidateOnly"], true); + assert_eq!(drift["runtimeVerified"], false); + assert!(!drift["findings"].as_array().unwrap().is_empty()); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index 62a5b79d..0c75cef1 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -31,7 +31,7 @@ pub const INSTRUCTIONS: &str = include_str!("text/guide-INSTRUCTIONS-49bd7ec6.md /// The rules that moved out of `instructions`, verbatim in meaning. This is a /// relocation, not a rewrite: every numbered rule that was published before is /// published here under the same number. -pub const GUIDE: &str = include_str!("text/guide-GUIDE-c682b0c5.md"); +pub const GUIDE: &str = concat!(include_str!("text/guide-GUIDE-c682b0c5.md"), "\n", include_str!("skills/devfive-frontend/references/delivery-quality.md")); #[cfg(test)] mod tests { diff --git a/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs index 529952b8..b9383691 100644 --- a/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs +++ b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs @@ -141,15 +141,15 @@ impl DevupServer { } report.violations.extend(missing); if let Some(coverage) = &input.section_coverage { - if coverage.node_ids.len() + coverage.asset_urls.len() > 4096 { + if coverage.node_ids.len() + coverage.asset_urls.len() + coverage.element_mappings.len() > 4096 { return Err(invalid("sectionCoverage exceeds 4096 entries.".into())); } - let mut missing = asset_guard::coverage_violations(&sources, &coverage.node_ids); + let mut missing = asset_guard::mapped_coverage_violations(&sources, &coverage.node_ids, &coverage.element_mappings); missing.extend(asset_guard::asset_coverage_violations( &sources, &coverage.asset_urls, )); - if !missing.is_empty() { + if missing.iter().any(|item| item.severity == Severity::Error || (input.strict && item.severity == Severity::Warning)) { report.ok = false; } report.violations.extend(missing); @@ -219,6 +219,9 @@ impl DevupServer { "themeAvailable": theme_lookup.theme.is_some(), "themeGuardrail": theme_lookup.guardrail, }); + if input.section_coverage.is_some() { + result["sectionCoverageVerification"] = json!({"sourceStructure":"Original source-map element/file mappings are compared where unambiguous.","nodeIdentity":"Source order is structural evidence only; extracted or reordered trees require updated mappings.","runtimeVerified":false,"renderedTargets":"Use semantic selectors in .devup/rendered-targets.json; no Figma attributes are required in JSX."}); + } if let Some(name) = &input.source_name { for finding in result["violations"].as_array_mut().unwrap() { finding["sourceName"] = json!(name); diff --git a/crates/devup-mcp/src/server/interactions.rs b/crates/devup-mcp/src/server/interactions.rs index 9d221bbb..4a08f68e 100644 --- a/crates/devup-mcp/src/server/interactions.rs +++ b/crates/devup-mcp/src/server/interactions.rs @@ -9,6 +9,8 @@ const MAX_CANDIDATES: usize = 128; const MAX_DETAILS: usize = 64; #[path = "interactions/marquee.rs"] mod marquee; +#[path = "interactions/state_capture.rs"] +mod state_capture; fn component_set_error(field: &str, error: &str) -> bool { matches!(field, "componentProperties" | "variantProperties") && (error.contains("Component set for node has existing errors") @@ -323,6 +325,11 @@ pub(super) fn candidates(snapshot: &Snapshot, roots: &[String]) -> Vec Vec Option<&'static st } fn append_hints(result: &mut Value, data: &Value) { - let mut hints = String::new(); - for candidate in result_candidates(data) { - let hint = json!({"nodeId":candidate["nodeId"],"role":candidate["role"],"prototypeActions":candidate["prototypeActions"],"states":candidate["states"],"targetResolved":false}); - hints.push_str("\n// @devup-interaction "); - hints.push_str( - &hint - .to_string() - .replace('\u{2028}', "\\u2028") - .replace('\u{2029}', "\\u2029"), - ); - } - for field in ["tsx", "componentTsx", "responsiveTsx"] { - if let Some(code) = result.get(field).and_then(Value::as_str).map(str::to_owned) { - result[field] = json!(format!("{code}{hints}")); - } - } + result["interactionHints"] = json!(result_candidates(data).iter().map(|candidate| + json!({"nodeId":candidate["nodeId"],"role":candidate["role"],"prototypeActions":candidate["prototypeActions"],"states":candidate["states"],"targetResolved":false}) + ).collect::>()); } pub(super) fn evidence(snapshot: &Snapshot, roots: &[String]) -> Value { @@ -545,7 +538,7 @@ pub(super) fn evidence(snapshot: &Snapshot, roots: &[String]) -> Value { shared.truncate(MAX_DETAILS); let mut evidence = json!({"interactiveCandidates":candidates,"sharedComponentCandidates":shared,"widgetCandidates":widgets,"stateAndIconDetails":details, "interactionEvidenceLimits":limits, - "interactionReadErrors":read_errors,"stateProjection":"Variants/prototype actions appear in @devup-interaction TSX comments and structured metadata. They do not implement runtime state/events or add native semantics automatically.", + "interactionReadErrors":read_errors,"stateProjection":"Variants/prototype actions are delivered as structured sidecar metadata only. They do not implement runtime state/events or add native semantics automatically.", "sharedComponentLimitations":"Only explicit master/set identity is grouped. Old snapshots without identity cannot establish shared components; no name match is treated as proof.", "interactionCoverage":{"candidateCount":total_candidates,"returnedCandidateCount":candidates.len(),"wiredCount":0,"runtimeVerified":false,"scope":"Design candidates only; code wiring must be measured after implementation."}}); // Metadata stays bounded even when a capture contains large component text properties. diff --git a/crates/devup-mcp/src/server/interactions/state_capture.rs b/crates/devup-mcp/src/server/interactions/state_capture.rs new file mode 100644 index 00000000..3f7a6e83 --- /dev/null +++ b/crates/devup-mcp/src/server/interactions/state_capture.rs @@ -0,0 +1,256 @@ +use super::*; + +/// Capture inventory only: never a verdict that styles or runtime states work. +pub(super) fn for_candidate(snapshot: &Snapshot, node: &RawNode) -> Option { + if node.node_type != "INSTANCE" || !captured_default(node) { + return None; + } + let view = node.typed_view(); + let set_id = view.string("mainComponentSetId").or_else(|| { + let master = snapshot.nodes.get(view.string("mainComponentId")?)?; + let parent = master.typed_view().string("parentId")?; + (snapshot.nodes.get(parent)?.node_type == "COMPONENT_SET").then_some(parent) + })?; + let set = snapshot + .nodes + .get(set_id) + .filter(|set| set.node_type == "COMPONENT_SET"); + let declared_known = set + .and_then(|set| set.fields.get("childrenIds")) + .is_some_and(Value::is_array); + let declared = set + .map(|set| set.typed_view().child_ids().collect::>()) + .unwrap_or_default(); + let captured = declared + .iter() + .filter(|id| { + snapshot + .nodes + .get(**id) + .is_some_and(|n| n.node_type == "COMPONENT") + }) + .count(); + let mut missing = set.is_none() || declared.is_empty() || captured != declared.len(); + let mut unreadable = false; + let mut seen = BTreeSet::new(); + let mut pending = declared + .iter() + .map(|id| (*id).to_owned()) + .collect::>(); + if let Some(set) = set { + unreadable = !set.field_errors.is_empty(); + } + while let Some(id) = pending.pop() { + if !seen.insert(id.clone()) { + continue; + } + if let Some(node) = snapshot.nodes.get(&id) { + unreadable |= !node.field_errors.is_empty(); + pending.extend(node.typed_view().child_ids().map(str::to_owned)); + } else { + missing = true; + } + } + let state = if set.is_none() { + "missing" + } else if unreadable || !declared_known { + "unverified" + } else if missing { + "partial" + } else { + "captured" + }; + let mut result = json!({"state":state,"sourceComponentSetId":set_id, + "capturedVariantCount":captured,"declaredVariantCount":declared_known.then_some(declared.len()), + "captureInventoryOnly":true,"isStyleError":false,"runtimeStateVerified":false, + "evidenceLimit":"Declared variant capture only. A default page export or screenshot does not prove pseudo-state styling, semantic ownership or runtime behavior."}); + if state != "captured" { + if local_document_set(snapshot, set_id) && safe_source_url(snapshot, set_id) { + result["nextAction"] = json!({"tool":"devup_figma_export","arguments":{ + "url":format!("https://www.figma.com/design/{}?node-id={}",snapshot.file_key,set_id.replace(':',"-")), + "outputs":["componentTsx"],"debug":true}, + "how":"Export the full local component set, then apply its captured selectors at the actual focusable control boundary while preserving instance geometry."}); + } else { + result["lookupRequired"] = json!({"componentSetId":set_id,"output":"componentTsx", + "reason":"Owning local/library file is not proven by this snapshot. Resolve the component set owner before constructing an export URL."}); + } + } + Some(result) +} + +fn captured_default(node: &RawNode) -> bool { + node.typed_view() + .value("variantProperties") + .and_then(Value::as_object) + .is_some_and(|values| { + values.values().any(|v| { + v.as_str() + .is_some_and(|v| v.eq_ignore_ascii_case("default")) + }) + }) + || node + .typed_view() + .value("componentProperties") + .and_then(Value::as_object) + .is_some_and(|values| { + values.values().any(|v| { + v["type"] == "VARIANT" + && v["value"] + .as_str() + .is_some_and(|v| v.eq_ignore_ascii_case("default")) + }) + }) +} + +/// A set ID alone can refer to a remote library. Require its actual parent +/// chain and child edges to reach a captured document/page root in this file. +fn local_document_set(snapshot: &Snapshot, set_id: &str) -> bool { + let mut current = set_id; + let mut seen = BTreeSet::new(); + while seen.insert(current.to_owned()) { + let Some(node) = snapshot.nodes.get(current) else { + return false; + }; + if snapshot.roots.iter().any(|root| root == current) + && matches!(node.node_type.as_str(), "PAGE" | "DOCUMENT") + { + return true; + } + if node.field_errors.contains_key("parentId") { + return false; + } + let Some(parent_id) = node.typed_view().string("parentId") else { + return false; + }; + let Some(parent) = snapshot.nodes.get(parent_id) else { + return false; + }; + if parent.field_errors.contains_key("childrenIds") { + return false; + } + if !parent.typed_view().child_ids().any(|id| id == current) { + return false; + } + current = parent_id; + } + false +} +fn safe_source_url(snapshot: &Snapshot, set_id: &str) -> bool { + !snapshot.file_key.is_empty() + && snapshot.file_key.chars().all(|c| c.is_ascii_alphanumeric()) + && set_id.split_once(':').is_some_and(|(left, right)| { + !left.is_empty() + && !right.is_empty() + && left.chars().all(|c| c.is_ascii_digit()) + && right.chars().all(|c| c.is_ascii_digit()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + fn cta() -> Snapshot { + serde_json::from_str(include_str!( + "../../../../../fixtures/feedback/cta-default-state-capture.json" + )) + .unwrap() + } + fn full() -> Snapshot { + let mut source: Snapshot = serde_json::from_str(include_str!( + "../../../../../fixtures/feedback/button-state-component-set.json" + )) + .unwrap(); + source.nodes.extend(cta().nodes); + source + } + #[test] + fn actual_cta_default_warns_capture_missing_without_inventing_library_url() { + let source = cta(); + let value = for_candidate(&source, &source.nodes["45:2075"]).unwrap(); + assert_eq!(value["state"], "missing"); + assert_eq!(value["sourceComponentSetId"], "23:1613"); + assert_eq!(value["runtimeStateVerified"], false); + assert_eq!(value["isStyleError"], false); + assert!(value["nextAction"].is_null()); + assert!(value["lookupRequired"].is_object()); + } + #[test] + fn actual_full_button_set_is_captured_but_not_runtime_verified() { + let source = full(); + let value = for_candidate(&source, &source.nodes["45:2075"]).unwrap(); + assert_eq!(value["state"], "captured"); + assert_eq!(value["declaredVariantCount"], 32); + assert_eq!(value["capturedVariantCount"], 32); + assert_eq!(value["runtimeStateVerified"], false); + assert!(value["nextAction"].is_null()); + } + #[test] + fn incomplete_local_document_set_has_explicit_same_file_export_action() { + let mut source = full(); + source.nodes.remove("24:1856"); + source + .nodes + .get_mut("23:1613") + .unwrap() + .fields + .insert("parentId".into(), json!("local-page")); + source.nodes.insert( + "local-page".into(), + serde_json::from_value( + json!({"id":"local-page","type":"PAGE","fields":{"childrenIds":["23:1613"]}}), + ) + .unwrap(), + ); + source.roots = vec!["local-page".into()]; + let value = for_candidate(&source, &source.nodes["45:2075"]).unwrap(); + assert_eq!(value["state"], "partial"); + assert_eq!( + value["nextAction"]["arguments"]["outputs"], + json!(["componentTsx"]) + ); + let url = value["nextAction"]["arguments"]["url"].as_str().unwrap(); + assert!(url.contains(&source.file_key) && url.ends_with("23-1613")); + } + #[test] + fn business_selection_and_non_instance_do_not_become_style_failures() { + let mut source = cta(); + let node = source.nodes.get_mut("45:2075").unwrap(); + node.fields + .insert("variantProperties".into(), json!({"state":"selected"})); + node.fields.remove("componentProperties"); + assert!(for_candidate(&source, &source.nodes["45:2075"]).is_none()); + source.nodes.get_mut("45:2075").unwrap().node_type = "FRAME".into(); + assert!(for_candidate(&source, &source.nodes["45:2075"]).is_none()); + } + #[test] + fn unavailable_declared_children_is_unverified_not_a_zero_variant_claim() { + let mut source = full(); + source + .nodes + .get_mut("23:1613") + .unwrap() + .fields + .remove("childrenIds"); + let value = for_candidate(&source, &source.nodes["45:2075"]).unwrap(); + assert_eq!(value["state"], "unverified"); + assert!(value["declaredVariantCount"].is_null()); + assert!(value["nextAction"].is_null()); + assert_eq!(value["isStyleError"], false); + } + #[test] + fn evidence_candidate_carries_capture_inventory() { + let source = cta(); + let output = super::super::evidence(&source, &source.roots); + let candidate = output["interactiveCandidates"] + .as_array() + .unwrap() + .iter() + .find(|n| n["nodeId"] == "45:2075") + .unwrap(); + assert_eq!( + candidate["states"]["fullInteractiveStateCapture"]["state"], + "missing" + ); + assert_eq!(candidate["states"]["runtimeStateVerified"], false); + } +} diff --git a/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs b/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs index 291b79b8..73940405 100644 --- a/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs +++ b/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs @@ -348,6 +348,7 @@ fn section_index_from_payload(payload: &CollectedPayload) -> Option Value { + development_contract::attach(&mut result); if let Some(gap) = skill_gap(CODE_SKILLS.into_iter(), lookup, CODE_GAP_WHY) { result["skillGap"] = gap; } @@ -421,6 +422,7 @@ fn skill_gap<'a>( /// has no use for the vespera skill, and naming it there is the noise that /// teaches a reader to skip the field. fn with_context_skill_gap(mut result: Value, scope: &str, lookup: &skills::Lookup) -> Value { + development_contract::attach(&mut result); let found = |key: &str| { // `all` nests one object per scope; a single scope answers at the top // level. Both say `found`. diff --git a/crates/devup-mcp/src/server/mod_parts/state_and_services.rs b/crates/devup-mcp/src/server/mod_parts/state_and_services.rs index 4362bbc2..1720dab6 100644 --- a/crates/devup-mcp/src/server/mod_parts/state_and_services.rs +++ b/crates/devup-mcp/src/server/mod_parts/state_and_services.rs @@ -31,6 +31,10 @@ mod field_select; mod framework; #[path = "../guide.rs"] mod guide; +#[path = "../development_contract.rs"] +mod development_contract; +#[path = "../behavior_contracts.rs"] +mod behavior_contracts; #[path = "../interactions.rs"] mod interactions; #[path = "../mask_guard.rs"] diff --git a/crates/devup-mcp/src/server/output.rs b/crates/devup-mcp/src/server/output.rs index f29a7a5c..33a985a0 100644 --- a/crates/devup-mcp/src/server/output.rs +++ b/crates/devup-mcp/src/server/output.rs @@ -55,6 +55,8 @@ pub struct OutputTransaction { } struct StagedOutput { + immutable: bool, + registry_hash: Option>, name: String, target: OutputTarget, temp_path: PathBuf, @@ -63,7 +65,7 @@ struct StagedOutput { replaced: bool, } -#[derive(Clone)] +#[derive(Clone, PartialEq, Eq)] struct FileFingerprint { bytes: u64, sha256: [u8; 32], @@ -181,6 +183,34 @@ impl OutputTransaction { self.no_clobber = true; } + /// Public assets may be reused byte-for-byte, but are never overwritten. + pub fn stage_asset( + &mut self, + name: impl Into, + target: OutputTarget, + contents: &[u8], + ) -> Result<(), DevupError> { + self.stage(name, target, contents)?; + self.staged.last_mut().expect("staged asset").immutable = true; + Ok(()) + } + + /// Registry updates are allowed during scaffold creation, but refuse stale state. + pub fn stage_registry( + &mut self, + name: impl Into, + target: OutputTarget, + contents: &[u8], + expected_hash: Option, + ) -> Result<(), DevupError> { + self.stage(name, target, contents)?; + self.staged + .last_mut() + .expect("staged registry") + .registry_hash = Some(expected_hash); + Ok(()) + } + pub fn stage( &mut self, name: impl Into, @@ -219,6 +249,8 @@ impl OutputTransaction { } drop(file); self.staged.push(StagedOutput { + immutable: false, + registry_hash: None, name: name.into(), target, temp_path, @@ -250,7 +282,7 @@ impl OutputTransaction { "The output target must be a regular file or not exist yet.", )); } - Ok(_) if self.no_clobber => { + Ok(_) if self.no_clobber && !output.immutable && output.registry_hash.is_none() => { return Err(transaction_error(format!( "Scaffold file collision: {} already exists.", output.target.display_path.display() @@ -264,6 +296,28 @@ impl OutputTransaction { ))); } } + if let Some(expected) = &output.registry_hash { + let actual = match fingerprint(&output.target.root, &output.target.relative_path) { + Ok(value) => Some( + value + .sha256 + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(transaction_error(format!( + "Cannot inspect asset registry: {error}" + ))); + } + }; + if &actual != expected { + return Err(transaction_error( + "Asset registry changed during export; retry against its current state.", + )); + } + } } let result = self.replace_all(hook); @@ -299,10 +353,13 @@ impl OutputTransaction { fn replace_all(&mut self, hook: &mut impl CommitHook) -> std::io::Result<()> { let mut replaced = 0; for output in &mut self.staged { - if self.no_clobber { + if (self.no_clobber && output.registry_hash.is_none()) + || output.immutable + || output.registry_hash == Some(None) + { // Hard-link publication is atomic create-if-absent, unlike exists+rename. // The staging sibling is on the same filesystem and inside the held capability. - output + let publication = output .target .root .dir @@ -319,7 +376,31 @@ impl OutputTransaction { output.target.display_path.display() ), ) - })?; + }); + if let Err(error) = publication { + if output.immutable + && error.kind() == std::io::ErrorKind::AlreadyExists + && output + .target + .root + .dir + .symlink_metadata(&output.target.relative_path)? + .is_file() + && !output + .target + .root + .dir + .symlink_metadata(&output.target.relative_path)? + .file_type() + .is_symlink() + && fingerprint(&output.target.root, &output.target.relative_path)? + == fingerprint(&output.target.root, &output.temp_path)? + { + output.target.root.dir.remove_file(&output.temp_path)?; + continue; + } + return Err(error); + } output.replaced = true; output.target.root.dir.remove_file(&output.temp_path)?; replaced += 1; @@ -680,261 +761,5 @@ fn unique_sibling(target: &Path, kind: &str) -> PathBuf { } #[cfg(test)] -mod tests { - use std::{fs, io, path::PathBuf}; - - use super::{CommitHook, OutputPolicy, OutputTransaction}; - - struct FailAfterFirstReplacement; - - impl CommitHook for FailAfterFirstReplacement { - fn after_replacement(&mut self, replaced: usize) -> io::Result<()> { - if replaced == 1 { - Err(io::Error::other("injected replacement failure")) - } else { - Ok(()) - } - } - } - - struct FailCommitAndRestore; - - impl CommitHook for FailCommitAndRestore { - fn after_replacement(&mut self, replaced: usize) -> io::Result<()> { - if replaced == 1 { - Err(io::Error::other("injected commit failure")) - } else { - Ok(()) - } - } - - fn before_restore(&mut self, _backup: &std::path::Path) -> io::Result<()> { - Err(io::Error::other("injected restore failure")) - } - } - - fn unique_root() -> anyhow::Result { - // Clock precision can make concurrent fixtures share a timestamp. - // Reserve each root exclusively instead of reusing an existing directory. - for _ in 0..32 { - let path = std::env::temp_dir().join(format!( - "devup-mcp-transaction-rollback-{}-{:032x}", - std::process::id(), - rand::random::() - )); - match fs::create_dir(&path) { - Ok(()) => return Ok(path), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error.into()), - } - } - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "No exclusive fixture root available", - ) - .into()) - } - - #[test] - fn concurrent_fixture_roots_remain_distinct() -> anyhow::Result<()> { - let barrier = std::sync::Arc::new(std::sync::Barrier::new(16)); - let workers = (0..16) - .map(|_| { - let barrier = barrier.clone(); - std::thread::spawn(move || { - (0..32) - .map(|_| { - barrier.wait(); - unique_root() - }) - .collect::>() - }) - }) - .collect::>(); - let mut roots = std::collections::BTreeSet::new(); - for worker in workers { - for root in worker.join().expect("fixture allocator worker panicked") { - assert!( - roots.insert(root?), - "concurrent fixtures reused a directory" - ); - } - } - assert_eq!(roots.len(), 512); - for root in roots { - fs::remove_dir(root)?; - } - Ok(()) - } - - #[test] - fn reports_canonical_paths_from_a_noncanonical_root_before_and_after_write() - -> anyhow::Result<()> { - let root = unique_root()?; - fs::create_dir(root.join("child"))?; - let alias = root.join("child").join(".."); - let canonical = crate::test_paths::canonical(&root); - assert_ne!(alias, canonical); - assert_eq!(crate::test_paths::canonical(&alias), canonical); - - let expected = canonical.join("nested").join("Component.tsx"); - assert!(!expected.exists()); - let policy = OutputPolicy::from_roots(vec![alias.clone()])?; - // Alias parent components and traversal rejection are covered - // separately in tests/output_policy.rs with explicit prefix fixtures. - let absolute = policy.resolve(expected.to_str().unwrap())?; - let relative = policy.resolve("nested/Component.tsx")?; - assert_eq!(absolute.display_path(), expected); - assert_eq!(relative.display_path(), expected); - let mut transaction = OutputTransaction::new(); - transaction.stage("tsx", absolute, b"canonical output")?; - let written = transaction.commit()?; - assert_eq!(written["tsx"], expected.to_string_lossy()); - assert_eq!(fs::read(&expected)?, b"canonical output"); - drop(relative); - drop(policy); - fs::remove_dir_all(root)?; - Ok(()) - } - - #[test] - fn restores_every_original_after_a_runtime_commit_failure() -> anyhow::Result<()> { - let root = unique_root()?; - fs::write(root.join("first.txt"), b"old-first")?; - fs::write(root.join("second.txt"), b"old-second")?; - let policy = OutputPolicy::from_roots(vec![root.clone()])?; - let mut transaction = OutputTransaction::new(); - transaction.stage("first", policy.resolve("first.txt")?, b"new-first")?; - transaction.stage("second", policy.resolve("second.txt")?, b"new-second")?; - - let error = transaction - .commit_with_hook(&mut FailAfterFirstReplacement) - .expect_err("the injected failure must abort the transaction"); - - assert!(error.message.contains("commit")); - assert_eq!(fs::read(root.join("first.txt"))?, b"old-first"); - assert_eq!(fs::read(root.join("second.txt"))?, b"old-second"); - let internal_files = fs::read_dir(&root)? - .filter_map(Result::ok) - .filter_map(|entry| entry.file_name().into_string().ok()) - .filter(|name| name.starts_with(".devup-")) - .collect::>(); - assert!(internal_files.is_empty(), "stale files: {internal_files:?}"); - - drop(transaction); - drop(policy); - fs::remove_dir_all(root)?; - Ok(()) - } - - #[test] - fn w7_no_clobber_refuses_a_late_collision_and_rolls_back_published_files() -> anyhow::Result<()> - { - struct CreateCollision(PathBuf); - impl CommitHook for CreateCollision { - fn after_replacement(&mut self, replaced: usize) -> io::Result<()> { - if replaced == 1 { - fs::write(self.0.join("second.tsx"), b"concurrent original")?; - } - Ok(()) - } - } - let root = std::env::temp_dir().join(format!( - "devup-w7-transaction-{:016x}", - rand::random::() - )); - fs::create_dir(&root)?; - let policy = OutputPolicy::from_roots(vec![root.clone()])?; - let mut transaction = OutputTransaction::new(); - transaction.refuse_existing(); - transaction.stage("page", policy.resolve("first.tsx")?, b"new page")?; - transaction.stage("screen", policy.resolve("second.tsx")?, b"new screen")?; - let error = transaction - .commit_with_hook(&mut CreateCollision(root.clone())) - .unwrap_err(); - assert!(error.message.contains("second.tsx")); - assert!(!root.join("first.tsx").exists()); - assert_eq!(fs::read(root.join("second.tsx"))?, b"concurrent original"); - drop(transaction); - assert_eq!(fs::read_dir(&root)?.count(), 1); - drop(policy); - fs::remove_dir_all(root)?; - Ok(()) - } - - #[test] - fn w7_later_scaffold_file_failure_restores_originals_in_reverse_order() -> anyhow::Result<()> { - struct FailSecond(Vec); - impl CommitHook for FailSecond { - fn after_replacement(&mut self, replaced: usize) -> io::Result<()> { - if replaced == 2 { - Err(io::Error::other("later scaffold failure")) - } else { - Ok(()) - } - } - fn before_restore(&mut self, backup: &std::path::Path) -> io::Result<()> { - self.0.push(backup.to_string_lossy().into_owned()); - Ok(()) - } - } - let root = std::env::temp_dir().join(format!( - "devup-w7-transaction-{:016x}", - rand::random::() - )); - fs::create_dir(&root)?; - let page = "src/app/proofread/page.tsx"; - let screen = "components/pages/proofread/Proofread.tsx"; - for name in [page, screen] { - fs::create_dir_all(root.join(name).parent().unwrap())?; - fs::write(root.join(name), name)?; - } - let policy = OutputPolicy::from_roots(vec![root.clone()])?; - let mut transaction = OutputTransaction::new(); - transaction.stage("pageScaffold:page", policy.resolve(page)?, b"new page")?; - transaction.stage( - "pageScaffold:component", - policy.resolve(screen)?, - b"new screen", - )?; - let mut hook = FailSecond(Vec::new()); - assert!(transaction.commit_with_hook(&mut hook).is_err()); - for name in [page, screen] { - assert_eq!(fs::read_to_string(root.join(name))?, name); - } - assert!(hook.0[0].contains("components")); - assert!(hook.0[1].contains("src")); - drop(transaction); - drop(policy); - fs::remove_dir_all(root)?; - Ok(()) - } - - #[test] - fn reports_and_retains_a_backup_when_rollback_restoration_fails() -> anyhow::Result<()> { - let root = unique_root()?; - fs::write(root.join("first.txt"), b"old-first")?; - let policy = OutputPolicy::from_roots(vec![root.clone()])?; - let mut transaction = OutputTransaction::new(); - transaction.stage("first", policy.resolve("first.txt")?, b"new-first")?; - - let error = transaction - .commit_with_hook(&mut FailCommitAndRestore) - .expect_err("the injected rollback failure must be reported"); - - assert_eq!(error.details["phase"], "rollback"); - assert_eq!(error.details["rollback"]["complete"], false); - let recovery_path = error.details["rollback"]["recoveryPaths"][0] - .as_str() - .expect("retained backup path"); - assert!(std::path::Path::new(recovery_path).exists()); - assert_eq!(fs::read(recovery_path)?, b"old-first"); - assert!(!root.join("first.txt").exists()); - - fs::remove_file(recovery_path)?; - drop(transaction); - drop(policy); - fs::remove_dir_all(root)?; - Ok(()) - } -} +#[path = "output_tests.rs"] +mod tests; diff --git a/crates/devup-mcp/src/server/output_tests.rs b/crates/devup-mcp/src/server/output_tests.rs new file mode 100644 index 00000000..f1340609 --- /dev/null +++ b/crates/devup-mcp/src/server/output_tests.rs @@ -0,0 +1,254 @@ +use std::{fs, io, path::PathBuf}; + +use super::{CommitHook, OutputPolicy, OutputTransaction}; + +struct FailAfterFirstReplacement; + +impl CommitHook for FailAfterFirstReplacement { + fn after_replacement(&mut self, replaced: usize) -> io::Result<()> { + if replaced == 1 { + Err(io::Error::other("injected replacement failure")) + } else { + Ok(()) + } + } +} + +struct FailCommitAndRestore; + +impl CommitHook for FailCommitAndRestore { + fn after_replacement(&mut self, replaced: usize) -> io::Result<()> { + if replaced == 1 { + Err(io::Error::other("injected commit failure")) + } else { + Ok(()) + } + } + + fn before_restore(&mut self, _backup: &std::path::Path) -> io::Result<()> { + Err(io::Error::other("injected restore failure")) + } +} + +fn unique_root() -> anyhow::Result { + // Clock precision can make concurrent fixtures share a timestamp. + // Reserve each root exclusively instead of reusing an existing directory. + for _ in 0..32 { + let path = std::env::temp_dir().join(format!( + "devup-mcp-transaction-rollback-{}-{:032x}", + std::process::id(), + rand::random::() + )); + match fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "No exclusive fixture root available", + ) + .into()) +} + +#[test] +fn concurrent_fixture_roots_remain_distinct() -> anyhow::Result<()> { + let barrier = std::sync::Arc::new(std::sync::Barrier::new(16)); + let workers = (0..16) + .map(|_| { + let barrier = barrier.clone(); + std::thread::spawn(move || { + (0..32) + .map(|_| { + barrier.wait(); + unique_root() + }) + .collect::>() + }) + }) + .collect::>(); + let mut roots = std::collections::BTreeSet::new(); + for worker in workers { + for root in worker.join().expect("fixture allocator worker panicked") { + assert!( + roots.insert(root?), + "concurrent fixtures reused a directory" + ); + } + } + assert_eq!(roots.len(), 512); + for root in roots { + fs::remove_dir(root)?; + } + Ok(()) +} + +#[test] +fn reports_canonical_paths_from_a_noncanonical_root_before_and_after_write() -> anyhow::Result<()> { + let root = unique_root()?; + fs::create_dir(root.join("child"))?; + let alias = root.join("child").join(".."); + let canonical = crate::test_paths::canonical(&root); + assert_ne!(alias, canonical); + assert_eq!(crate::test_paths::canonical(&alias), canonical); + + let expected = canonical.join("nested").join("Component.tsx"); + assert!(!expected.exists()); + let policy = OutputPolicy::from_roots(vec![alias.clone()])?; + // Alias parent components and traversal rejection are covered + // separately in tests/output_policy.rs with explicit prefix fixtures. + let absolute = policy.resolve(expected.to_str().unwrap())?; + let relative = policy.resolve("nested/Component.tsx")?; + assert_eq!(absolute.display_path(), expected); + assert_eq!(relative.display_path(), expected); + let mut transaction = OutputTransaction::new(); + transaction.stage("tsx", absolute, b"canonical output")?; + let written = transaction.commit()?; + assert_eq!(written["tsx"], expected.to_string_lossy()); + assert_eq!(fs::read(&expected)?, b"canonical output"); + drop(relative); + drop(policy); + fs::remove_dir_all(root)?; + Ok(()) +} + +#[test] +fn restores_every_original_after_a_runtime_commit_failure() -> anyhow::Result<()> { + let root = unique_root()?; + fs::write(root.join("first.txt"), b"old-first")?; + fs::write(root.join("second.txt"), b"old-second")?; + let policy = OutputPolicy::from_roots(vec![root.clone()])?; + let mut transaction = OutputTransaction::new(); + transaction.stage("first", policy.resolve("first.txt")?, b"new-first")?; + transaction.stage("second", policy.resolve("second.txt")?, b"new-second")?; + + let error = transaction + .commit_with_hook(&mut FailAfterFirstReplacement) + .expect_err("the injected failure must abort the transaction"); + + assert!(error.message.contains("commit")); + assert_eq!(fs::read(root.join("first.txt"))?, b"old-first"); + assert_eq!(fs::read(root.join("second.txt"))?, b"old-second"); + let internal_files = fs::read_dir(&root)? + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.starts_with(".devup-")) + .collect::>(); + assert!(internal_files.is_empty(), "stale files: {internal_files:?}"); + + drop(transaction); + drop(policy); + fs::remove_dir_all(root)?; + Ok(()) +} + +#[test] +fn w7_no_clobber_refuses_a_late_collision_and_rolls_back_published_files() -> anyhow::Result<()> { + struct CreateCollision(PathBuf); + impl CommitHook for CreateCollision { + fn after_replacement(&mut self, replaced: usize) -> io::Result<()> { + if replaced == 1 { + fs::write(self.0.join("second.tsx"), b"concurrent original")?; + } + Ok(()) + } + } + let root = std::env::temp_dir().join(format!( + "devup-w7-transaction-{:016x}", + rand::random::() + )); + fs::create_dir(&root)?; + let policy = OutputPolicy::from_roots(vec![root.clone()])?; + let mut transaction = OutputTransaction::new(); + transaction.refuse_existing(); + transaction.stage("page", policy.resolve("first.tsx")?, b"new page")?; + transaction.stage("screen", policy.resolve("second.tsx")?, b"new screen")?; + let error = transaction + .commit_with_hook(&mut CreateCollision(root.clone())) + .unwrap_err(); + assert!(error.message.contains("second.tsx")); + assert!(!root.join("first.tsx").exists()); + assert_eq!(fs::read(root.join("second.tsx"))?, b"concurrent original"); + drop(transaction); + assert_eq!(fs::read_dir(&root)?.count(), 1); + drop(policy); + fs::remove_dir_all(root)?; + Ok(()) +} + +#[test] +fn w7_later_scaffold_file_failure_restores_originals_in_reverse_order() -> anyhow::Result<()> { + struct FailSecond(Vec); + impl CommitHook for FailSecond { + fn after_replacement(&mut self, replaced: usize) -> io::Result<()> { + if replaced == 2 { + Err(io::Error::other("later scaffold failure")) + } else { + Ok(()) + } + } + fn before_restore(&mut self, backup: &std::path::Path) -> io::Result<()> { + self.0.push(backup.to_string_lossy().into_owned()); + Ok(()) + } + } + let root = std::env::temp_dir().join(format!( + "devup-w7-transaction-{:016x}", + rand::random::() + )); + fs::create_dir(&root)?; + let page = "src/app/proofread/page.tsx"; + let screen = "components/pages/proofread/Proofread.tsx"; + for name in [page, screen] { + fs::create_dir_all(root.join(name).parent().unwrap())?; + fs::write(root.join(name), name)?; + } + let policy = OutputPolicy::from_roots(vec![root.clone()])?; + let mut transaction = OutputTransaction::new(); + transaction.stage("pageScaffold:page", policy.resolve(page)?, b"new page")?; + transaction.stage( + "pageScaffold:component", + policy.resolve(screen)?, + b"new screen", + )?; + let mut hook = FailSecond(Vec::new()); + assert!(transaction.commit_with_hook(&mut hook).is_err()); + for name in [page, screen] { + assert_eq!(fs::read_to_string(root.join(name))?, name); + } + assert!(hook.0[0].contains("components")); + assert!(hook.0[1].contains("src")); + drop(transaction); + drop(policy); + fs::remove_dir_all(root)?; + Ok(()) +} + +#[test] +fn reports_and_retains_a_backup_when_rollback_restoration_fails() -> anyhow::Result<()> { + let root = unique_root()?; + fs::write(root.join("first.txt"), b"old-first")?; + let policy = OutputPolicy::from_roots(vec![root.clone()])?; + let mut transaction = OutputTransaction::new(); + transaction.stage("first", policy.resolve("first.txt")?, b"new-first")?; + + let error = transaction + .commit_with_hook(&mut FailCommitAndRestore) + .expect_err("the injected rollback failure must be reported"); + + assert_eq!(error.details["phase"], "rollback"); + assert_eq!(error.details["rollback"]["complete"], false); + let recovery_path = error.details["rollback"]["recoveryPaths"][0] + .as_str() + .expect("retained backup path"); + assert!(std::path::Path::new(recovery_path).exists()); + assert_eq!(fs::read(recovery_path)?, b"old-first"); + assert!(!root.join("first.txt").exists()); + + fs::remove_file(recovery_path)?; + drop(transaction); + drop(policy); + fs::remove_dir_all(root)?; + Ok(()) +} diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 4b5d265d..413a0eb3 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -13,6 +13,7 @@ include!("projection/artifact_metadata.rs"); include!("projection/verification_scope.rs"); include!("projection/diagnostics.rs"); include!("projection/component_projection.rs"); +include!("projection/overlay_anchor_delivery.rs"); include!("projection/instance_overrides.rs"); include!("projection/completion.rs"); include!("projection/diagnostic_screens.rs"); diff --git a/crates/devup-mcp/src/server/projection/asset_paths.rs b/crates/devup-mcp/src/server/projection/asset_paths.rs index 3541cda5..91988eb2 100644 --- a/crates/devup-mcp/src/server/projection/asset_paths.rs +++ b/crates/devup-mcp/src/server/projection/asset_paths.rs @@ -1,176 +1,84 @@ -/// Reconcile only exports backed by verified bytes. Preserve an entry per node, -/// but make identical content use one file and one code reference. -fn reconcile_asset_paths( - manifest: &mut AssetManifest, - output_paths: &mut BTreeMap, - public_root: Option<&std::path::Path>, - policy: &OutputPolicy, -) -> Result, DevupError> { - // This verifies decoded length/hash as well as MIME before any write. - projected_asset_outputs(manifest)?; - let mut groups = BTreeMap::<_, Vec>::new(); - for (index, asset) in manifest.assets.iter().enumerate() { - if asset.status != AssetStatus::Exported { - continue; - } - let bytes = STANDARD - .decode(asset.data_base64.as_deref().unwrap_or_default()) - .map_err(|_| { - DevupError::new( - ErrorCode::DevupCodegenFailed, - "Invalid exported asset base64.", - false, - ) - })?; - let key = ( - sha256_hex(&bytes), - asset.format.map(|format| format.extension()), - asset.mime_type.clone(), - asset - .variants - .iter() - .map(|v| (v.sha256.clone(), v.width, v.height)) - .collect::>(), - ); - groups.entry(key).or_default().push(index); - } - let mut replacements = BTreeMap::new(); - for members in groups.values() { - // Canonical code names must survive a later code-only projection. - // Select the placeholder independently from the requested file target. - let canonical_path = members - .iter() - .find_map(|&index| manifest.assets[index].path.clone()); - let output_path = members - .iter() - .find_map(|&index| output_paths.get(&manifest.assets[index].asset_id).cloned()); - let canonical_path = canonical_path.map(|p| { - if manifest.assets[members[0]].format == Some(devup_mcp_figma::AssetFormat::Webp) { - p.rsplit_once('.') - .map_or_else(|| format!("{p}.webp"), |(stem, _)| format!("{stem}.webp")) - } else { - p - } - }); - let path = if let (Some(root), Some(output)) = (public_root, &output_path) { - Some(super::validation::public_asset_url( - root, - policy.resolve(output)?.display_path(), - )?) - } else { - canonical_path - }; - for &index in members { - let asset = &mut manifest.assets[index]; - if let Some(output) = &output_path { - output_paths.insert(asset.asset_id.clone(), output.clone()); - } - let old_path = std::mem::replace(&mut asset.path, path.clone()); - if let (Some(old), Some(new)) = (&old_path, &path) - && !asset.variants.is_empty() - { - replacements.insert( - asset_policy::variant_path(old), - asset_policy::variant_path(new), - ); - } - if let (Some(old), Some(new)) = (old_path, path.as_ref()) - && old != *new - && let Some(previous) = replacements.insert(old, new.clone()) - && previous != *new - { - return Err(DevupError::new( - ErrorCode::DevupInvalidInput, - "One placeholder refers to different exported assets. Use assetNamesPerNode:true.", - false, - )); - } - } - } - Ok(replacements) -} - -fn rewrite_asset_references(code: &str, paths: &BTreeMap) -> String { - // One pass prevents a replacement from becoming another replacement's input. - let mut result = String::with_capacity(code.len()); - let mut cursor = 0; - while cursor < code.len() { - let rest = &code[cursor..]; - let matched = paths.iter().find(|(old, _)| { - rest.starts_with(old.as_str()) - && (cursor == 0 - || matches!( - code.as_bytes()[cursor - 1], - b'"' | b'\'' | b'(' | b' ' | b',' - )) - && rest - .as_bytes() - .get(old.len()) - .is_none_or(|next| matches!(next, b'"' | b'\'' | b')' | b' ' | b',')) - }); - if let Some((old, new)) = matched { - result.push_str(new); - cursor += old.len(); - } else { - let character = rest.chars().next().expect("nonempty remainder"); - result.push(character); - cursor += character.len_utf8(); - } - } - result -} - -fn rewrite_result_asset_references(value: &mut Value, paths: &BTreeMap) { - match value { - Value::Object(object) => { - for (key, value) in object { - if matches!( - key.as_str(), - "tsx" - | "componentTsx" - | "responsiveTsx" - | "generatedProperty" - | "generatedSource" - | "srcSet" - ) { - match value { - Value::String(code) => { - *code = if key == "srcSet" { - rewrite_asset_references(code, paths) - } else { - rewrite_asset_references(&with_host_safe_asset_paths(code), paths) - } - } - Value::Array(items) => { - for item in items { - if let Value::String(code) = item { - *code = rewrite_asset_references( - &with_host_safe_asset_paths(code), - paths, - ); - } else { - rewrite_result_asset_references(item, paths); - } - } - } - _ => rewrite_result_asset_references(value, paths), - } - } else if matches!(key.as_str(), "assetPath" | "path") { - if let Value::String(path) = value - && let Some(replacement) = paths.get(path) - { - *path = replacement.clone(); - } - } else if !matches!(key.as_str(), "rawSnapshot" | "rawPayload" | "originalValue") { - rewrite_result_asset_references(value, paths); - } - } - } - Value::Array(values) => { - for value in values { - rewrite_result_asset_references(value, paths); - } - } - _ => {} - } -} +fn rewrite_asset_references(code: &str, paths: &BTreeMap) -> String { + // One pass prevents a replacement from becoming another replacement's input. + let mut result = String::with_capacity(code.len()); + let mut cursor = 0; + while cursor < code.len() { + let rest = &code[cursor..]; + let matched = paths.iter().find(|(old, _)| { + rest.starts_with(old.as_str()) + && (cursor == 0 + || matches!( + code.as_bytes()[cursor - 1], + b'"' | b'\'' | b'(' | b' ' | b',' + )) + && rest + .as_bytes() + .get(old.len()) + .is_none_or(|next| matches!(next, b'"' | b'\'' | b')' | b' ' | b',')) + }); + if let Some((old, new)) = matched { + result.push_str(new); + cursor += old.len(); + } else { + let character = rest.chars().next().expect("nonempty remainder"); + result.push(character); + cursor += character.len_utf8(); + } + } + result +} + +fn rewrite_result_asset_references(value: &mut Value, paths: &BTreeMap) { + match value { + Value::Object(object) => { + for (key, value) in object { + if matches!( + key.as_str(), + "tsx" + | "componentTsx" + | "responsiveTsx" + | "generatedProperty" + | "generatedSource" + | "srcSet" + ) { + match value { + Value::String(code) => { + *code = if key == "srcSet" { + rewrite_asset_references(code, paths) + } else { + rewrite_asset_references(&with_host_safe_asset_paths(code), paths) + } + } + Value::Array(items) => { + for item in items { + if let Value::String(code) = item { + *code = rewrite_asset_references( + &with_host_safe_asset_paths(code), + paths, + ); + } else { + rewrite_result_asset_references(item, paths); + } + } + } + _ => rewrite_result_asset_references(value, paths), + } + } else if matches!(key.as_str(), "assetPath" | "path") { + if let Value::String(path) = value + && let Some(replacement) = paths.get(path) + { + *path = replacement.clone(); + } + } else if !matches!(key.as_str(), "rawSnapshot" | "rawPayload" | "originalValue") { + rewrite_result_asset_references(value, paths); + } + } + } + Value::Array(values) => { + for value in values { + rewrite_result_asset_references(value, paths); + } + } + _ => {} + } +} diff --git a/crates/devup-mcp/src/server/projection/asset_reference_names.rs b/crates/devup-mcp/src/server/projection/asset_reference_names.rs index 6a2cc56e..23327759 100644 --- a/crates/devup-mcp/src/server/projection/asset_reference_names.rs +++ b/crates/devup-mcp/src/server/projection/asset_reference_names.rs @@ -42,7 +42,20 @@ fn with_host_safe_asset_paths(code: &str) -> String { { end = after + filename.trim_end().len(); } - safe.push_str(&host_safe_file_name(&rest[after..end])); + let candidate = &rest[after..end]; + // Canonical caller-selected URLs are already escaped by public_asset_url. + // Sanitizing percent escapes would change the selected filename on replay. + if candidate.contains('%') { + safe.push_str(candidate); + } else { + safe.push_str( + &candidate + .split('/') + .map(host_safe_file_name) + .collect::>() + .join("/"), + ); + } rest = &rest[end..]; } } diff --git a/crates/devup-mcp/src/server/projection/canonical_assets.rs b/crates/devup-mcp/src/server/projection/canonical_assets.rs new file mode 100644 index 00000000..e7aa2561 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/canonical_assets.rs @@ -0,0 +1,647 @@ +//! Project-wide asset identities and semantic public paths, committed with their assets. +use super::*; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +#[derive(Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Record { + path: String, + identity: String, + sha256: String, + mime_type: String, + #[serde(default)] + mask: bool, + #[serde(default)] + geometry: Option, + #[serde(default)] + sources: BTreeSet, +} + +#[derive(Default, serde::Serialize, serde::Deserialize)] +struct Registry { + version: u32, + assets: Vec, +} + +pub(super) struct Resolution { + pub replacements: BTreeMap, + pub registry_write: Option<(String, Vec, Option)>, + pub registry: Value, + pub filters: BTreeMap, +} + +struct Identity { + key: String, + geometry: Option, + filter: Option, +} + +fn error(message: impl Into) -> DevupError { + DevupError::new(ErrorCode::DevupInvalidInput, message, false) +} + +fn meaningful_name(path: &str) -> bool { + let decoded = super::super::mask_guard::percent_decode(path); + let path = decoded.as_str(); + let name = path + .rsplit('/') + .next() + .unwrap_or(path) + .rsplit_once('.') + .map_or(path, |(name, _)| name); + name.split(['-', '_']).any(|part| { + let part = part.trim_end_matches(|c: char| c.is_ascii_digit()); + !part.is_empty() + && !part.chars().all(|c| c.is_ascii_digit()) + && !(part.len() >= 16 && part.chars().all(|c| c.is_ascii_hexdigit())) + && ![ + "image", + "asset", + "frame", + "screen", + "rectangle", + "vector", + "group", + "layer", + "shape", + "node", + "fill", + "section", + "instance", + "component", + "default", + "variant", + "small", + "large", + "mobile", + "desktop", + "alternative", + ] + .contains(&part.to_lowercase().as_str()) + }) +} + +fn identity(asset: &devup_mcp_figma::AssetManifestEntry, bytes: &[u8], mask: bool) -> Identity { + let mime = asset + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"); + let svg = (mime == "image/svg+xml") + .then(|| super::svg_identity::identity(bytes, mask)) + .flatten(); + let variants = asset + .variants + .iter() + .map(|v| format!("{}:{}:{}:{}", v.density, v.width, v.height, v.sha256)) + .collect::>() + .join("|"); + Identity { + key: format!( + "{mime}:{}:{variants}", + svg.as_ref() + .map_or_else(|| sha256_hex(bytes), |svg| svg.key.clone()) + ), + geometry: svg.as_ref().map(|svg| svg.geometry.clone()), + filter: svg.and_then(|svg| svg.grayscale_filter), + } +} + +fn codes(value: &Value, out: &mut Vec) { + match value { + Value::Object(object) => { + for (key, value) in object { + if matches!(key.as_str(), "tsx" | "componentTsx" | "responsiveTsx") { + if let Some(source) = value.as_str() { + out.push(with_host_safe_asset_paths(source)); + } + } else if !matches!(key.as_str(), "rawSnapshot" | "rawPayload" | "originalValue") { + codes(value, out); + } + } + } + Value::Array(values) => { + for value in values { + codes(value, out); + } + } + _ => {} + } +} + +fn usage(sources: &[String], path: &str) -> (bool, bool) { + let mut mask = false; + let mut image = false; + let mut other = false; + for source in sources { + let attributes = devup_mcp_devup_ui::provenance::attributes::generated_attributes(source); + for attr in attributes.iter().filter(|a| references(&a.source, path)) { + if attr.name == "maskImage" { + mask = true; + } else if attr.name == "src" + && matches!(attr.tag.as_str(), "Image" | "img") + && !attributes + .iter() + .any(|a| a.element == attr.element && a.name == "filter") + { + image = true; + } else { + other = true; + } + } + } + (mask && !image && !other, image && !mask && !other) +} + +fn references(source: &str, path: &str) -> bool { + source.match_indices(path).any(|(at, _)| { + (at == 0 || matches!(source.as_bytes()[at - 1], b'"' | b'\'' | b'(' | b' ' | b',')) + && source + .as_bytes() + .get(at + path.len()) + .is_none_or(|next| matches!(next, b'"' | b'\'' | b')' | b' ' | b',')) + }) +} + +fn read(path: &Path, policy: &OutputPolicy, limit: usize) -> Result>, DevupError> { + let target = policy.resolve(&path.to_string_lossy())?; + match std::fs::symlink_metadata(target.display_path()) { + Ok(_) => target.read_bounded(limit).map(Some), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(error( + "Cannot inspect the canonical asset registry or file.", + )), + } +} + +fn public_target(root: &Path, url: &str, policy: &OutputPolicy) -> Result { + let decoded = super::super::mask_guard::percent_decode(url); + let url = decoded.as_str(); + if !url.starts_with('/') + || url.starts_with("//") + || url.split('/').skip(1).any(|part| { + part.is_empty() + || part == "." + || part == ".." + || part.contains(['\\', '?', '#', '\n', '\r']) + }) + { + return Err(error("Asset registry contains an unsafe public path.")); + } + let target = policy.resolve(&root.join(url.trim_start_matches('/')).to_string_lossy())?; + Ok(target.display_path().to_path_buf()) +} + +fn registry_path(root: &Path, policy: &OutputPolicy) -> Result { + let sibling = root + .parent() + .unwrap_or(root) + .join(".devup/public-assets.json"); + // A caller may scope writes to the public root itself. Never widen that authority + // merely to put metadata in the usual application sibling directory. + let target = policy + .resolve(&sibling.to_string_lossy()) + .or_else(|_| policy.resolve(&root.join(".devup/public-assets.json").to_string_lossy()))?; + Ok(target.display_path().to_path_buf()) +} + +fn imported_records( + root: &Path, + project: Option<&str>, + policy: &OutputPolicy, +) -> Result, DevupError> { + let mut records = Vec::new(); + let registry = registry_path(root, policy)?; + if let Some(bytes) = read(®istry, policy, 4 * 1024 * 1024)? { + let stored: Registry = serde_json::from_slice(&bytes).map_err(|_| { + error("Canonical asset registry JSON is invalid; repair it before export.") + })?; + if stored.version != 1 { + return Err(error("Unsupported canonical asset registry version.")); + } + records = stored.assets; + } + if let Some(project) = project { + let path = Path::new(project).join("docs/public-assets.json"); + if let Some(bytes) = read(&path, policy, 4 * 1024 * 1024)? { + let value: Value = serde_json::from_slice(&bytes) + .map_err(|_| error("Project public asset registry JSON is invalid."))?; + for entry in value["assets"].as_array().into_iter().flatten() { + let (Some(url), Some(hash)) = (entry["path"].as_str(), entry["sha256"].as_str()) + else { + continue; + }; + if records.iter().any(|record| record.path == url) { + continue; + } + let path = public_target(root, url, policy)?; + let Some(bytes) = read(&path, policy, devup_mcp_figma::MAX_ASSET_BYTES)? else { + continue; + }; + if sha256_hex(&bytes) != hash { + continue; + } + let mime = if url.ends_with(".svg") { + "image/svg+xml" + } else if url.ends_with(".webp") { + "image/webp" + } else if url.ends_with(".png") { + "image/png" + } else if url.ends_with(".jpg") || url.ends_with(".jpeg") { + "image/jpeg" + } else if url.ends_with(".pdf") { + "application/pdf" + } else { + continue; + }; + let mask = entry["usage"] == "mask"; + let svg = (mime == "image/svg+xml") + .then(|| super::svg_identity::identity(&bytes, mask)) + .flatten(); + records.push(Record { + path: url.into(), + identity: format!( + "{mime}:{}:", + svg.as_ref().map_or_else(|| hash.into(), |s| s.key.clone()) + ), + sha256: hash.into(), + mime_type: mime.into(), + mask, + geometry: svg.map(|s| s.geometry), + sources: BTreeSet::new(), + }); + } + } + } + Ok(records) +} + +pub(super) fn reconcile( + manifest: &mut AssetManifest, + outputs: &mut BTreeMap, + root: Option<&Path>, + project: Option<&str>, + policy: &OutputPolicy, + result: &Map, +) -> Result { + let normalized_root = root + .map(|root| { + policy + .resolve(&root.join(".devup-root").to_string_lossy()) + .map(|target| { + target + .display_path() + .parent() + .expect("asset parent") + .to_path_buf() + }) + }) + .transpose()?; + let root = normalized_root.as_deref(); + let registry_base = if let Some(root) = root { + read(®istry_path(root, policy)?, policy, 4 * 1024 * 1024)? + } else { + None + }; + projected_asset_outputs(manifest)?; + let mut sources = Vec::new(); + for value in result.values() { + codes(value, &mut sources); + } + // Include top-level TSX fields as well as frame outputs. + for key in ["tsx", "componentTsx", "responsiveTsx"] { + if let Some(code) = result.get(key).and_then(Value::as_str) { + sources.push(with_host_safe_asset_paths(code)); + } + } + let imported = if let Some(root) = root { + imported_records(root, project, policy)? + } else { + Vec::new() + }; + let mut valid = Vec::new(); + for record in imported { + if let Some(root) = root + && let Some(bytes) = read( + &public_target(root, &record.path, policy)?, + policy, + devup_mcp_figma::MAX_ASSET_BYTES, + )? + && sha256_hex(&bytes) == record.sha256 + { + valid.push(record); + } + } + let mut registry = Registry { + version: 1, + assets: valid, + }; + let mut captures = Vec::new(); + for (index, asset) in manifest + .assets + .iter() + .enumerate() + .filter(|(_, a)| a.status == AssetStatus::Exported) + { + let bytes = STANDARD + .decode(asset.data_base64.as_deref().unwrap_or_default()) + .map_err(|_| error("Invalid asset base64."))?; + let path = asset.path.as_deref().unwrap_or("/images/image.png"); + let (mask, image) = usage(&sources, path); + let identity = identity(asset, &bytes, mask); + captures.push((index, bytes, identity, mask, image)); + } + captures.sort_by_key(|(_, _, identity, _, _)| identity.filter.is_some()); + let mut replacements = BTreeMap::new(); + let mut naming_review = Vec::new(); + let mut filters = BTreeMap::new(); + let mut bytes_by_path = BTreeMap::>::new(); + for (index, acquired, identity, mask, image) in captures { + let asset = &mut manifest.assets[index]; + let old = asset.path.clone().unwrap_or_else(|| { + format!( + "/images/{}", + host_safe_file_name(asset.suggested_filename.as_deref().unwrap_or("image.png")) + ) + }); + let mime = asset.mime_type.clone().unwrap_or_default(); + let mut existing = registry + .assets + .iter() + .position(|r| r.identity == identity.key); + let mut filter = None; + if existing.is_none() && image && identity.filter.is_some() { + existing = registry.assets.iter().position(|r| { + r.geometry.is_some() + && r.geometry == identity.geometry + && r.mime_type == mime + && !r.mask + }); + if existing.is_some() { + filter = identity.filter.clone(); + } + } + let mut canonical = None; + if let Some(position) = existing { + let record = ®istry.assets[position]; + let bytes = if let Some(bytes) = bytes_by_path.get(&record.path) { + Some(bytes.clone()) + } else if let Some(root) = root { + read( + &public_target(root, &record.path, policy)?, + policy, + devup_mcp_figma::MAX_ASSET_BYTES, + )? + } else { + None + }; + if let Some(bytes) = bytes + && sha256_hex(&bytes) == record.sha256 + { + registry.assets[position] + .sources + .insert(asset.asset_id.clone()); + canonical = Some((registry.assets[position].path.clone(), bytes)); + } else { + existing = None; + filter = None; + } + } + let (path, bytes) = if let Some(canonical) = canonical { + canonical + } else { + let explicit = root.is_some() && outputs.contains_key(&asset.asset_id); + let proposed = if let (Some(root), Some(output)) = (root, outputs.get(&asset.asset_id)) + { + super::super::validation::public_asset_url( + root, + policy.resolve(output)?.display_path(), + )? + } else { + old.clone() + }; + let extension = asset + .format + .map_or("bin", devup_mcp_figma::AssetFormat::extension); + let stem = proposed + .rsplit_once('/') + .map_or(proposed.as_str(), |(_, s)| s) + .rsplit_once('.') + .map_or("image", |(s, _)| s); + let folder = proposed + .rsplit_once('/') + .map(|(folder, _)| folder.trim_start_matches('/')) + .filter(|folder| !folder.is_empty()) + .unwrap_or(if extension == "svg" { + "icons" + } else { + "images" + }); + let semantic = host_safe_file_name(&format!("{stem}.{extension}")); + let mut path = if explicit { + proposed.clone() + } else { + format!("/{folder}/{semantic}") + }; + let mut attempt = 0; + loop { + let occupied = registry + .assets + .iter() + .any(|r| r.path.eq_ignore_ascii_case(&path) && r.identity != identity.key); + let disk = if let Some(root) = root { + read( + &public_target(root, &path, policy)?, + policy, + devup_mcp_figma::MAX_ASSET_BYTES, + )? + } else { + None + }; + if !occupied && disk.as_ref().is_none_or(|bytes| *bytes == acquired) { + break; + } + attempt += 1; + if attempt > 16 { + return Err(error( + "Asset semantic name is ambiguous; supply a distinct content or crop name.", + )); + } + let suffix = if attempt == 1 { + "-alternative".into() + } else { + format!("-alternative-{attempt}") + }; + path = format!("/{folder}/{stem}{suffix}.{extension}"); + } + if attempt > 0 { + naming_review.push(json!({"assetId":asset.asset_id,"nodeId":asset.node_id,"path":path,"reason":"different-content-name-collision","verifiedSemanticName":false,"nextAction":"Choose a content/crop-specific assetRequests.outputPath; numeric collision suffix is a provisional path, not completed naming."})); + } + registry.assets.push(Record { + path: path.clone(), + identity: identity.key.clone(), + sha256: sha256_hex(&acquired), + mime_type: mime.clone(), + mask, + geometry: identity.geometry.clone(), + sources: BTreeSet::from([asset.asset_id.clone()]), + }); + (path, acquired) + }; + let previous = asset.sha256.clone(); + if let Some(prior) = replacements.insert(old.clone(), path.clone()) + && prior != path + { + return Err(error( + "One code placeholder identifies different pictures. Re-export with assetNamesPerNode:true; no files were written.", + )); + } + if !asset.variants.is_empty() { + replacements.insert( + asset_policy::variant_path(&old), + asset_policy::variant_path(&path), + ); + } + if !asset.render_info.as_ref().is_some_and(Value::is_object) { + asset.render_info = Some(json!({})); + } + if let Some(filter) = filter { + filters.insert(old.clone(), filter.clone()); + asset.render_info.get_or_insert_with(|| json!({}))["canonicalPresentation"] = json!({"filter":filter,"status":"derived-flat-grayscale","preserveElementSize":true}); + } + asset.render_info.get_or_insert_with(|| json!({}))["canonicalAsset"] = json!({"path":path,"requestedOutputPath":outputs.get(&asset.asset_id),"acquiredSha256":previous,"deliveredSha256":sha256_hex(&bytes),"identityPolicy":"exact-bytes-or-parsed-svg-identity; unsupported SVG remains distinct","intrinsicSize":"preserve use-site dimensions; do not rely on canonical intrinsic width"}); + asset.path = Some(path.clone()); + if !meaningful_name(&path) { + naming_review.push(json!({"assetId":asset.asset_id,"nodeId":asset.node_id,"path":path,"reason":"anonymous-source-label","verifiedSemanticName":false,"nextAction":"Inspect the acquired image and source hierarchy, then reproject with a descriptive assetRequests.outputPath."})); + } + asset.sha256 = Some(sha256_hex(&bytes)); + asset.byte_length = Some(bytes.len()); + asset.data_base64 = Some(STANDARD.encode(&bytes)); + if let Some(root) = root + && outputs.contains_key(&asset.asset_id) { + outputs.insert( + asset.asset_id.clone(), + public_target(root, &path, policy)?.to_string_lossy().into(), + ); + } + bytes_by_path.insert(path, bytes); + let _ = existing; + } + registry.assets.sort_by(|a, b| a.path.cmp(&b.path)); + let registry_write = if let Some(root) = root.filter(|_| !outputs.is_empty()) { + let path = registry_path(root, policy)?; + Some(( + path.to_string_lossy().into(), + serde_json::to_vec_pretty(®istry) + .map_err(|_| error("Cannot encode asset registry."))?, + registry_base.as_ref().map(|bytes| sha256_hex(bytes)), + )) + } else { + None + }; + Ok(Resolution { + replacements, + registry_write, + registry: json!({"version":1,"assetCount":registry.assets.len(),"namingComplete":naming_review.is_empty(),"namingReviewCount":naming_review.len(),"namingReview":naming_review,"assets":registry.assets,"policy":"semantic-public-paths; immutable-publication; no automatic unused deletion","unverified":["SVG paint/filter constructs not proven equivalent stay separate","Raster crop/resolution differences require source-identity or independent visual evidence before consolidation","Renaming persisted API/DB URLs requires a project migration; existing files are not deleted"]}), + filters, + }) +} + +pub(super) fn apply_filters(value: &mut Value, filters: &BTreeMap) { + match value { + Value::Object(object) => { + for field in ["tsx", "componentTsx", "responsiveTsx"] { + let mut derived = Vec::new(); + if let Some(Value::String(code)) = object.get_mut(field) { + let attributes = + devup_mcp_devup_ui::provenance::attributes::generated_attributes(code); + let mut inserts = Vec::new(); + let mut touched = BTreeSet::new(); + for attr in attributes.iter().filter(|a| a.name == "src") { + if let Some((_, filter)) = filters + .iter() + .find(|(old, _)| references(&attr.source, old)) + { + inserts.push((attr.start, format!("filter=\"{filter}\" "))); + touched.insert(attr.element); + } + } + inserts.sort_by_key(|(at, _)| std::cmp::Reverse(*at)); + for (at, insert) in inserts { + code.insert_str(at, &insert); + } + for attr in + devup_mcp_devup_ui::provenance::attributes::generated_attributes(code) + .into_iter() + .filter(|attr| attr.name == "filter" && touched.contains(&attr.element)) + { + derived.push(json!({"elementIndex":attr.element,"tag":attr.tag,"generatedProperty":attr.source,"verified":true,"derivation":{"classification":"derived-canonical-grayscale","source":"parsed SVG geometry and uniform gray paint","scope":"exact paint transform; not a captured Figma filter","preserveElementSize":true}})); + } + } + if !derived.is_empty() { + let result = object + .entry("outputResults") + .or_insert_with(|| json!({})) + .as_object_mut() + .expect("output results"); + let output = result.entry(field).or_insert_with(|| json!({})); + if !output["_propertyContract"].is_array() { + output["_propertyContract"] = json!([]); + } + output["_propertyContract"] + .as_array_mut() + .unwrap() + .extend(derived.clone()); + if !object + .get("projectionEvidence") + .is_some_and(Value::is_array) + { + object.insert("projectionEvidence".into(), json!([])); + } + object + .get_mut("projectionEvidence") + .unwrap() + .as_array_mut() + .unwrap() + .extend(derived); + } + } + for (key, value) in object { + if !matches!( + key.as_str(), + "tsx" + | "componentTsx" + | "responsiveTsx" + | "rawSnapshot" + | "rawPayload" + | "originalValue" + ) { + apply_filters(value, filters); + } + } + } + Value::Array(values) => { + for value in values { + apply_filters(value, filters); + } + } + _ => {} + } +} + +pub(super) fn enforce_naming_review(value: &mut Value) { + if value["assetRegistry"]["namingComplete"] == false { + if let Some(deliverable) = value.get_mut("deliverable").and_then(Value::as_object_mut) { + deliverable.insert("isFinal".into(), json!(false)); + deliverable.insert("assetNamingVerified".into(), json!(false)); + deliverable.insert("assetNamingState".into(), json!("review-required")); + } + if value["status"] == "complete" { + value["status"] = json!("partial"); + } + } +} + +#[cfg(test)] +#[path = "canonical_assets_tests.rs"] +mod tests; diff --git a/crates/devup-mcp/src/server/projection/canonical_assets_tests.rs b/crates/devup-mcp/src/server/projection/canonical_assets_tests.rs new file mode 100644 index 00000000..24d33b8b --- /dev/null +++ b/crates/devup-mcp/src/server/projection/canonical_assets_tests.rs @@ -0,0 +1,526 @@ +use super::*; + +fn asset(id: &str, path: &str, body: &[u8], format: &str) -> devup_mcp_figma::AssetManifestEntry { + serde_json::from_value(json!({"assetId":id,"nodeId":id,"field":"node","sourceKind":"node","status":"exported","format":format,"path":path,"mimeType":if format=="svg"{"image/svg+xml"}else{"image/png"},"byteLength":body.len(),"sha256":sha256_hex(body),"dataBase64":STANDARD.encode(body)})).unwrap() +} +fn manifest(assets: Vec) -> AssetManifest { + AssetManifest { + version: 1, + assets, + diagnostics: Vec::new(), + } +} +fn result(code: &str) -> Map { + json!({"tsx":code}).as_object().unwrap().clone() +} +fn svg(size: usize, color: &str) -> Vec { + format!("").into_bytes() +} + +#[test] +fn mask_state_colors_and_sizes_share_one_semantic_asset() { + let mut manifest = manifest(vec![ + asset("a", "/icons/arrow.svg", &svg(40, "#0057bb"), "svg"), + asset("b", "/icons/arrow-selected.svg", &svg(80, "#949caa"), "svg"), + ]); + let path = std::env::temp_dir(); + let policy = OutputPolicy::from_roots(vec![path]).unwrap(); + let resolved=reconcile(&mut manifest,&mut BTreeMap::new(),None,None,&policy,&result(r#"<>"#)).unwrap(); + assert_eq!(manifest.assets[0].path, manifest.assets[1].path); + assert_eq!( + manifest.assets[0].data_base64, + manifest.assets[1].data_base64 + ); + assert_eq!(resolved.registry["assets"].as_array().unwrap().len(), 1); + assert!(resolved.filters.is_empty()); +} + +#[test] +fn flat_gray_image_reuses_color_original_with_derived_filter() { + let mut manifest = manifest(vec![ + asset("color", "/icons/logo.svg", &svg(40, "#0057bb"), "svg"), + asset("gray", "/icons/logo-muted.svg", &svg(40, "#aaaaaa"), "svg"), + ]); + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + let mut result = Value::Object(result( + r#"<>"#, + )); + let contract=devup_mcp_devup_ui::provenance::attributes::generated_attributes(result["tsx"].as_str().unwrap()).into_iter().map(|attr|json!({"elementIndex":attr.element,"tag":attr.tag,"generatedProperty":attr.source,"verified":true})).collect::>(); + result["outputResults"] = json!({"tsx":{"_propertyContract":contract}}); + let resolved = reconcile( + &mut manifest, + &mut BTreeMap::new(), + None, + None, + &policy, + result.as_object().unwrap(), + ) + .unwrap(); + assert_eq!(manifest.assets[0].path, manifest.assets[1].path); + apply_filters(&mut result, &resolved.filters); + assert!( + result["tsx"] + .as_str() + .unwrap() + .contains("grayscale(1) brightness(0) invert(") + ); + assert!(result["tsx"].as_str().unwrap().contains("w=\"40px\"")); + assert!( + devup_mcp_devup_ui::provenance::attributes::uncovered_attributes( + result["tsx"].as_str().unwrap(), + result["outputResults"]["tsx"]["_propertyContract"] + .as_array() + .unwrap() + ) + .is_empty() + ); +} + +#[test] +fn unsafe_svg_and_real_geometry_differences_are_not_merged() { + assert!( + super::super::svg_identity::identity( + br#""#, + false + ) + .is_none() + ); + assert!( + super::super::svg_identity::identity( + br#""#, + false + ) + .is_none() + ); + let triangle = super::super::svg_identity::identity(&svg(40, "#0057bb"), false).unwrap(); + let square = super::super::svg_identity::identity( + br##""##, + false, + ) + .unwrap(); + assert_ne!(triangle.key, square.key); +} + +#[test] +fn svg_ids_are_normalized_but_mask_paint_semantics_are_preserved() { + let a=br##""##; + let b = String::from_utf8(a.to_vec()) + .unwrap() + .replace("figma-1", "figma-2"); + assert_eq!( + super::super::svg_identity::identity(a, true).unwrap().key, + super::super::svg_identity::identity(b.as_bytes(), true) + .unwrap() + .key + ); + let c = b.replace("white", "black"); + assert_ne!( + super::super::svg_identity::identity(a, true).unwrap().key, + super::super::svg_identity::identity(c.as_bytes(), true) + .unwrap() + .key + ); +} + +#[test] +fn repeated_exports_reuse_the_registry_and_preserve_user_files() { + let root = + std::env::temp_dir().join(format!("devup-canonical-assets-{}", rand::random::())); + std::fs::create_dir_all(root.join("public")).unwrap(); + let policy = OutputPolicy::from_roots(vec![root.clone()]).unwrap(); + let public = root.join("public"); + let make = |id: &str, path: &str| manifest(vec![asset(id, path, b"one raster", "png")]); + let mut first = make("a", "/images/hero.png"); + let mut outputs = BTreeMap::from([( + "a".into(), + public.join("images/hero.png").display().to_string(), + )]); + let resolved = reconcile( + &mut first, + &mut outputs, + Some(&public), + Some(root.to_str().unwrap()), + &policy, + &result(""), + ) + .unwrap(); + let mut transaction = OutputTransaction::new(); + transaction + .stage_asset( + "asset:a", + policy.resolve(&outputs["a"]).unwrap(), + b"one raster", + ) + .unwrap(); + let (path, bytes, expected) = resolved.registry_write.unwrap(); + transaction + .stage_registry( + "assetRegistry", + policy.resolve(&path).unwrap(), + &bytes, + expected, + ) + .unwrap(); + transaction.commit().unwrap(); + let mut second = make("another-instance", "/images/mobile-hero.png"); + let mut outputs = BTreeMap::from([( + "another-instance".into(), + public.join("images/mobile-hero.png").display().to_string(), + )]); + let resolved = reconcile( + &mut second, + &mut outputs, + Some(&public), + Some(root.to_str().unwrap()), + &policy, + &result(""), + ) + .unwrap(); + assert_eq!(second.assets[0].path, first.assets[0].path); + assert_eq!(resolved.registry["assets"].as_array().unwrap().len(), 1); + std::fs::write(public.join("images/hero.png"), b"user edited").unwrap(); + let mut third = make("a", "/images/hero.png"); + let mut outputs = BTreeMap::from([( + "a".into(), + public.join("images/hero.png").display().to_string(), + )]); + reconcile( + &mut third, + &mut outputs, + Some(&public), + Some(root.to_str().unwrap()), + &policy, + &result(""), + ) + .unwrap(); + assert_ne!(third.assets[0].path, first.assets[0].path); + assert_eq!( + std::fs::read(public.join("images/hero.png")).unwrap(), + b"user edited" + ); + drop(policy); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn late_asset_collision_rolls_back_code_and_registry_without_overwriting_assets() { + let root = + std::env::temp_dir().join(format!("devup-assets-rollback-{}", rand::random::())); + std::fs::create_dir_all(&root).unwrap(); + let policy = OutputPolicy::from_roots(vec![root.clone()]).unwrap(); + std::fs::write(root.join("page.tsx"), b"original code").unwrap(); + std::fs::write(root.join("registry.json"), b"original registry").unwrap(); + let mut transaction = OutputTransaction::new(); + transaction + .stage("tsx", policy.resolve("page.tsx").unwrap(), b"new code") + .unwrap(); + transaction + .stage( + "registry", + policy.resolve("registry.json").unwrap(), + b"new registry", + ) + .unwrap(); + transaction + .stage_asset( + "asset:x", + policy.resolve("picture.svg").unwrap(), + b"exported picture", + ) + .unwrap(); + std::fs::write(root.join("picture.svg"), b"concurrent user file").unwrap(); + assert!(transaction.commit().is_err()); + assert_eq!( + std::fs::read(root.join("page.tsx")).unwrap(), + b"original code" + ); + assert_eq!( + std::fs::read(root.join("registry.json")).unwrap(), + b"original registry" + ); + assert_eq!( + std::fs::read(root.join("picture.svg")).unwrap(), + b"concurrent user file" + ); + let mut reuse = OutputTransaction::new(); + reuse + .stage_asset( + "asset:x", + policy.resolve("picture.svg").unwrap(), + b"concurrent user file", + ) + .unwrap(); + reuse.commit().unwrap(); + drop(policy); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn registry_paths_cannot_escape_public_root() { + let root = std::env::temp_dir().join(format!("devup-assets-unsafe-{}", rand::random::())); + std::fs::create_dir_all(root.join("public")).unwrap(); + std::fs::create_dir_all(root.join(".devup")).unwrap(); + std::fs::write(root.join(".devup/public-assets.json"),br#"{"version":1,"assets":[{"path":"/../private.json","identity":"x","sha256":"x","mimeType":"image/svg+xml"}]}"#).unwrap(); + let policy = OutputPolicy::from_roots(vec![root.clone()]).unwrap(); + assert!( + reconcile( + &mut manifest(Vec::new()), + &mut BTreeMap::new(), + Some(&root.join("public")), + None, + &policy, + &Map::new() + ) + .is_err() + ); + drop(policy); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn shared_assets_and_registry_updates_work_with_scaffold_no_clobber_and_stale_registry_is_refused() +{ + let root = + std::env::temp_dir().join(format!("devup-assets-registry-{}", rand::random::())); + std::fs::create_dir_all(&root).unwrap(); + let policy = OutputPolicy::from_roots(vec![root.clone()]).unwrap(); + std::fs::write(root.join("logo.svg"), b"same").unwrap(); + std::fs::write(root.join("registry.json"), b"initial").unwrap(); + let mut transaction = OutputTransaction::new(); + transaction.refuse_existing(); + transaction + .stage_asset("asset:x", policy.resolve("logo.svg").unwrap(), b"same") + .unwrap(); + transaction + .stage_registry( + "assetRegistry", + policy.resolve("registry.json").unwrap(), + b"updated", + Some(sha256_hex(b"initial")), + ) + .unwrap(); + transaction.commit().unwrap(); + assert_eq!( + std::fs::read(root.join("registry.json")).unwrap(), + b"updated" + ); + let mut stale = OutputTransaction::new(); + stale + .stage_registry( + "assetRegistry", + policy.resolve("registry.json").unwrap(), + b"stale", + Some(sha256_hex(b"initial")), + ) + .unwrap(); + assert!(stale.commit().is_err()); + assert_eq!( + std::fs::read(root.join("registry.json")).unwrap(), + b"updated" + ); + drop(policy); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn anonymous_names_and_collisions_require_review_and_cannot_be_final() { + let mut pictures = manifest(vec![ + asset("a", "/images/image-123.png", b"one", "png"), + asset("b", "/images/image-123.png", b"two", "png"), + ]); + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + // Distinct pictures cannot share one source-code placeholder. + assert!( + reconcile( + &mut pictures, + &mut BTreeMap::new(), + None, + None, + &policy, + &Map::new() + ) + .is_err() + ); + let mut pictures = manifest(vec![asset("a", "/images/rectangle123.png", b"one", "png")]); + let resolved = reconcile( + &mut pictures, + &mut BTreeMap::new(), + None, + None, + &policy, + &Map::new(), + ) + .unwrap(); + assert_eq!(resolved.registry["namingComplete"], false); + let mut value = json!({"status":"complete","assetRegistry":resolved.registry,"deliverable":{"isFinal":true}}); + enforce_naming_review(&mut value); + assert_eq!(value["status"], "partial"); + assert_eq!(value["deliverable"]["isFinal"], false); + assert_eq!(value["deliverable"]["assetNamingState"], "review-required"); +} + +#[test] +fn nested_unicode_paths_survive_registry_replay_and_code_only_projection() { + let root = std::env::temp_dir().join(format!("devup-assets-unicode-{}", rand::random::())); + std::fs::create_dir_all(root.join("public")).unwrap(); + let public = root.join("public"); + let policy = OutputPolicy::from_roots(vec![root.clone()]).unwrap(); + let path = "/images/기업소개/연구실.png"; + let mut first = manifest(vec![asset("a", path, b"lab photograph", "png")]); + let mut outputs = BTreeMap::from([( + "a".into(), + public + .join("images/기업소개/연구실.png") + .display() + .to_string(), + )]); + let resolved = reconcile( + &mut first, + &mut outputs, + Some(&public), + None, + &policy, + &Map::new(), + ) + .unwrap(); + let encoded = super::super::super::validation::public_asset_url( + &public, + &public.join("images/기업소개/연구실.png"), + ) + .unwrap(); + assert_eq!(first.assets[0].path.as_deref(), Some(encoded.as_str())); + assert_eq!(resolved.registry["namingComplete"], true); + let mut transaction = OutputTransaction::new(); + transaction + .stage_asset( + "asset:a", + policy.resolve(&outputs["a"]).unwrap(), + b"lab photograph", + ) + .unwrap(); + let (registry, bytes, expected) = resolved.registry_write.unwrap(); + transaction + .stage_registry( + "assetRegistry", + policy.resolve(®istry).unwrap(), + &bytes, + expected, + ) + .unwrap(); + transaction.commit().unwrap(); + let mut second = manifest(vec![asset( + "b", + "/images/lab-mobile.png", + b"lab photograph", + "png", + )]); + let resolved = reconcile( + &mut second, + &mut BTreeMap::new(), + Some(&public), + None, + &policy, + &Map::new(), + ) + .unwrap(); + assert_eq!(second.assets[0].path.as_deref(), Some(encoded.as_str())); + let mut value = json!({"tsx":"<>"}); + super::super::rewrite_result_asset_references(&mut value, &resolved.replacements); + assert!(value["tsx"].as_str().unwrap().contains(&encoded)); + assert!( + value["tsx"] + .as_str() + .unwrap() + .contains("/icons/brand/콘드로젠.svg") + ); + assert_eq!( + super::super::with_host_safe_asset_paths(value["tsx"].as_str().unwrap()), + value["tsx"].as_str().unwrap() + ); + drop(policy); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn public_root_only_scope_preserves_explicit_filename_and_scopes_registry() { + let root = std::env::temp_dir().join(format!( + "devup-assets-public-scope-{}", + rand::random::() + )); + std::fs::create_dir_all(&root).unwrap(); + let policy = OutputPolicy::from_roots(vec![root.clone()]).unwrap(); + let mut manifest = manifest(vec![asset("a", "/images/brand.png", b"brand", "png")]); + let requested = root.join("icons/BI icon.png"); + let mut outputs = BTreeMap::from([("a".into(), requested.display().to_string())]); + let resolved = reconcile( + &mut manifest, + &mut outputs, + Some(&root), + None, + &policy, + &Map::new(), + ) + .unwrap(); + assert_eq!( + manifest.assets[0].path.as_deref(), + Some("/icons/BI%20icon.png") + ); + assert_eq!( + std::path::Path::new(&outputs["a"]), + policy + .resolve(&requested.display().to_string()) + .unwrap() + .display_path() + ); + let (registry, bytes, expected) = resolved.registry_write.unwrap(); + assert!(std::path::Path::new(®istry).ends_with(".devup/public-assets.json")); + assert!(std::path::Path::new(®istry).starts_with(policy.primary_root())); + let mut transaction = OutputTransaction::new(); + transaction + .stage_asset("asset:a", policy.resolve(&outputs["a"]).unwrap(), b"brand") + .unwrap(); + transaction + .stage_registry( + "assetRegistry", + policy.resolve(®istry).unwrap(), + &bytes, + expected, + ) + .unwrap(); + transaction.commit().unwrap(); + assert_eq!(std::fs::read(&requested).unwrap(), b"brand"); + drop(policy); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn svg_comparison_never_rounds_geometry_or_guesses_implicit_paint() { + let exact = |bytes: &[u8]| super::super::svg_identity::identity(bytes, false).unwrap(); + let a = br##""##; + let b = String::from_utf8(a.to_vec()) + .unwrap() + .replace("L40 40Z", "L40 39.999999999999Z"); + assert_ne!(exact(a).key, exact(b.as_bytes()).key); + let implicit=br##""##; + assert!(exact(implicit).grayscale_filter.is_none()); + let duplicate=br##""##; + assert!(super::super::svg_identity::identity(duplicate, true).is_none()); + let external=br##""##; + assert!(super::super::svg_identity::identity(external, true).is_none()); +} + +#[test] +fn derived_filters_only_touch_the_exact_asset_url() { + let mut value = json!({"tsx":"<>"}); + apply_filters( + &mut value, + &BTreeMap::from([("/icons/logo-muted.svg".into(), "grayscale(1)".into())]), + ); + assert_eq!(value["tsx"].as_str().unwrap().matches("filter=").count(), 1); +} diff --git a/crates/devup-mcp/src/server/projection/component_projection.rs b/crates/devup-mcp/src/server/projection/component_projection.rs index 2c5f86f3..eefefaf3 100644 --- a/crates/devup-mcp/src/server/projection/component_projection.rs +++ b/crates/devup-mcp/src/server/projection/component_projection.rs @@ -1,6 +1,4 @@ -/// Marks the page root, its top-level children (the sections), their -/// children and every asset with `data-figma-node-id`, so the runtime audit captures a section -/// or an asset by the node it came from. Only when `figmaNodeIds` is true. +/// Preserve source identity in sidecar records, never application JSX attributes. fn annotate_node_ids( output: &mut devup_mcp_devup_ui::codegen::CodegenOutput, snapshot: &devup_mcp_figma::Snapshot, @@ -39,15 +37,18 @@ fn annotate_node_ids( .chain(sections.iter().cloned()) .chain(parts.iter().cloned()) .chain(assets.iter().cloned()) - .collect(); - let written = devup_mcp_devup_ui::codegen::node_ids::annotate_node_ids(output, &wanted); + .collect::>(); + let elements = output.source_map.entries.iter().filter_map(|entry| { + Some(json!({"nodeId":entry.node_id.as_ref()?,"elementIndex":entry.element_index?})) + }).filter(|entry| entry["nodeId"].as_str().is_some_and(|id| wanted.contains(id))).collect::>(); + let written = elements.iter().filter_map(|entry| entry["nodeId"].as_str()).collect::>(); json!({ - "attribute": devup_mcp_devup_ui::codegen::node_ids::NODE_ID_ATTRIBUTE, - "root": written.iter().any(|id| id == root_id).then_some(root_id), - "sections": sections.iter().filter(|id| written.contains(id)).collect::>(), - "parts": parts.iter().filter(|id| written.contains(id)).collect::>(), - "assets": assets.iter().filter(|id| written.contains(id)).collect::>(), - "note": include_str!("../text/projection-annotate_node_ids-b03e6d56.md"), + "strategy": "source-map-sidecar", "attributesWritten": false, "elements": elements, + "root": written.iter().any(|id| *id == root_id).then_some(root_id), + "sections": sections.iter().filter(|id| written.contains(id.as_str())).collect::>(), + "parts": parts.iter().filter(|id| written.contains(id.as_str())).collect::>(), + "assets": assets.iter().filter(|id| written.contains(id.as_str())).collect::>(), + "note": "Source ordinals are export evidence, not verified DOM selectors. Supply semantic rendered-target mappings to audit the browser.", }) } diff --git a/crates/devup-mcp/src/server/projection/feedback_codegen_tests.rs b/crates/devup-mcp/src/server/projection/feedback_codegen_tests.rs index 615381e0..b25180cf 100644 --- a/crates/devup-mcp/src/server/projection/feedback_codegen_tests.rs +++ b/crates/devup-mcp/src/server/projection/feedback_codegen_tests.rs @@ -62,6 +62,44 @@ fn responsive_merge_diagnostics_survive_export_summary_without_opt_in() { } } +#[tokio::test] +async fn explicit_expanded_tsx_also_expands_the_responsive_companion() { + for expanded in [false, true] { + let mut data = responsive_payload(); + for (root, instance, text) in [ + ("1:1", "small-card", "small-text"), + ("1:2", "wide-card", "wide-text"), + ] { + data.snapshot.nodes.get_mut(root).unwrap().fields.insert("childrenIds".into(), json!([instance])); + for value in [ + json!({"id":instance,"type":"INSTANCE","fields":{ + "name":"Card","parentId":root,"mainComponentId":"master", + "width":100,"height":40,"layoutMode":"VERTICAL","childrenIds":[text] + }}), + json!({"id":text,"type":"TEXT","fields":{ + "name":"Label","parentId":instance,"characters":"Concrete instance content", + "width":100,"height":20,"fontSize":16 + }}), + ] { + let node: devup_mcp_figma::RawNode = serde_json::from_value(value).unwrap(); + data.snapshot.nodes.insert(node.id.clone(), node); + } + } + let master: devup_mcp_figma::RawNode = serde_json::from_value(json!({ + "id":"master","type":"COMPONENT","fields":{"name":"Card","width":100,"height":40} + })).unwrap(); + data.snapshot.nodes.insert(master.id.clone(), master); + let mut op = operation(if expanded { &["tsx", "responsiveTsx"] } else { &["responsiveTsx"] }); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec!["1:1".into(), "1:2".into()]; + } + let result = project(data, op).await.unwrap(); + let source = result["responsiveTsx"].as_str().expect("responsive source"); + assert_eq!(source.contains("Concrete instance content"), expanded, "{source}"); + assert_eq!(source.contains("@/components/Card"), !expanded, "{source}"); + } +} + #[tokio::test] async fn instance_overrides_reach_export_and_required_review_without_opt_in() { for output in ["tsx", "componentTsx"] { @@ -80,7 +118,7 @@ async fn instance_overrides_reach_export_and_required_review_without_opt_in() { super::super::review::attach(&mut result); assert_eq!(result["instanceOverrides"][0]["instanceNodeId"], root, "{result}"); assert_eq!(result["instanceOverrides"][0]["resolution"], "resolved"); - assert!(result[output].as_str().unwrap().contains("// @devup-override ")); + assert!(!result[output].as_str().unwrap().contains("// @devup-")); assert!(result["projectionIssues"].as_array().unwrap().iter().any(|item| item["code"] == "DEVUP_CODEGEN_INSTANCE_OVERRIDE")); assert!(result["reviewChecklist"].as_array().unwrap().iter().any(|item| @@ -276,7 +314,7 @@ async fn feedback32_component_set_has_no_canvas_coverage_failure() { } #[tokio::test] -async fn feedback64_node_identity_requires_explicit_opt_in() { +async fn feedback64_node_identity_stays_in_sidecars_even_with_legacy_opt_in() { for enabled in [false, true] { let mut op = operation(&["tsx", "componentTsx", "sourceMap"]); if let PendingOperation::Export { theme_options, .. } = &mut op { @@ -285,7 +323,9 @@ async fn feedback64_node_identity_requires_explicit_opt_in() { let result = project(payload(), op).await.unwrap(); for output in ["tsx", "componentTsx"] { let code = result[output].as_str().unwrap(); - assert_eq!(code.contains("data-figma-node-id"), enabled, "{output}: {code}"); + assert!(!code.contains("data-figma-node-id"), "{output}: {code}"); + assert!(!code.contains("// @devup-"), "{output}: {code}"); + assert!(result["outputResults"][output]["sectionCoverage"]["elementMappings"].is_array(), "{result}"); } assert!(result["sourceMap"].is_object(), "{result}"); let mut op = operation(&["responsiveTsx"]); @@ -295,7 +335,9 @@ async fn feedback64_node_identity_requires_explicit_opt_in() { } let result = project(responsive_payload(), op).await.unwrap(); let code = result["responsiveTsx"].as_str().unwrap(); - assert_eq!(code.contains("data-figma-node-id"), enabled, "{code}"); + assert!(!code.contains("data-figma-node-id"), "{code}"); + assert!(!code.contains("// @devup-"), "{code}"); + assert!(result["sourceIdentity"]["responsive"].is_array(), "{result}"); } } diff --git a/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs b/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs index c660816a..3dcf9ddc 100644 --- a/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs +++ b/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs @@ -109,8 +109,9 @@ async fn feedback_integrated_large_code_hints_theme_and_resources_match_saved_by assert_eq!(sha256_hex(&bytes), manifest.sha256); } let code = std::fs::read_to_string(directory.join("screen.tsx")).unwrap(); - assert!(code.contains("// @devup-interaction ")); - assert!(code.contains("https://example.com/confirmed")); + assert!(!code.contains("// @devup-")); + assert!(!code.contains("https://example.com/confirmed")); + assert!(result["interactionHints"].to_string().contains("https://example.com/confirmed"), "{result}"); let theme: Value = serde_json::from_slice(&std::fs::read(directory.join("devup.json")).unwrap()).unwrap(); assert_eq!(theme["theme"]["typography"].as_object().unwrap().keys().collect::>(), vec!["body"]); assert!(code.contains("typography=\"body\"")); diff --git a/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs b/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs index be2124cd..42e3d6a8 100644 --- a/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs +++ b/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs @@ -71,11 +71,17 @@ pub(super) async fn finalize_export_delivery( responsive_widths, } = input; result.insert("_defaultNodeId".into(), json!(default_node_id)); - default_code_paths::apply(&mut result, &mut output_paths, project_root.as_deref(), output_policy); + default_code_paths::apply( + &mut result, + &mut output_paths, + project_root.as_deref(), + output_policy, + ); result.remove("_defaultNodeId"); for output in ["tsx", "componentTsx", "responsiveTsx"] { if output_paths.contains_key(output) - && let Some(code) = result.get(output).and_then(Value::as_str) { + && let Some(code) = result.get(output).and_then(Value::as_str) + { pending_text_outputs.insert(output.into(), code.into()); } } @@ -95,13 +101,21 @@ pub(super) async fn finalize_export_delivery( output_policy, )?; } + let mut registry_write = None; + let mut presentation_filters = BTreeMap::new(); let replacements = if let Some(manifest) = &mut pending_asset_manifest { - reconcile_asset_paths( + let resolved = canonical_assets::reconcile( manifest, &mut asset_output_paths, asset_public_root.as_deref(), + project_root.as_deref(), output_policy, - )? + &result, + )?; + result.insert("assetRegistry".into(), resolved.registry); + registry_write = resolved.registry_write; + presentation_filters = resolved.filters; + resolved.replacements } else { BTreeMap::new() }; @@ -172,17 +186,21 @@ pub(super) async fn finalize_export_delivery( } } let mut value = Value::Object(std::mem::take(&mut result)); + canonical_assets::apply_filters(&mut value, &presentation_filters); rewrite_result_asset_references(&mut value, &replacements); super::super::asset_guard::refresh_export_asset_urls(&mut value); audit_delivered_properties(&mut value); super::super::interactions::attach(&mut value, &payload.snapshot); attach_instance_overrides(&mut value, payload, &payload.snapshot.roots); + super::super::behavior_contracts::attach(&mut value, &payload.snapshot); + super::super::development_contract::attach(&mut value); if let Some(root) = project_root.as_deref() { super::super::screen_plan::attach_component_width_plan(&mut value, root)?; } attach_diagnostic_screens(&mut value, &owners); super::super::verdict_scope::attach(&mut value); attach_verification_scope(&mut value, &responsive_widths); + canonical_assets::enforce_naming_review(&mut value); if strict && value["mappingComplete"] == false { return Err(DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, @@ -217,6 +235,13 @@ pub(super) async fn finalize_export_delivery( *contents = serde_json::to_string_pretty(&result[name]).unwrap_or_default(); } } + for field in ["behaviorPlan", "developmentContract"] { + if output_paths.contains_key(field) + && let Some(value) = result.get(field) + { + pending_text_outputs.insert(field.into(), serde_json::to_string_pretty(value).unwrap()); + } + } let mut planned_outputs = Vec::new(); let mut supported_keys = Vec::new(); if let Some(options) = &page_scaffold { @@ -245,6 +270,8 @@ pub(super) async fn finalize_export_delivery( "imageAudit", "assetCssMap", "outline", + "behaviorPlan", + "developmentContract", ] { if result.contains_key(field) { supported_keys.push(field.to_owned()); @@ -295,6 +322,11 @@ pub(super) async fn finalize_export_delivery( planned_outputs.push((output, output_policy.resolve(path)?, bytes)); } } + let mut registry_expected_hash = None; + if let Some((path, bytes, expected_hash)) = registry_write { + registry_expected_hash = expected_hash; + planned_outputs.push(("assetRegistry".into(), output_policy.resolve(&path)?, bytes)); + } if let Some(manifest) = pending_asset_manifest.as_ref() { for asset in &manifest.assets { if asset.status != AssetStatus::Exported { @@ -355,18 +387,9 @@ pub(super) async fn finalize_export_delivery( // shares a file, as the plugin has it - so several assets resolve // to one path. That is one write, not a collision. Only differing // bytes under one name are a mistake, and that is worth refusing. - // Two different drawings under one layer name land here too: the - // plugin names an asset after its layer, and a snapshot cannot - // tell two drawings of one name apart - only the exported bytes - // can. The first is written and the rest are reported, so the - // code still points at a file that exists and the caller learns - // which names hide more than one picture. - let fingerprint_path = planned_outputs - .iter() - .find(|(name, _, _)| name == "designFingerprints") - .map(|(_, target, _)| target.display_path().to_path_buf()); + // Canonical reconciliation already names differing pictures separately. + // A remaining collision between different outputs must fail atomically. let mut staged_content: BTreeMap = BTreeMap::new(); - let mut shared_names: BTreeMap> = BTreeMap::new(); for (name, target, bytes) in planned_outputs { let path = target.display_path().to_string_lossy().into_owned(); let fingerprint = sha256_hex(&bytes); @@ -375,27 +398,25 @@ pub(super) async fn finalize_export_delivery( written_paths.insert(name, json!(path)); continue; } - Some(_) - if scaffold_requested - || fingerprint_path.as_deref() == Some(target.display_path()) => - { + Some(_) => { return Err(DevupError::new( ErrorCode::DevupInvalidInput, format!("Output file collision: different outputs claim {path}."), false, )); } - Some(_) => { - let asset = name.strip_prefix("asset:").unwrap_or(&name).to_owned(); - shared_names.entry(path).or_default().push(asset); - continue; - } None => { staged_content.insert(path.clone(), fingerprint); } } written_paths.insert(name.clone(), json!(path)); - transaction.stage(name, target, &bytes)?; + if name.starts_with("asset:") { + transaction.stage_asset(name, target, &bytes)?; + } else if name == "assetRegistry" { + transaction.stage_registry(name, target, &bytes, registry_expected_hash.clone())?; + } else { + transaction.stage(name, target, &bytes)?; + } } if manifest_requested && let Some(mut manifest) = pending_asset_manifest { asset_resource_outputs = projected_asset_outputs(&manifest)?; @@ -419,20 +440,6 @@ pub(super) async fn finalize_export_delivery( } } } - for (path, others) in &shared_names { - manifest.diagnostics.push(Diagnostic { - code: "DEVUP_ASSET_NAME_SHARED".to_owned(), - message: format!( - include_str!("../text/projection-project_operation_Diagnostic-6ab28a84.md"), - others.len(), - path = path - ), - severity: Some(DiagnosticSeverity::Warning), - resource_kind: Some("asset".to_owned()), - details: Some(json!({ "outputPath": path, "notWritten": others })), - ..Diagnostic::default() - }); - } let mut manifest = json!(manifest); if let Some(assets) = manifest["assets"].as_array_mut() { for asset in assets { @@ -499,8 +506,16 @@ pub(super) async fn finalize_export_delivery( .get("outputPaths") .cloned() .unwrap_or_else(|| json!({})); - return Ok(json!({"status":"complete","written":true, + let written = paths.as_object().is_some_and(|paths| !paths.is_empty()); + let note = "The selected response exceeded the delivery budget. Read the same artifact with nextAction before reviewing or implementing its outputs. Any listed output files were committed and remain available."; + return Ok(json!({"status":"partial","written":written, + "deliveryState":"recovery-required", "artifactId":artifact.artifact_id,"outputPaths":paths, + "nextAction":next, + "deliverable":{"isFinal":false,"recoveryRequired":true,"requestedOutputs":outputs,"note":note}, + "projectionIssues":[{"code":"DEVUP_EXPORT_RESPONSE_RECOVERY_REQUIRED", + "message":note,"severity":"error","fidelityImpact":"failed","property":"output", + "details":{"stage":"response-selection","classification":"delivery-incomplete","nextAction":next}}], "responseTruncated":{"reason":error.message,"maxSelectedBytes":65536,"nextAction":next}})); } } @@ -513,7 +528,11 @@ pub(super) async fn finalize_export_delivery( projected_outputs.extend(compact_details); if !response_selection.active() && project_root.is_some() { default_code_paths::omit_written(&mut result); - default_code_paths::compact_written(&mut result, &mut projected_outputs, &artifact.artifact_id)?; + default_code_paths::compact_written( + &mut result, + &mut projected_outputs, + &artifact.artifact_id, + )?; } let mut result = Value::Object(result); let attachment = apply_delivery( @@ -535,7 +554,10 @@ pub(super) async fn finalize_export_delivery( return Err(error); } commit_delivery(attachment); - if let Some(object) = result.as_object_mut() { default_code_paths::omit_written(object); } + canonical_assets::enforce_naming_review(&mut result); + if let Some(object) = result.as_object_mut() { + default_code_paths::omit_written(object); + } if let Some(path) = result["outputPaths"].get("designFingerprints").cloned() { result["outputPathResults"]["committed"] = json!({"designFingerprints":path}); } diff --git a/crates/devup-mcp/src/server/projection/instance_overrides.rs b/crates/devup-mcp/src/server/projection/instance_overrides.rs index c6587738..454f9f45 100644 --- a/crates/devup-mcp/src/server/projection/instance_overrides.rs +++ b/crates/devup-mcp/src/server/projection/instance_overrides.rs @@ -30,17 +30,6 @@ fn attach_instance_overrides( fn attach_override_records(result: &mut Value, records: Vec) { if records.is_empty() { return; } - for field in ["tsx", "componentTsx", "responsiveTsx"] { - if let Some(source) = result.get(field).and_then(Value::as_str).map(str::to_owned) { - let mut source = source; - for record in &records { - let hint = format!("// @devup-override {}", record.to_string() - .replace('\u{2028}', "\\u2028").replace('\u{2029}', "\\u2029")); - if !source.contains(&hint) { source.push_str(&format!("\n{hint}\n")); } - } - result[field] = json!(source); - } - } let issues = result.as_object_mut().map(|object| object.entry("projectionIssues").or_insert_with(|| json!([]))); if let Some(issues) = issues.and_then(Value::as_array_mut) { for record in &records { diff --git a/crates/devup-mcp/src/server/projection/overlay_anchor_delivery.rs b/crates/devup-mcp/src/server/projection/overlay_anchor_delivery.rs new file mode 100644 index 00000000..e548a679 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/overlay_anchor_delivery.rs @@ -0,0 +1,72 @@ +fn attach_overlay_anchor_delivery( + snapshot: &devup_mcp_figma::Snapshot, + roots: &[String], + tsx: &str, + output: &str, + result: &mut Map, +) { + let rows = devup_mcp_devup_ui::codegen::overlay_anchor_metadata(snapshot, roots, tsx); + for row in rows { + let mut scoped = row; + if let Some(object) = scoped.as_object_mut() { + object.insert("output".into(), json!(output)); + } + let delivered = result.entry("overlayAnchors").or_insert_with(|| json!([])); + if let Some(delivered) = delivered.as_array_mut() + && !delivered.contains(&scoped) + { + delivered.push(scoped); + } + } +} + +#[cfg(test)] +mod overlay_anchor_delivery_tests { + use super::*; + use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; + + fn snapshot() -> devup_mcp_figma::Snapshot { + serde_json::from_str(include_str!( + "../../../../devup-mcp-devup-ui/tests/fixtures/chondrogen_dropdown_anchor_nodes.json" + )).expect("captured Contact dropdown nodes") + } + + #[test] + fn ordinary_delivery_exposes_real_anchor_proof_without_duplicate_comments() { + let snapshot = snapshot(); + let roots = vec![snapshot.roots[0].clone()]; + let output = generate_component( + &snapshot, &roots[0], + &CodegenOptions { inline_instances: true, ..Default::default() }, + ).unwrap(); + let comment_count = output.tsx.matches("// @devup-overlay-anchor ").count(); + assert_eq!(comment_count, 0); + let mut result = Map::new(); + attach_overlay_anchor_delivery(&snapshot, &roots, &output.tsx, "tsx", &mut result); + let rows = result["overlayAnchors"].as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["projected"], true); + assert_eq!(rows[0]["runtimeVerified"], false); + assert_eq!(rows[0]["output"], "tsx"); + assert_eq!(output.tsx.matches("// @devup-overlay-anchor ").count(), comment_count); + } + + #[test] + fn responsive_delivery_keeps_each_captured_widths_anchor_in_the_written_module() { + let snapshot = snapshot(); + let merged = devup_mcp_devup_ui::codegen::responsive::merge_confirmed_breakpoints( + &snapshot, + &CodegenOptions { inline_instances: true, ..Default::default() }, + ).unwrap().unwrap(); + let module = merged.module("ContactPage"); + let mut result = Map::new(); + attach_overlay_anchor_delivery( + &snapshot, &snapshot.roots, &module, "responsiveTsx", &mut result, + ); + let rows = result["overlayAnchors"].as_array().unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|row| row["projected"] == true && row["runtimeVerified"] == false)); + assert_eq!(module.matches("// @devup-overlay-anchor ").count(), 0); + assert!(devup_mcp_devup_ui::validation::validate_tsx(&module).is_ok()); + } +} diff --git a/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs b/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs index 9c0fea20..a963d49f 100644 --- a/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs +++ b/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs @@ -124,6 +124,13 @@ pub(super) fn project_node_and_theme_outputs( fidelity_reports.push(output.fidelity_report.clone()); generated_output_target = "tsx"; tsx_source_map = Some(output.source_map.clone()); + attach_overlay_anchor_delivery( + &payload.snapshot, + &[node_id.to_owned()], + &output.tsx, + "tsx", + &mut result, + ); if keep_tsx && output_paths.contains_key("tsx") { pending_text_outputs.insert("tsx".to_owned(), output.tsx.clone()); } @@ -192,6 +199,13 @@ pub(super) fn project_node_and_theme_outputs( generated_output_target = "componentTsx"; tsx_source_map = Some(output.source_map.clone()); } + attach_overlay_anchor_delivery( + &payload.snapshot, + &[node_id.to_owned()], + &output.tsx, + "componentTsx", + &mut result, + ); if output_paths.contains_key("componentTsx") { pending_text_outputs.insert("componentTsx".to_owned(), output.tsx.clone()); } diff --git a/crates/devup-mcp/src/server/projection/project_operation.rs b/crates/devup-mcp/src/server/projection/project_operation.rs index 3f4ddf7d..a9eccf05 100644 --- a/crates/devup-mcp/src/server/projection/project_operation.rs +++ b/crates/devup-mcp/src/server/projection/project_operation.rs @@ -173,11 +173,12 @@ async fn project_operation( .collect(); if let Some(root) = &asset_public_root { for capture in &asset_captures { - let name = format!("{}.webp", sha256_hex(capture.asset_id.as_bytes())); + let asset = payload.assets.iter().find(|asset| asset.asset_id == capture.asset_id); + let name = asset.and_then(|asset| asset.suggested_filename.clone().or_else(|| devup_mcp_figma::suggested_asset_filename(&payload.snapshot, asset))) + .map(|name| host_safe_file_name(&name)).unwrap_or_else(|| "image.webp".into()); asset_output_paths.insert( capture.asset_id.clone(), root.join("images") - .join("web-ready") .join(name) .display() .to_string(), diff --git a/crates/devup-mcp/src/server/projection/project_responsive_output.rs b/crates/devup-mcp/src/server/projection/project_responsive_output.rs index 75a882be..b21d3bdd 100644 --- a/crates/devup-mcp/src/server/projection/project_responsive_output.rs +++ b/crates/devup-mcp/src/server/projection/project_responsive_output.rs @@ -65,13 +65,16 @@ pub(super) fn project_responsive_output(input: ResponsiveOutput<'_>) -> Responsi if outputs .iter() .any(|output| output == "tsx" || output == "responsiveTsx") - && let Some(mut merged) = merge( + && let Some(merged) = merge( &responsive_snapshot, &CodegenOptions { asset_policies: asset_policies.clone(), component_name: component_name_for_components.clone(), include_diagnostics, - inline_instances: false, + // `tsx` explicitly requests expanded instances. Keep the same + // contract in its responsive companion so concrete instance + // text/layout overrides cannot disappear behind a bare call. + inline_instances: outputs.iter().any(|output| output == "tsx"), root_layout, asset_names_per_node, ..CodegenOptions::default() @@ -115,19 +118,21 @@ pub(super) fn project_responsive_output(input: ResponsiveOutput<'_>) -> Responsi "Two frames occupy the same breakpoint slot. Request tsx separately, or select one frame per breakpoint slot.", )); } + let openings = devup_mcp_devup_ui::provenance::attributes::opening_elements(&merged.tsx); + let source_structure_verified = openings.len() == merged.elements.len(); + let identities = merged.elements.iter().enumerate().filter(|_| source_structure_verified).map(|(index, element)| json!({"elementIndex":index,"ids":element.ids.iter().map(|(width,id)|json!({"width":width,"nodeId":id})).collect::>(),"hiddenAt":element.hidden_at})).collect::>(); + result.insert("sourceIdentity".into(), json!({"strategy":"source-map-sidecar","attributesWritten":false,"sourceStructureVerified":source_structure_verified,"runtimeVerified":false,"responsive":identities})); if theme_options.figma_node_ids { - let written = devup_mcp_devup_ui::codegen::node_ids::annotate_merged(&mut merged); - let ids = result.entry("figmaNodeIds").or_insert_with( - || json!({"attribute": devup_mcp_devup_ui::codegen::node_ids::NODE_ID_ATTRIBUTE}), - ); - ids["perWidthAttribute"] = - json!(devup_mcp_devup_ui::codegen::node_ids::NODE_IDS_ATTRIBUTE); - ids["responsive"] = json!(written); - ids["responsiveFormat"] = json!(include_str!( - "../text/projection-project_operation-8a156710.md" - )); + result.insert("figmaNodeIds".into(), result["sourceIdentity"].clone()); } let module = merged.module(&name); + attach_overlay_anchor_delivery( + &responsive_snapshot, + &responsive_snapshot.roots, + &module, + "responsiveTsx", + &mut result, + ); // The responsive tree builder does not run the ordinary // finalizer. Preserve each breakpoint's source limitations. let mut breakpoint_fidelity = Vec::new(); diff --git a/crates/devup-mcp/src/server/projection/project_section_frames.rs b/crates/devup-mcp/src/server/projection/project_section_frames.rs index 0dd62ffb..76718166 100644 --- a/crates/devup-mcp/src/server/projection/project_section_frames.rs +++ b/crates/devup-mcp/src/server/projection/project_section_frames.rs @@ -218,6 +218,15 @@ pub(super) fn project_section_frames(input: SectionFrames<'_>) -> SectionFramePr } frame_diagnostics.extend(output.diagnostics.iter().cloned()); fidelity_reports.push(output.fidelity_report.clone()); + if let Some(frame_result) = frame.as_object_mut() { + attach_overlay_anchor_delivery( + &payload.snapshot, + std::slice::from_ref(&candidate.node.node_id), + &output.tsx, + field, + frame_result, + ); + } frame[field] = json!(output.tsx); attach_fidelity(&mut frame, &output.fidelity_report, include_diagnostics); if outputs.iter().any(|output| output == "sourceMap") { diff --git a/crates/devup-mcp/src/server/projection/regression_tests.rs b/crates/devup-mcp/src/server/projection/regression_tests.rs index cc9eacb1..f9f09787 100644 --- a/crates/devup-mcp/src/server/projection/regression_tests.rs +++ b/crates/devup-mcp/src/server/projection/regression_tests.rs @@ -10,10 +10,10 @@ mod tests { assert_eq!( with_host_safe_asset_paths(code), concat!( - "\n", - "\n", - "\n", - "\n", + "\n", + "\n", + "\n", + "\n", ) ); } @@ -22,11 +22,11 @@ mod tests { fn the_written_path_is_renamed_the_same_way_the_code_is() { assert_eq!( host_safe_asset_path("/icons/grommet-icons:language.svg"), - "/icons/grommet-icons-language-d201f62b16f5.svg" + "/icons/grommet-icons-language.svg" ); assert_eq!( host_safe_asset_path("/images/Frame 269.png"), - "/images/Frame-269-3e2af8571e3f.png" + "/images/frame-269.png" ); } } diff --git a/crates/devup-mcp/src/server/projection/resource_delivery.rs b/crates/devup-mcp/src/server/projection/resource_delivery.rs index 16c8e772..b328c6c3 100644 --- a/crates/devup-mcp/src/server/projection/resource_delivery.rs +++ b/crates/devup-mcp/src/server/projection/resource_delivery.rs @@ -120,7 +120,10 @@ pub(super) async fn apply_delivery( spill_oversized_fields(result, &mut outputs, &artifact.artifact_id)?; let projection_key = projection_key(&outputs); materialize_asset_resource_references(result, &mut outputs, &artifact.artifact_id)?; - let output_bytes = outputs.iter().map(|output| output.bytes.len()).sum::(); + let output_bytes = outputs + .iter() + .map(|output| output.bytes.len()) + .sum::(); let reservation = artifact_store .reserve_outputs(&artifact.artifact_id, &projection_key, outputs) .await?; @@ -157,9 +160,13 @@ pub(super) async fn apply_delivery( ("imageFillAudit", "image-fill-audit.json"), ("imageAudit", "image-audit.json"), ("assetCssMap", "asset-css-map.json"), + ("assetRegistry", "asset-registry.json"), ] { if let Some(audit) = result.get_mut(field).and_then(Value::as_object_mut) { audit.remove("assets"); + if field == "assetRegistry" { + audit.remove("namingReview"); + } if let Some(manifest) = manifests.iter().find(|m| m.name == name) { audit.insert("detailsResource".into(), json!({"uri":manifest.manifest_uri,"name":manifest.name,"contentHash":manifest.sha256,"requiresFullDetailRead":true})); } @@ -174,6 +181,23 @@ pub(super) async fn apply_delivery( plan.insert("detailsResource".into(), json!({"uri":manifest.manifest_uri,"name":manifest.name,"contentHash":manifest.sha256,"requiresFullDetailRead":true})); } } + if let Some(plan) = result + .get_mut("behaviorPlan") + .and_then(Value::as_object_mut) + && let Some(manifest) = manifests.iter().find(|m| m.name == "behavior-plan.json") + { + let count = plan + .get("candidates") + .and_then(Value::as_array) + .map_or(0, Vec::len); + plan.clear(); + plan.insert("candidateCount".into(), json!(count)); + plan.insert("implemented".into(), json!(false)); + plan.insert( + "detailsResource".into(), + json!({"uri":manifest.manifest_uri,"name":manifest.name,"requiresFullDetailRead":true}), + ); + } for field in [ "tsx", "componentTsx", @@ -289,7 +313,13 @@ const ESSENTIAL_INLINE: [&str; 6] = [ "server", ]; -const HANDLED_LATER: [&str; 4] = ["imageFillAudit", "imageAudit", "assetCssMap", "webReadyImages"]; +const HANDLED_LATER: [&str; 5] = [ + "imageFillAudit", + "imageAudit", + "assetCssMap", + "webReadyImages", + "assetRegistry", +]; fn envelope_bytes(result: &Value) -> usize { let mut probe = result.clone(); @@ -305,7 +335,9 @@ fn envelope_bytes(result: &Value) -> usize { } } } - serde_json::to_vec(&probe).map_or(0, |bytes| bytes.len()) + // MCP repeats structured content in a text block. Escaping can make that + // text much larger than twice the raw JSON, particularly for sidecars. + serde_json::to_vec(&super::delivery::tool_result(probe)).map_or(usize::MAX, |bytes| bytes.len()) } fn spill_oversized_fields( @@ -313,7 +345,8 @@ fn spill_oversized_fields( outputs: &mut Vec, artifact_id: &str, ) -> Result<(), DevupError> { - let budget = super::delivery::MAX_INLINE_TOTAL_BYTES / 2; + // Reserve room for the resource links that are attached after this pass. + let budget = super::delivery::MAX_INLINE_TOTAL_BYTES - 64 * 1024; if envelope_bytes(result) <= budget { return Ok(()); } @@ -393,4 +426,18 @@ mod spill_tests { assert_eq!(outputs.len(), 1); assert!(envelope_bytes(&result) <= super::super::delivery::MAX_INLINE_TOTAL_BYTES / 2); } + + #[test] + fn metadata_spilling_measures_escaped_mcp_text_as_well_as_structured_json() { + let quoted = "\"".repeat(180_000); + let mut result = json!({"status":"complete", "details":{"quoted":quoted}}); + assert!(serde_json::to_vec(&result).unwrap().len() < super::super::delivery::MAX_INLINE_TOTAL_BYTES / 2); + assert!(super::super::delivery::choose_delivery_for_result(DeliveryMode::Inline, &result, &[]).is_err()); + let mut outputs = Vec::new(); + spill_oversized_fields(&mut result, &mut outputs, "artifact").unwrap(); + super::super::delivery::choose_delivery_for_result(DeliveryMode::Inline, &result, &[]).unwrap(); + assert_eq!(result["details"]["state"], "resource-details"); + let full: Value = serde_json::from_slice(&outputs[0].bytes).unwrap(); + assert_eq!(full["details"]["quoted"], quoted); + } } diff --git a/crates/devup-mcp/src/server/projection/resource_outputs.rs b/crates/devup-mcp/src/server/projection/resource_outputs.rs index 7a1bf5f1..55ce1aeb 100644 --- a/crates/devup-mcp/src/server/projection/resource_outputs.rs +++ b/crates/devup-mcp/src/server/projection/resource_outputs.rs @@ -42,15 +42,22 @@ pub(super) fn projected_outputs_from_result( ("rawSnapshot", "raw-snapshot.json"), ("rawPayload", "raw-payload.json"), ("sourceMap", "source-map.json"), + ("instanceOverrides", "instance-overrides.json"), + ("interactionHints", "interaction-hints.json"), + ("sourceIdentity", "source-identity.json"), + ("overlayAnchors", "overlay-anchors.json"), ("designFingerprints", "design-fingerprints.json"), ("designChanges", "design-changes.json"), ("assetManifest", "asset-manifest.json"), + ("assetRegistry", "asset-registry.json"), ("imageFillAudit", "image-fill-audit.json"), ("imageAudit", "image-audit.json"), ("assetCssMap", "asset-css-map.json"), ("webReadyImages", "web-ready-images.json"), ("pageScaffold", "page-scaffold.json"), ("outline", "outline.json"), + ("behaviorPlan", "behavior-plan.json"), + ("developmentContract", "development-contract.json"), ("selected", "selected.json"), ] { if let Some(value) = result.get(field) { diff --git a/crates/devup-mcp/src/server/projection/responsive_groups.rs b/crates/devup-mcp/src/server/projection/responsive_groups.rs index d7ab18f7..2b1e6f5f 100644 --- a/crates/devup-mcp/src/server/projection/responsive_groups.rs +++ b/crates/devup-mcp/src/server/projection/responsive_groups.rs @@ -5,13 +5,51 @@ use devup_mcp_devup_ui::codegen::responsive::{ use devup_mcp_figma::ExploreCandidate; pub(crate) fn confirmed(project_root: Option<&str>, ids: &[String]) -> Result { - let Some(root) = project_root else { return Ok(false) }; - let Some(plan) = super::super::screen_plan::read_plan(root)? else { return Ok(false) }; - Ok(plan["status"] == "confirmed" && ids.len() >= 2 && plan["routes"].as_array().is_some_and(|routes| { - routes.iter().any(|route| route["nodeIds"].as_array().is_some_and(|nodes| { - nodes.len() == ids.len() && ids.iter().all(|id| nodes.contains(&json!(id))) - })) - })) + let Some(root) = project_root else { + return Ok(false); + }; + if ids.len() < 2 { + return Ok(false); + } + for ancestor in std::path::Path::new(root).ancestors() { + let Some(plan) = super::super::screen_plan::read_plan(&ancestor.to_string_lossy())? else { + continue; + }; + if plan["status"] != "confirmed" { + continue; + } + return Ok(plan["routes"].as_array().is_some_and(|routes| { + routes.iter().any(|route| { + route["implementation"] != "state" + && (legacy_exact_confirmation(route, ids) + || super::super::screen_plan::responsive_groups(route) + .ok() + .is_some_and(|groups| { + groups.iter().any(|group| { + group["frameIds"].as_array().is_some_and(|nodes| { + nodes.len() == ids.len() + && ids.iter().all(|id| nodes.contains(&json!(id))) + }) + }) + })) + }) + })); + } + Ok(false) +} + +/// Older confirmed plans recorded exact frame identity without drawn widths. +/// Preserve that identity proof; the export still validates actual snapshot +/// widths and slots. A newer plan with explicit groups or width evidence must +/// satisfy its stronger contract rather than falling back to route membership. +fn legacy_exact_confirmation(route: &Value, ids: &[String]) -> bool { + route.get("drawnWidths").is_none() + && route.get("responsiveGroups").is_none() + && route["nodeIds"].as_array().is_some_and(|nodes| { + nodes.len() == ids.len() + && ids.iter().collect::>().len() == ids.len() + && ids.iter().all(|id| nodes.contains(&json!(id))) + }) } pub fn discover(candidates: &[ExploreCandidate], artifact_id: &str) -> Vec { @@ -191,6 +229,27 @@ pub fn completion(result: &mut Map, selected: &[String], all_scre #[cfg(test)] mod feedback_tests { use super::*; + + #[test] + fn legacy_confirmation_never_bypasses_recorded_widths_or_explicit_groups() { + let ids = vec!["1:1".to_owned(), "1:2".to_owned()]; + let legacy = json!({"route":"/technology","nodeIds":ids}); + assert!(legacy_exact_confirmation(&legacy, &ids)); + assert!(!legacy_exact_confirmation(&legacy, &ids[..1])); + assert!(!legacy_exact_confirmation( + &legacy, + &["1:1".into(), "1:1".into()] + )); + for field in ["drawnWidths", "responsiveGroups"] { + let mut stronger = legacy.clone(); + stronger[field] = json!([]); + assert!( + !legacy_exact_confirmation(&stronger, &ids), + "A malformed recorded {field} cannot silently become legacy evidence" + ); + } + } + #[test] fn responsive_ambiguity_and_missing_widths_are_reviewed_within_selected_roots() { let snapshot:devup_mcp_figma::Snapshot=serde_json::from_value(json!({"fileKey":"Synthetic","roots":["1:1","1:2"],"nodes":{ diff --git a/crates/devup-mcp/src/server/projection/responsive_paint_regressions.rs b/crates/devup-mcp/src/server/projection/responsive_paint_regressions.rs new file mode 100644 index 00000000..d0a8090e --- /dev/null +++ b/crates/devup-mcp/src/server/projection/responsive_paint_regressions.rs @@ -0,0 +1,84 @@ +#[tokio::test] +async fn r13_known_empty_desktop_paint_is_verified_and_strict_accepts() { + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { all_screens, strict, .. } = &mut op { + *all_screens = true; + *strict = true; + } + let mut data = responsive_payload(); + // This positive strict fixture declares its size policy too; the generic + // payload deliberately leaves dimensions unexplained for other audits. + for node in data.snapshot.nodes.values_mut() { + node.fields.extend(serde_json::from_value::>( + json!({"layoutMode":"HORIZONTAL","layoutSizingHorizontal":"FIXED", + "layoutSizingVertical":"FIXED","parentType":"SECTION"}), + ).unwrap()); + } + data.snapshot.nodes.get_mut("1:1").unwrap().fields.insert( + "fills".into(), json!([{"type":"SOLID","color":{"r":1,"g":0,"b":0}}]), + ); + let result = project(data, op).await.unwrap(); + assert_eq!(result["mappingComplete"], true, "{result}"); + assert_eq!(result["outputResults"]["responsiveTsx"]["fidelity"]["mergedMappingVerified"], true); + let code: String = result["responsiveTsx"].as_str().unwrap().split_whitespace().collect(); + assert!(code.contains("bg={[\"#F00\",null,null,null,\"initial\"]}"), "{code}"); +} + +#[tokio::test] +async fn r13_responsive_mapping_gap_is_explicit_and_strict_rejects() { + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + // Desktop paint is unreadable, so a mobile fill cannot be safely + // cleared or accepted as a verified match at that width. + let mut data = responsive_payload(); + data.snapshot.nodes.get_mut("1:1").unwrap().fields.insert( + "fills".into(), + json!([{"type":"SOLID","color":{"r":1,"g":0,"b":0}}]), + ); + data.snapshot.nodes.get_mut("1:2").unwrap().fields.insert( + "fills".into(), json!({"$unsupported":"paint read failed"}), + ); + let result = project(data.clone(), op.clone()).await.unwrap(); + assert!(result["responsiveTsx"].is_string()); + assert_eq!(result["mappingComplete"], false); + let merge = result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .flat_map(|d| { + d["details"]["unmappedProperties"] + .as_array() + .into_iter() + .flatten() + }) + .find(|p| p["reason"] == "unverified-responsive-merge") + .unwrap(); + assert!( + merge["gaps"] + .as_array() + .unwrap() + .iter() + .any(|gap| gap["property"] == "bg" && gap["slot"] == 4 && gap["widthNodeId"] == "1:2"), + "{merge}" + ); + assert_ne!( + result["outputResults"]["responsiveTsx"]["projection"], + "exact" + ); + assert!( + result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|d| d["code"] == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" + && d["details"]["output"] == "responsiveTsx" + && d["fidelityImpact"] == "none") + ); + if let PendingOperation::Export { strict, .. } = &mut op { + *strict = true; + } + assert!(project(data, op).await.is_err()); +} + diff --git a/crates/devup-mcp/src/server/projection/safe_file_names.rs b/crates/devup-mcp/src/server/projection/safe_file_names.rs index c8a15cec..240650ae 100644 --- a/crates/devup-mcp/src/server/projection/safe_file_names.rs +++ b/crates/devup-mcp/src/server/projection/safe_file_names.rs @@ -1,13 +1,13 @@ -/// Bound generated filenames, retaining a hash when transliteration would lose -/// identity. The generated folders are short; user-chosen output roots remain +/// Bound semantic filenames without putting source IDs or hashes into public paths. +/// Unicode names remain readable on the supported filesystems. Generated folders are short; output roots remain /// subject to OutputPolicy and the host filesystem's own path limits. fn host_safe_file_name(name: &str) -> String { let (stem, extension) = name.rsplit_once('.').unwrap_or((name, "")); let mut slug = stem .chars() .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '@') { - c + if c.is_alphanumeric() || matches!(c, '-' | '_' | '@') { + c.to_lowercase().next().unwrap_or(c) } else { '-' } @@ -19,17 +19,19 @@ fn host_safe_file_name(name: &str) -> String { || (upper.len() == 4 && (upper.starts_with("COM") || upper.starts_with("LPT")) && matches!(upper.as_bytes()[3], b'1'..=b'9')); - let needs_hash = - !name.is_ascii() || slug != stem || slug.len() > 120 || slug.is_empty() || reserved; if slug.is_empty() || reserved { slug.insert_str(0, "asset-"); } - if needs_hash { - slug.truncate(100); + slug = slug + .split('-') + .filter(|part| !(part.len() >= 24 && part.chars().all(|c| c.is_ascii_hexdigit()))) + .collect::>() + .join("-"); + while slug.len() > 120 { + slug.pop(); } - if needs_hash { - slug.push('-'); - slug.push_str(&sha256_hex(name.as_bytes())[..12]); + if slug.is_empty() { + slug = "image".into(); } let extension = extension .chars() @@ -43,12 +45,17 @@ fn host_safe_file_name(name: &str) -> String { slug } -/// `/icons/ic:round-arrow-left.svg` as `/icons/ic-round-arrow-left-b041d24e668d.svg`. Only -/// the file name is touched; the folder the code looks in is left alone. +/// Preserve semantic subdirectories and Unicode while sanitizing unsafe path characters. fn host_safe_asset_path(path: &str) -> String { for prefix in ["/icons/", "/images/"] { if let Some(name) = path.strip_prefix(prefix) { - return format!("{prefix}{}", host_safe_file_name(name)); + return format!( + "{prefix}{}", + name.split('/') + .map(host_safe_file_name) + .collect::>() + .join("/") + ); } } match path.rsplit_once('/') { diff --git a/crates/devup-mcp/src/server/projection/setup.rs b/crates/devup-mcp/src/server/projection/setup.rs index 9cf58a07..aa4ea499 100644 --- a/crates/devup-mcp/src/server/projection/setup.rs +++ b/crates/devup-mcp/src/server/projection/setup.rs @@ -1,5 +1,9 @@ #[path = "asset_policy.rs"] mod asset_policy; +#[path = "canonical_assets.rs"] +mod canonical_assets; +#[path = "svg_identity.rs"] +mod svg_identity; #[path = "exclude_hidden_asset_outputs.rs"] mod exclude_hidden_asset_outputs; #[path = "finalize_export_delivery.rs"] diff --git a/crates/devup-mcp/src/server/projection/svg_identity.rs b/crates/devup-mcp/src/server/projection/svg_identity.rs new file mode 100644 index 00000000..df8f8217 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/svg_identity.rs @@ -0,0 +1,482 @@ +//! Conservative SVG identities. Unsafe or unsupported constructs fall back to byte identity. +use quick_xml::{Reader, XmlVersion, events::Event}; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Clone, Serialize)] +struct Element { + tag: String, + attrs: BTreeMap, + children: Vec, +} + +pub(super) struct Identity { + pub key: String, + pub geometry: String, + pub grayscale_filter: Option, +} + +// Identities use exact decimal fractions, never tolerance or rounded coordinates. +#[derive(Clone, Copy)] +struct Fraction(i128, i128); + +impl Fraction { + fn parse(value: &str) -> Option { + let (base, exponent) = value + .split_once(['e', 'E']) + .map_or(Some((value, 0)), |(base, exponent)| { + Some((base, exponent.parse::().ok()?)) + })?; + let negative = base.starts_with('-'); + let unsigned = base.trim_start_matches(['-', '+']); + let (whole, decimal) = unsigned.split_once('.').unwrap_or((unsigned, "")); + if whole.is_empty() && decimal.is_empty() { + return None; + } + if !whole + .bytes() + .chain(decimal.bytes()) + .all(|b| b.is_ascii_digit()) + { + return None; + } + let mut numerator = format!("{whole}{decimal}").parse::().ok()?; + if negative { + numerator = numerator.checked_neg()?; + } + let power = i32::try_from(decimal.len()).ok()?.checked_sub(exponent)?; + let denominator = if power >= 0 { + 10_i128.checked_pow(power.try_into().ok()?)? + } else { + numerator = numerator + .checked_mul(10_i128.checked_pow(power.checked_neg()?.try_into().ok()?)?)?; + 1 + }; + Some(Self::reduced(numerator, denominator)) + } + + fn reduced(numerator: i128, denominator: i128) -> Self { + fn gcd(mut a: u128, mut b: u128) -> u128 { + while b != 0 { + (a, b) = (b, a % b); + } + a + } + let divisor = gcd(numerator.unsigned_abs(), denominator as u128) as i128; + Self(numerator / divisor, denominator / divisor) + } + + fn divided_by(self, other: Self) -> Option { + if other.0 <= 0 { + return None; + } + let left = Self::reduced(self.0, other.0); + let right = Self::reduced(other.1, self.1); + let result = Self::reduced(left.0.checked_mul(right.0)?, left.1.checked_mul(right.1)?); + Some(if result.1 == 1 { + result.0.to_string() + } else { + format!("{}/{}", result.0, result.1) + }) + } +} + +fn numbers(value: &str) -> Option> { + value + .split(|c: char| c.is_ascii_whitespace() || c == ',') + .filter(|s| !s.is_empty()) + .map(Fraction::parse) + .collect() +} + +fn number(value: f64) -> String { + if value == 0.0 { + "0".into() + } else { + format!("{value:.10}") + .trim_end_matches('0') + .trim_end_matches('.') + .into() + } +} + +fn path(value: &str, width: Fraction, height: Fraction) -> Option { + let mut tokens = Vec::new(); + let mut cursor = 0; + let bytes = value.as_bytes(); + let mut command = ' '; + let mut slot = 0; + while cursor < bytes.len() { + let c = bytes[cursor] as char; + if c.is_ascii_whitespace() || c == ',' { + cursor += 1; + continue; + } + if c.is_ascii_alphabetic() { + if !"MmLlHhVvCcSsQqTtAaZz".contains(c) { + return None; + } + command = c; + slot = 0; + tokens.push(c.to_string()); + cursor += 1; + continue; + } + let start = cursor; + if matches!(bytes[cursor], b'+' | b'-') { + cursor += 1; + } + while cursor < bytes.len() && (bytes[cursor].is_ascii_digit() || bytes[cursor] == b'.') { + cursor += 1; + } + if cursor < bytes.len() && matches!(bytes[cursor], b'e' | b'E') { + cursor += 1; + if cursor < bytes.len() && matches!(bytes[cursor], b'+' | b'-') { + cursor += 1; + } + while cursor < bytes.len() && bytes[cursor].is_ascii_digit() { + cursor += 1; + } + } + if start == cursor { + return None; + } + let n = Fraction::parse(&value[start..cursor])?; + let upper = command.to_ascii_uppercase(); + let divisor = match upper { + 'H' => width, + 'V' => height, + 'A' => match slot % 7 { + 0 | 5 => width, + 1 | 6 => height, + _ => Fraction(1, 1), + }, + 'M' | 'L' | 'C' | 'S' | 'Q' | 'T' => { + if slot % 2 == 0 { + width + } else { + height + } + } + _ => return None, + }; + tokens.push(n.divided_by(divisor)?); + slot += 1; + } + Some(tokens.join(" ")) +} + +fn gray(value: &str) -> Option { + let rgb = match value { + "black" => 0, + "white" => 0xffffff, + _ => u32::from_str_radix(value.strip_prefix('#')?.get(..6)?, 16).ok()?, + }; + let (r, g, b) = ((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8); + (r == g && g == b).then_some(r) +} + +fn opaque_color(value: &str) -> bool { + matches!(value, "black" | "white" | "red" | "green" | "blue") + || value.strip_prefix('#').is_some_and(|hex| { + matches!(hex.len(), 3 | 6) && hex.bytes().all(|b| b.is_ascii_hexdigit()) + }) +} + +pub(super) fn identity(bytes: &[u8], mask: bool) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + if text.len() > 4 * 1024 * 1024 { + return None; + } + let mut reader = Reader::from_str(text); + reader.config_mut().trim_text(true); + let mut stack = Vec::::new(); + let mut root = None; + loop { + match reader.read_event().ok()? { + Event::Start(tag) | Event::Empty(tag) => { + let name = tag.name().as_ref().to_owned(); + if ![ + "svg", + "g", + "path", + "rect", + "circle", + "ellipse", + "line", + "polyline", + "polygon", + "defs", + "linearGradient", + "radialGradient", + "stop", + "clipPath", + "mask", + ] + .contains(&name.as_str()) + { + return None; + } + let attrs = tag + .attributes() + .map(|a| { + let a = a.ok()?; + Some(( + a.key.as_ref().to_owned(), + a.normalized_value(XmlVersion::Implicit1_0) + .ok()? + .into_owned(), + )) + }) + .collect::>>()?; + if attrs.iter().any(|(key, value)| { + key.starts_with("on") + || key == "style" + || ((key == "href" || key == "xlink:href") && !value.starts_with('#')) + || (value.contains("url(") && !value.starts_with("url(#")) + }) { + return None; + } + let element = Element { + tag: name, + attrs, + children: Vec::new(), + }; + // quick-xml's empty syntax is stable at the event byte boundary. + if text[..reader.buffer_position() as usize] + .trim_end() + .ends_with("/>") + { + if let Some(parent) = stack.last_mut() { + parent.children.push(element); + } else if root.replace(element).is_some() { + return None; + } + } else { + stack.push(element); + } + } + Event::End(_) => { + let element = stack.pop()?; + if let Some(parent) = stack.last_mut() { + parent.children.push(element); + } else if root.replace(element).is_some() { + return None; + } + } + Event::Eof => break, + Event::Comment(_) | Event::Decl(_) => {} + _ => return None, + } + } + if !stack.is_empty() { + return None; + } + let mut root = root?; + if root.tag != "svg" { + return None; + } + let view = numbers(root.attrs.get("viewBox")?)?; + if view.len() != 4 || view[0].0 != 0 || view[1].0 != 0 || view[2].0 <= 0 || view[3].0 <= 0 { + return None; + } + fn ids(element: &Element, out: &mut BTreeMap) -> Option<()> { + if let Some(id) = element.attrs.get("id") { + let next = format!("id{}", out.len()); + if out.insert(id.clone(), next).is_some() { + return None; + } + } + for child in &element.children { + ids(child, out)?; + } + Some(()) + } + let mut id_map = BTreeMap::new(); + ids(&root, &mut id_map)?; + fn complex(e: &Element) -> bool { + matches!(e.tag.as_str(), "defs" | "mask" | "clipPath") || e.children.iter().any(complex) + } + let mut flat = !complex(&root); + fn scalable(e: &Element) -> bool { + !e.attrs.contains_key("transform") + && !e.attrs.contains_key("vector-effect") + && !e.children.iter().any(|child| child.tag == "svg") + && e.attrs.iter().all(|(key, value)| { + ![ + "x", + "x1", + "x2", + "cx", + "rx", + "width", + "r", + "stroke-width", + "y", + "y1", + "y2", + "cy", + "ry", + "height", + "stroke-dashoffset", + ] + .contains(&key.as_str()) + || Fraction::parse(value).is_some() + || value.ends_with('%') + }) + && e.children.iter().all(scalable) + } + let scale = !complex(&root) && scalable(&root); + let mut paint_values = Vec::new(); + fn effective_paint(e: &Element, fill: &str, stroke: &str, out: &mut Vec) { + let fill = e.attrs.get("fill").map_or(fill, String::as_str); + let stroke = e.attrs.get("stroke").map_or(stroke, String::as_str); + if matches!( + e.tag.as_str(), + "path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" + ) { + if e.tag != "line" && fill != "none" { + out.push(fill.into()); + } + if stroke != "none" { + out.push(stroke.into()); + } + } + for child in &e.children { + effective_paint(child, fill, stroke, out); + } + } + effective_paint(&root, "black", "none", &mut paint_values); + fn normalize( + e: &mut Element, + ids: &BTreeMap, + dims: (Fraction, Fraction), + scale: bool, + paint: &mut Vec, + flat: &mut bool, + ) -> Option<()> { + for (key, value) in &mut e.attrs { + if key == "id" { + *value = ids.get(value)?.clone(); + continue; + } + if let Some(id) = value + .strip_prefix("url(#") + .and_then(|s| s.strip_suffix(')')) + { + *value = format!("url(#{})", ids.get(id)?); + } else if matches!(key.as_str(), "href" | "xlink:href") { + *value = format!("#{}", ids.get(value.strip_prefix('#')?)?); + } else if value.contains("url(") { + return None; + } + if ["fill", "stroke", "stop-color"].contains(&key.as_str()) && value != "none" { + if !opaque_color(value) { + *flat = false; + } else { + paint.push(value.clone()); + } + } + if key == "transform" { + *flat = false; + } + if scale { + if key == "d" { + *value = path(value, dims.0, dims.1)?; + } else if key == "points" { + let points = numbers(value)?; + if points.len() % 2 != 0 { + return None; + } + *value = points + .iter() + .enumerate() + .map(|(i, n)| n.divided_by(if i % 2 == 0 { dims.0 } else { dims.1 })) + .collect::>>()? + .join(" "); + } else if key == "stroke-dasharray" && value != "none" { + *value = numbers(value)? + .iter() + .map(|n| n.divided_by(dims.0)) + .collect::>>()? + .join(" "); + } else if [ + "x", + "x1", + "x2", + "cx", + "rx", + "width", + "r", + "stroke-width", + "stroke-dashoffset", + ] + .contains(&key.as_str()) + { + if let Some(n) = Fraction::parse(value) { + *value = n.divided_by(dims.0)?; + } + } else if ["y", "y1", "y2", "cy", "ry", "height"].contains(&key.as_str()) + && let Some(n) = Fraction::parse(value) { + *value = n.divided_by(dims.1)?; + } + } + } + for child in &mut e.children { + normalize(child, ids, dims, scale, paint, flat)?; + } + Some(()) + } + normalize( + &mut root, + &id_map, + (view[2], view[3]), + scale, + &mut paint_values, + &mut flat, + )?; + root.attrs.remove("width"); + root.attrs.remove("height"); + if scale { + root.attrs.insert( + "viewBox".into(), + format!("0 0 {} 1", view[2].divided_by(view[3])?), + ); + } + let full = serde_json::to_vec(&root).ok()?; + fn strip(e: &mut Element) { + for key in ["fill", "stroke", "stop-color"] { + if let Some(value) = e.attrs.get_mut(key) + && value != "none" + { + *value = "paint".into(); + } + } + for child in &mut e.children { + strip(child); + } + } + let grayscale = if flat { + paint_values + .first() + .and_then(|v| gray(v)) + .filter(|g| paint_values.iter().all(|v| gray(v) == Some(*g))) + } else { + None + }; + if flat { + strip(&mut root); + } + let geometry = serde_json::to_vec(&root).ok()?; + let key = if mask && flat { &geometry } else { &full }; + Some(Identity { + key: super::sha256_hex(key), + geometry: super::sha256_hex(&geometry), + grayscale_filter: grayscale.map(|g| { + format!( + "grayscale(1) brightness(0) invert({})", + number(f64::from(g) / 255.0) + ) + }), + }) +} diff --git a/crates/devup-mcp/src/server/projection/truncated_delivery_tests.rs b/crates/devup-mcp/src/server/projection/truncated_delivery_tests.rs new file mode 100644 index 00000000..f4d97965 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/truncated_delivery_tests.rs @@ -0,0 +1,76 @@ +mod truncated_delivery_tests { + use super::*; + + fn oversized_request() -> (CollectedPayload, PendingOperation) { + let mut data = payload(); + data.metadata = json!({"large": "x".repeat(100_000)}); + let mut request = operation(&["rawPayload"]); + if let PendingOperation::Export { + response_selection, .. + } = &mut request + { + response_selection.response_fields = vec!["rawPayload".into()]; + } + (data, request) + } + + fn assert_recovery_required(response: &mut Value) { + assert_eq!(response["status"], "partial"); + assert_eq!(response["deliveryState"], "recovery-required"); + assert_eq!(response["deliverable"]["isFinal"], false); + assert_eq!(response["deliverable"]["recoveryRequired"], true); + assert_eq!( + response["nextAction"], + response["responseTruncated"]["nextAction"] + ); + assert_eq!( + response["nextAction"]["arguments"]["artifactId"], + response["artifactId"] + ); + super::super::super::review::attach(response); + assert_eq!(response["summary"]["required"], 1); + assert!( + response["summary"]["next"] + .as_str() + .unwrap() + .contains("required") + ); + assert_eq!( + response["reviewChecklist"][0]["code"], + "DEVUP_EXPORT_RESPONSE_RECOVERY_REQUIRED" + ); + } + + #[tokio::test] + async fn oversized_selection_without_writes_is_partial_and_requires_recovery() { + let (data, request) = oversized_request(); + let mut response = project(data, request).await.unwrap(); + assert_eq!(response["written"], false); + assert_eq!(response["outputPaths"], json!({})); + assert_recovery_required(&mut response); + } + + #[tokio::test] + async fn oversized_selection_keeps_committed_files_but_never_claims_final_delivery() { + let directory = std::env::temp_dir().join(format!( + "devup-truncated-delivery-{:016x}", + rand::random::() + )); + std::fs::create_dir(&directory).unwrap(); + let target = directory.join("payload.json"); + let (data, mut request) = oversized_request(); + if let PendingOperation::Export { output_paths, .. } = &mut request { + output_paths.insert("rawPayload".into(), target.to_string_lossy().into_owned()); + } + let mut response = project(data, request).await.unwrap(); + assert_eq!(response["written"], true); + assert_eq!( + response["outputPaths"]["rawPayload"], + json!(crate::test_paths::canonical(&target)) + ); + let saved: Value = serde_json::from_slice(&std::fs::read(&target).unwrap()).unwrap(); + assert_eq!(saved["metadata"]["large"].as_str().unwrap().len(), 100_000); + assert_recovery_required(&mut response); + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/crates/devup-mcp/src/server/projection/w1_live_header_icon_contract.rs b/crates/devup-mcp/src/server/projection/w1_live_header_icon_contract.rs new file mode 100644 index 00000000..80ccde86 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/w1_live_header_icon_contract.rs @@ -0,0 +1,25 @@ +fn assert_r4_live_header_icon_contract(snapshot: &devup_mcp_figma::Snapshot) { + let probe = devup_mcp_devup_ui::codegen::generate_component( + snapshot, + "3997:46582", + &devup_mcp_devup_ui::codegen::CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap(); + // A 50px vertically FILL header wrapper must retain its fixed 32px icon, + // not export the whole wrapper as a scalable 50px picture. The child's + // two independently represented dimensions add two covered obligations. + let icon = "I3997:46589;65:3063"; + for field in ["width", "height"] { + assert!( + probe.source_map.entries.iter().any(|entry| { + entry.node_id.as_deref() == Some(icon) + && entry.property.as_deref() == Some(field) + && entry.generated_range.is_some() + }), + "The live icon must retain its own {field} source mapping" + ); + } +} diff --git a/crates/devup-mcp/src/server/projection/w1_regressions.rs b/crates/devup-mcp/src/server/projection/w1_regressions.rs index c2ab553b..d4c25f79 100644 --- a/crates/devup-mcp/src/server/projection/w1_regressions.rs +++ b/crates/devup-mcp/src/server/projection/w1_regressions.rs @@ -1,8 +1,10 @@ +include!("responsive_paint_regressions.rs"); include!("design_drift_tests.rs"); include!("feedback_theme_tests.rs"); include!("feedback_codegen_tests.rs"); include!("feedback_delivery_tests.rs"); +include!("truncated_delivery_tests.rs"); use super::super::artifacts::ArtifactRequestKey; use super::*; use devup_mcp_figma::{CollectionRequest, CollectionScope}; @@ -285,61 +287,6 @@ async fn r13_agreeing_widths_verify_the_responsive_merge() { ); } -#[tokio::test] -async fn r13_responsive_mapping_gap_is_explicit_and_strict_rejects() { - let mut op = operation(&["responsiveTsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - // Only the mobile width is filled. devup-ui keeps a value into the - // wider slots, so the desktop width is drawn red where it is not. - let mut data = responsive_payload(); - data.snapshot.nodes.get_mut("1:1").unwrap().fields.insert( - "fills".into(), - json!([{"type":"SOLID","color":{"r":1,"g":0,"b":0}}]), - ); - let result = project(data.clone(), op.clone()).await.unwrap(); - assert!(result["responsiveTsx"].is_string()); - assert_eq!(result["mappingComplete"], false); - let merge = result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .flat_map(|d| { - d["details"]["unmappedProperties"] - .as_array() - .into_iter() - .flatten() - }) - .find(|p| p["reason"] == "unverified-responsive-merge") - .unwrap(); - assert!( - merge["gaps"] - .as_array() - .unwrap() - .iter() - .any(|gap| gap["property"] == "bg" && gap["slot"] == 4 && gap["widthNodeId"] == "1:2"), - "{merge}" - ); - assert_ne!( - result["outputResults"]["responsiveTsx"]["projection"], - "exact" - ); - assert!( - result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|d| d["code"] == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" - && d["details"]["output"] == "responsiveTsx" - && d["fidelityImpact"] == "none") - ); - if let PendingOperation::Export { strict, .. } = &mut op { - *strict = true; - } - assert!(project(data, op).await.is_err()); -} - #[tokio::test] async fn r13_final_frame_contract_covers_both_outputs_after_filename_rewrite() { let mut data = payload(); @@ -771,6 +718,7 @@ async fn r2_production_six_frames_return_valid_story_tsx() { if let PendingOperation::Export { frame_ids, include_diagnostics, + delivery, .. } = &mut op { @@ -785,8 +733,35 @@ async fn r2_production_six_frames_return_valid_story_tsx() { .map(str::to_owned) .to_vec(); *include_diagnostics = false; + *delivery = DeliveryMode::Auto; } - let result = project(data, op).await.unwrap(); + // Complete source evidence can outgrow inline delivery; normal auto mode + // must retain all six frame bodies in readable resources without widening + // the transport limit or discarding diagnostic evidence. + let store = ArtifactStore::default(); + let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), CollectionScope::Node, + )); + let artifact = store.insert(key, data).await.unwrap(); + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + let mut result = complete_operation(op, &artifact.payload, "fixture", &artifact, &policy, &store).await.unwrap(); + let mut frame_bodies = BTreeMap::new(); + for manifest in store.output_manifests().await { + let frame = manifest.name.strip_prefix("frame-").and_then(|name| name.strip_suffix(".tsx")).and_then(|index| index.parse::().ok()); + if frame.is_none() && manifest.name != "envelope-overflow.json" { continue; } + let mut bytes = Vec::new(); + for chunk in 0..manifest.chunk_count { + bytes.extend(store.read_output_chunk(&artifact.artifact_id, &manifest.output_id, chunk).await.unwrap()); + } + if let Some(index) = frame { + frame_bodies.insert(index, String::from_utf8(bytes).unwrap()); + } else { + let full: Map = serde_json::from_slice(&bytes).unwrap(); + result.as_object_mut().unwrap().extend(full); + } + } + assert_eq!(frame_bodies.len(), 6); + for (index, body) in frame_bodies { result["frames"][index - 1]["tsx"] = json!(body); } assert_eq!(result["frames"].as_array().unwrap().len(), 6); for frame in result["frames"].as_array().unwrap() { let tsx = frame["tsx"] @@ -885,6 +860,11 @@ async fn r5_wquw119_missing_mask_size_is_output_scoped_and_not_final() { data.target.node_id = Some("3997:46315".into()); data.target.file_key = data.snapshot.file_key.clone(); let node = data.snapshot.nodes.get_mut("3997:46317").unwrap(); + // The unmodified FILL wrapper now stays live around its fixed-size Logo. + // This regression specifically exercises a caller-declared folded mask, + // whose own missing dimension must remain an output-scoped fidelity loss. + // live_asset_layout covers the original, unmodified capture separately. + node.fields.insert("isAsset".into(), json!(true)); node.fields.remove("height"); node.fields.remove("absoluteBoundingBox"); let mut op = operation(&["tsx", "sourceMap"]); diff --git a/crates/devup-mcp/src/server/projection/w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs b/crates/devup-mcp/src/server/projection/w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs index 7a0e3a0b..17018478 100644 --- a/crates/devup-mcp/src/server/projection/w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs +++ b/crates/devup-mcp/src/server/projection/w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs @@ -1,4 +1,3 @@ - #[tokio::test] async fn original_source_variant_and_css_map_resources_recover_without_reacquiring() { let store = ArtifactStore::default(); @@ -194,7 +193,13 @@ async fn image_modes_keep_file_manifest_and_shared_resource_observations_consist asset["outputPath"], result["outputPaths"][format!("asset:{}", asset["assetId"].as_str().unwrap())] ); - assert!(asset["suggestedFilename"].as_str().unwrap().contains(mode)); + assert!( + asset["suggestedFilename"] + .as_str() + .unwrap() + .ends_with(".png") + ); + assert!(asset["renderInfo"]["canonicalAsset"]["path"].is_string()); } if delivery_mode == DeliveryMode::Resource { let resource = result["resources"] @@ -437,8 +442,16 @@ async fn webp_preset_replays_more_than_six_captures_with_public_files_and_couple assert_eq!(result["webReadyImages"]["total"], 9); assert_eq!(result["webReadyImages"]["failed"], 0); let files = result["outputPaths"].as_object().unwrap(); - assert_eq!(files.len(), 9); - for path in files.values().filter_map(Value::as_str) { + assert_eq!( + files.keys().filter(|key| key.starts_with("asset:")).count(), + 9 + ); + assert!(files.contains_key("assetRegistry")); + for path in files + .iter() + .filter(|(key, _)| key.starts_with("asset:")) + .filter_map(|(_, path)| path.as_str()) + { assert!(path.ends_with(".webp")); assert_eq!( image::guess_format(&std::fs::read(path).unwrap()).unwrap(), diff --git a/crates/devup-mcp/src/server/projection/w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs b/crates/devup-mcp/src/server/projection/w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs index 12012bf4..558e6ebd 100644 --- a/crates/devup-mcp/src/server/projection/w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs +++ b/crates/devup-mcp/src/server/projection/w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs @@ -1,3 +1,4 @@ +include!("w1_live_header_icon_contract.rs"); #[tokio::test] async fn r4_export_exposes_placement_contract_without_diagnostics() { @@ -5,6 +6,7 @@ async fn r4_export_exposes_placement_contract_without_diagnostics() { "../../../../../fixtures/r2/wquw-120-payload.json" )) .unwrap(); + assert_r4_live_header_icon_contract(&data.snapshot); let mut op = operation(&["tsx", "componentTsx"]); if let PendingOperation::Export { frame_ids, @@ -41,7 +43,7 @@ async fn r4_export_exposes_placement_contract_without_diagnostics() { assert_eq!(result["placementContracts"].as_array().unwrap().len(), 2); assert_eq!( frame["outputResults"]["tsx"]["fidelity"]["layout"]["covered"], - 134 // R17: prior 117 + 17 verified horizontal FILL obligations. + 136 // Prior 134 plus the live Header icon's independent 32px width/height. ); assert_eq!( frame["outputResults"]["componentTsx"]["fidelity"]["impacts"]["lossy"], @@ -103,7 +105,9 @@ async fn r9_confirmed_evidence_survives_diagnostics_opt_out() { .as_array() .unwrap() .iter() - .any(|d| d["nodeId"] == "3997:46621") + .any(|d| d["nodeId"] == "3997:46621"), + "Hidden node must not create fidelity issues: {:?}", + owner["projectionIssues"] ); } } @@ -411,8 +415,12 @@ async fn r1_approximation_is_explained_without_diagnostics() { let frame = &result["frames"][0]; assert_eq!(frame["quality"]["projection"], "lossy"); assert!(!frame["tsx"].as_str().unwrap().contains("pos=\"absolute\"")); - let issue = frame["projectionIssues"].as_array().unwrap().iter() - .find(|issue| issue["code"] == "DEVUP_CODEGEN_ABSOLUTE_FALLBACK").unwrap(); + let issue = frame["projectionIssues"] + .as_array() + .unwrap() + .iter() + .find(|issue| issue["code"] == "DEVUP_CODEGEN_ABSOLUTE_FALLBACK") + .unwrap(); assert_eq!(issue["fidelityImpact"], "approximated"); assert_eq!(issue["nodeId"], "1:1"); assert_eq!(issue["property"], "layoutPositioning"); diff --git a/crates/devup-mcp/src/server/projection/w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs b/crates/devup-mcp/src/server/projection/w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs index e2db3880..6b813b01 100644 --- a/crates/devup-mcp/src/server/projection/w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs +++ b/crates/devup-mcp/src/server/projection/w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs @@ -1,4 +1,3 @@ - #[tokio::test] async fn r7_real_wquw118_layout_contracts_are_preserved() { let data: CollectedPayload = serde_json::from_str(include_str!( @@ -210,99 +209,11 @@ fn r8_nonsection_semantic_asset_mapping_uses_delivered_path() { rewrite_result_asset_references(&mut result, &BTreeMap::new()); assert_eq!( result["sourceMap"]["tsx"][0]["generatedProperty"], - "src=\"/icons/grommet-icons-language-d201f62b16f5.svg\"" + "src=\"/icons/grommet-icons-language.svg\"" ); } -#[tokio::test] -async fn r8_three_frame_nine_output_units_measure_local_projection() { - let data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../../fixtures/r2/wquw-119-payload.json" - )) - .unwrap(); - let mut op = operation(&["tsx", "rawSnapshot", "sourceMap"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec![ - "3997:46315".into(), - "3997:46715".into(), - "3997:46333".into(), - ]; - } - let began = std::time::Instant::now(); - let store = ArtifactStore::default(); - let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )); - let artifact = store.insert(key, data).await.unwrap(); - let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); - // R17 source FILL issues exceed the unchanged inline limit. Exercise the - // retained-artifact recovery and read every output, rather than hiding loss. - let error = complete_operation( - op.clone(), - &artifact.payload, - "fixture", - &artifact, - &policy, - &store, - ) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); - assert_eq!(error.details["recoveryState"], "available"); - assert_eq!( - error.details["nextAction"]["arguments"]["artifactId"], - artifact.artifact_id - ); - assert_eq!( - error.details["nextAction"]["arguments"]["delivery"], - "resource" - ); - if let PendingOperation::Export { delivery, .. } = &mut op { - *delivery = DeliveryMode::Resource; - } - let mut result = - complete_operation(op, &artifact.payload, "fixture", &artifact, &policy, &store) - .await - .unwrap(); - let mut outputs = BTreeMap::new(); - for manifest in store.output_manifests().await { - let mut bytes = Vec::new(); - for index in 0..manifest.chunk_count { - bytes.extend( - store - .read_output_chunk(&artifact.artifact_id, &manifest.output_id, index) - .await - .unwrap(), - ); - } - assert_eq!(bytes.len(), manifest.raw_bytes); - outputs.insert(manifest.name, String::from_utf8(bytes).unwrap()); - } - assert_eq!(outputs.len(), 8); // shared snapshot/audit plus 3 TSX/sourceMap pairs - assert_eq!( - serde_json::from_str::(&outputs["raw-snapshot.json"]).unwrap(), - serde_json::to_value(&artifact.payload.snapshot).unwrap() - ); - for index in 0..3 { - let tsx = &outputs[&format!("frame-{}.tsx", index + 1)]; - assert!(tsx.contains("export function")); - result["frames"][index]["tsx"] = json!(tsx); - result["frames"][index]["sourceMap"] = - serde_json::from_str(&outputs[&format!("frame-{}.source-map.json", index + 1)]) - .unwrap(); - } - assert_eq!(result["frames"].as_array().unwrap().len(), 3); - for frame in result["frames"].as_array().unwrap() { - assert!(frame["tsx"].is_string()); - assert!(frame["sourceMap"].is_object()); - assert!(frame["sourceMap"]["rasterizedAssets"].is_array()); - } - eprintln!( - "R8 offline 3-frame/9-unit projection: {:?}; no upstream timing inferred", - began.elapsed() - ); -} +include!("w1_three_frame_resource_delivery.rs"); #[tokio::test] async fn r8_final_korean_semantic_map_follows_asset_path_rewrites() { @@ -705,9 +616,36 @@ async fn feedback_interaction_hints_match_saved_files_and_resource_hashes() { assert_eq!(bytes.len(), manifest.raw_bytes); assert_eq!(sha256_hex(&bytes), manifest.sha256); let code = String::from_utf8(bytes).unwrap(); - assert!(code.contains("// @devup-interaction ")); - assert!(code.contains("https://example.com/confirmed")); - assert!(code.contains("disabled")); + assert!(!code.contains("// @devup-")); + assert!(!code.contains("https://example.com/confirmed")); + let hints = result["interactionHints"].to_string(); + assert!(hints.contains("https://example.com/confirmed"), "{result}"); + assert!(hints.contains("disabled"), "{result}"); + let hints_manifest = store + .output_manifests() + .await + .into_iter() + .find(|manifest| manifest.name == "interaction-hints.json") + .expect("interaction evidence resource"); + let mut hint_bytes = Vec::new(); + for index in 0..hints_manifest.chunk_count { + hint_bytes.extend( + store + .read_output_chunk( + &hints_manifest.artifact_id, + &hints_manifest.output_id, + index, + ) + .await + .unwrap(), + ); + } + assert_eq!(sha256_hex(&hint_bytes), hints_manifest.sha256); + assert_eq!(hint_bytes.len(), hints_manifest.raw_bytes); + assert_eq!( + serde_json::from_slice::(&hint_bytes).unwrap(), + result["interactionHints"] + ); assert!(!code.contains("href=")); assert_eq!(result["deliverable"]["isFinal"], false); assert_eq!(result["mappingComplete"], baseline["mappingComplete"]); @@ -775,7 +713,10 @@ async fn w1_unavailable_responsive_output_is_actionable_even_with_tsx_and_strict let response = project(section(2), op).await; if outputs == vec!["responsiveTsx"] { let error = response.unwrap_err(); - assert_eq!(error.details["nextAction"]["arguments"]["outputs"], json!(["tsx"])); + assert_eq!( + error.details["nextAction"]["arguments"]["outputs"], + json!(["tsx"]) + ); assert_eq!(error.details["frameReasons"].as_array().unwrap().len(), 2); continue; } @@ -784,11 +725,24 @@ async fn w1_unavailable_responsive_output_is_actionable_even_with_tsx_and_strict } else { response.unwrap() }; - let failure = result["failures"].as_array().unwrap().iter() - .find(|failure| failure["output"] == "responsiveTsx").unwrap(); - assert_eq!(failure["details"]["nextAction"]["tool"], "devup_figma_export"); - assert_eq!(failure["details"]["nextAction"]["arguments"]["outputs"], json!(["tsx"])); - assert_eq!(failure["details"]["frameReasons"].as_array().unwrap().len(), 2); + let failure = result["failures"] + .as_array() + .unwrap() + .iter() + .find(|failure| failure["output"] == "responsiveTsx") + .unwrap(); + assert_eq!( + failure["details"]["nextAction"]["tool"], + "devup_figma_export" + ); + assert_eq!( + failure["details"]["nextAction"]["arguments"]["outputs"], + json!(["tsx"]) + ); + assert_eq!( + failure["details"]["frameReasons"].as_array().unwrap().len(), + 2 + ); if !strict_value { assert_eq!(result["outputResults"]["responsiveTsx"]["state"], "failed"); } @@ -936,10 +890,10 @@ async fn w1_resource_delivery_moves_all_code_and_updates_note() { } #[test] -fn w1_asset_paths_are_ascii_bounded_and_windows_safe() { +fn w1_asset_paths_are_semantic_bounded_and_windows_safe() { assert_eq!( host_safe_asset_path("/images/Frame 269.png"), - "/images/Frame-269-3e2af8571e3f.png" + "/images/frame-269.png" ); for name in [ "한글.svg".to_owned(), @@ -947,7 +901,7 @@ fn w1_asset_paths_are_ascii_bounded_and_windows_safe() { format!("{}.png", "a".repeat(300)), ] { let path = host_safe_asset_path(&format!("/icons/{name}")); - assert!(path.is_ascii()); + assert!(!path.contains(['?', ':', '\\'])); assert!(path.len() < 200); assert_ne!(path.to_uppercase(), "/ICONS/CON.SVG"); assert_eq!(host_safe_asset_path(&path), path); diff --git a/crates/devup-mcp/src/server/projection/w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs b/crates/devup-mcp/src/server/projection/w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs index f925e385..5113e616 100644 --- a/crates/devup-mcp/src/server/projection/w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs +++ b/crates/devup-mcp/src/server/projection/w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs @@ -1,4 +1,3 @@ - #[tokio::test] async fn w1_hidden_asset_is_warning_and_requested_failure_is_partial() { let mut data = payload(); @@ -59,7 +58,7 @@ async fn w1_manifest_preserves_layer_name_and_section_paths_match() { let asset = &result["assetManifest"]["assets"][0]; assert_eq!(asset["layerName"], "BI - 아이콘"); let path = asset["path"].as_str().unwrap(); - assert!(path.is_ascii()); + assert!(!path.contains(['?', ':', '\\'])); assert!(!path.contains(' ')); assert!(result["frames"][0]["tsx"].as_str().unwrap().contains(path)); } @@ -258,9 +257,9 @@ fn w1_slash_in_layer_name_matches_manifest_and_code() { let original = "/icons/한글 Foo/Bar-1-2.svg"; let path = host_safe_asset_path(original); let code = with_host_safe_asset_paths(&format!("")); - assert!(path.is_ascii()); + assert!(path.contains("한글"), "meaningful Unicode names are preserved"); assert!(!path.contains(' ')); - assert_eq!(path.matches('/').count(), 2); + assert_eq!(path.matches('/').count(), 3); assert!(code.contains(&path)); } @@ -288,8 +287,7 @@ async fn w1_captured_main_component_id_is_reported_when_missing() { assert_eq!(result["failures"][0]["details"]["componentId"], "9:9"); } -#[tokio::test] -async fn w1_responsive_text_loss_is_partial_and_strict_rejects() { +fn responsive_literal_payload(known_characters: bool) -> CollectedPayload { let mut data = responsive_payload(); for (root, id, text) in [ ("1:1", "2:1", "Mobile only"), @@ -301,13 +299,54 @@ async fn w1_responsive_text_loss_is_partial_and_strict_rejects() { .unwrap() .fields .insert("childrenIds".into(), json!([id])); - data.snapshot.nodes.insert(id.into(), serde_json::from_value(json!({"id":id,"type":"TEXT", - "fields":{"name":"Label","parentId":root,"characters":text,"visible":true,"fontSize":16}})).unwrap()); + let mut node: devup_mcp_figma::RawNode = serde_json::from_value(json!({"id":id,"type":"TEXT", + "fields":{"name":"Label","parentId":root,"characters":text,"visible":true,"fontSize":16}})).unwrap(); + if !known_characters { + // Partial styled runs can render text but do not establish that the + // authoritative full characters value was captured successfully. + node.fields.remove("characters"); + node.fields.insert( + "styledTextSegments".into(), + json!([{"characters":text,"fontSize":16}]), + ); + } + data.snapshot.nodes.insert(id.into(), node); } + data +} + +#[tokio::test] +async fn w1_responsive_captured_literal_copies_are_preserved() { let mut op = operation(&["responsiveTsx"]); if let PendingOperation::Export { all_screens, .. } = &mut op { *all_screens = true; } + let result = project(responsive_literal_payload(true), op).await.unwrap(); + assert!( + result["responsiveUnrepresented"] + .as_array() + .is_none_or(Vec::is_empty) + ); + let tsx = result["responsiveTsx"].as_str().unwrap(); + assert!( + tsx.contains("Mobile only") && tsx.contains("Desktop only"), + "{tsx}" + ); + assert!( + tsx.contains("display="), + "Different captured copies must be viewport gated: {tsx}" + ); + // This positive content proof does not waive unrelated incomplete layout or + // typography evidence from the deliberately small source fixture. +} + +#[tokio::test] +async fn w1_responsive_unknown_full_characters_is_partial_and_strict_rejects() { + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let data = responsive_literal_payload(false); let result = project(data.clone(), op.clone()).await.unwrap(); assert!( result["responsiveUnrepresented"] @@ -348,8 +387,13 @@ async fn w1_missing_collected_outputs_explain_how_to_recollect() { assert!(error.message.contains(output)); assert!(error.details["snapshotRootIds"].is_array()); } - let result = project(payload(), operation(&["referencePng"])).await.unwrap(); - assert_eq!(result["outputResults"]["referencePng"]["state"], "unavailable"); + let result = project(payload(), operation(&["referencePng"])) + .await + .unwrap(); + assert_eq!( + result["outputResults"]["referencePng"]["state"], + "unavailable" + ); assert_ne!(result["status"], "complete"); assert!(result["artifactId"].is_string()); } @@ -373,12 +417,14 @@ async fn w1_optional_responsive_collision_keeps_requested_tsx() { } #[test] -fn w1_ascii_slugs_do_not_merge_distinct_variant_assets() { - assert_ne!( +fn w1_sanitized_name_collisions_require_content_registry_reconciliation() { + // Name normalization cannot prove content identity; registry reconciliation + // detects differing bytes before any public path is written. + assert_eq!( host_safe_asset_path("/icons/Icons=a b.svg"), host_safe_asset_path("/icons/Icons=a-b.svg") ); - assert_ne!( + assert_eq!( host_safe_asset_path("/icons/Foo Bar.svg"), host_safe_asset_path("/icons/Foo-Bar.svg") ); diff --git a/crates/devup-mcp/src/server/projection/w1_three_frame_resource_delivery.rs b/crates/devup-mcp/src/server/projection/w1_three_frame_resource_delivery.rs new file mode 100644 index 00000000..a6dc7701 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/w1_three_frame_resource_delivery.rs @@ -0,0 +1,130 @@ +#[tokio::test] +async fn r8_three_frame_requested_outputs_and_complete_proof_measure_local_projection() { + let data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-119-payload.json" + )) + .unwrap(); + let mut op = operation(&["tsx", "rawSnapshot", "sourceMap"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec![ + "3997:46315".into(), + "3997:46715".into(), + "3997:46333".into(), + ]; + } + let began = std::time::Instant::now(); + let store = ArtifactStore::default(); + let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )); + let artifact = store.insert(key, data).await.unwrap(); + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + // R17 source FILL issues exceed the unchanged inline limit. Exercise the + // retained-artifact recovery and read every output, rather than hiding loss. + let error = complete_operation( + op.clone(), + &artifact.payload, + "fixture", + &artifact, + &policy, + &store, + ) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); + assert_eq!(error.details["recoveryState"], "available"); + assert_eq!( + error.details["nextAction"]["arguments"]["artifactId"], + artifact.artifact_id + ); + assert_eq!( + error.details["nextAction"]["arguments"]["delivery"], + "resource" + ); + if let PendingOperation::Export { delivery, .. } = &mut op { + *delivery = DeliveryMode::Resource; + } + let mut result = + complete_operation(op, &artifact.payload, "fixture", &artifact, &policy, &store) + .await + .unwrap(); + let mut outputs = BTreeMap::new(); + for manifest in store.output_manifests().await { + let mut bytes = Vec::new(); + for index in 0..manifest.chunk_count { + bytes.extend( + store + .read_output_chunk(&artifact.artifact_id, &manifest.output_id, index) + .await + .unwrap(), + ); + } + assert_eq!(bytes.len(), manifest.raw_bytes); + outputs.insert(manifest.name, String::from_utf8(bytes).unwrap()); + } + // Fixed literal loss can reduce metadata below the unchanged spill limit. + // Every requested frame body/map and the raw snapshot remain mandatory; + // complete diagnostic proof follows the existing inline/resource policy. + let details = outputs + .get("projection-details.json") + .map(|text| serde_json::from_str::(text).unwrap()); + let response_proof = details.as_ref().map_or(&result, |body| &body["response"]); + assert!(response_proof["projectionEvidence"].is_array()); + assert!(response_proof["projectionIssues"].is_array()); + let proof_frames = details + .as_ref() + .map_or(&result["frames"], |body| &body["frames"]); + assert_eq!(proof_frames.as_array().unwrap().len(), 3); + for frame in proof_frames.as_array().unwrap() { + assert!(frame["projectionEvidence"].is_array()); + assert!(frame["projectionIssues"].is_array()); + if details.is_none() { + assert!( + frame["metadataPreviewFields"] + .get("projectionEvidence") + .is_none() + ); + assert!( + frame["metadataPreviewFields"] + .get("projectionIssues") + .is_none() + ); + } + } + assert!(outputs.contains_key("image-fill-audit.json")); + for name in ["interaction-hints.json", "behavior-plan.json", "development-contract.json"] { + let body: Value = serde_json::from_str(&outputs[name]).unwrap(); + assert!(body.is_object() || body.is_array(), "{name} must retain structured evidence"); + } + let expected_retained_files = 3 * 2 + 5 + usize::from(details.is_some()); + assert_eq!( + outputs.len(), + expected_retained_files, + "{:?}", + outputs.keys() + ); + assert_eq!( + serde_json::from_str::(&outputs["raw-snapshot.json"]).unwrap(), + serde_json::to_value(&artifact.payload.snapshot).unwrap() + ); + for index in 0..3 { + let tsx = &outputs[&format!("frame-{}.tsx", index + 1)]; + assert!(tsx.contains("export function")); + result["frames"][index]["tsx"] = json!(tsx); + result["frames"][index]["sourceMap"] = + serde_json::from_str(&outputs[&format!("frame-{}.source-map.json", index + 1)]) + .unwrap(); + } + assert_eq!(result["frames"].as_array().unwrap().len(), 3); + for frame in result["frames"].as_array().unwrap() { + assert!(frame["tsx"].is_string()); + assert!(frame["sourceMap"].is_object()); + assert!(frame["sourceMap"]["rasterizedAssets"].is_array()); + } + eprintln!( + "R8 offline 3-frame/9 requested projection units/{} retained files: {:?}; no upstream timing inferred", + outputs.len(), + began.elapsed() + ); +} diff --git a/crates/devup-mcp/src/server/runtime_audit.rs b/crates/devup-mcp/src/server/runtime_audit.rs index 3a8f528a..d589b9f1 100644 --- a/crates/devup-mcp/src/server/runtime_audit.rs +++ b/crates/devup-mcp/src/server/runtime_audit.rs @@ -18,3 +18,4 @@ include!("runtime_audit/capture_comparison.rs"); include!("runtime_audit/plan_columns.rs"); include!("runtime_audit/screenshot_integrity.rs"); include!("runtime_audit/tests.rs"); +include!("runtime_audit/selection_tests.rs"); diff --git a/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs b/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs index 20449bad..cd0c9220 100644 --- a/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs +++ b/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs @@ -25,7 +25,7 @@ fn capture_comparison( let mut blockers = Vec::new(); if (viewport.device_scale_factor - 1.0).abs() > f64::EPSILON { blockers.push(format!( - "Captured at deviceScaleFactor {}; the native render is at scale 1.", + "Declared DPR {} does not establish logical-to-PNG scale; independent capture/reference calibration is missing, so strict pixels remain inconclusive.", viewport.device_scale_factor )); } @@ -51,7 +51,7 @@ fn capture_comparison( }, "ifItFails": {"class": class, "fixHint": fix_hint(class, "capture", &Value::Null)}, "blockers": blockers, - "note": "A size mismatch with the native render is reported as dimensions, not pixels: fix the element's size (its bounds check says by how much) and compare again.", + "note": "Incompatible PNG dimensions make strict pixels inconclusive. Inspect capture scale/scope and compare logical element bounds separately; do not infer a layout error from raster dimensions or resize/crop to force a match.", }) } @@ -82,6 +82,23 @@ fn visual_comparison( "environment.devupUiVersion", "environment.fontManifest", ]; + let capture_context = report["runs"] + .as_array() + .into_iter() + .flatten() + .find(|run| { + run["viewport"]["width"] == viewport.width + && run["viewport"]["height"] == viewport.height + }) + .and_then(|run| run["screens"].as_array()) + .and_then(|screens| screens.iter().find(|row| row["id"] == screen.id)) + .map(|row| row["captureContext"].clone()) + .filter(Value::is_object); + if let Some(context) = capture_context { + environment["captureContext"] = context; + } else { + must_add.push("environment.captureContext"); + } let browser = &report["executor"]["browser"]; match (browser["name"].as_str(), browser["version"].as_str()) { (Some(name), Some(version)) => { @@ -107,7 +124,7 @@ fn visual_comparison( let mut blockers = Vec::new(); if (viewport.device_scale_factor - 1.0).abs() > f64::EPSILON { blockers.push(format!( - "Captured at deviceScaleFactor {}; the Figma reference is at scale 1, so plan this width at 1.", + "Declared DPR {} does not establish logical-to-PNG scale; independent capture/reference calibration is missing, so strict pixels remain inconclusive.", viewport.device_scale_factor )); } @@ -186,6 +203,10 @@ pub struct CompletenessInput { /// a non-empty box, an decoded. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub images: Vec, + /// Consumer-declared CSS selectors for nodes rendered without Figma + /// attributes. Missing or ambiguous matches never count as present. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub node_selectors: BTreeMap, /// Lowest contrast ratio text may have against the background painted /// behind it; default 1.5, below which text cannot be seen. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -196,6 +217,20 @@ pub struct CompletenessInput { pub(crate) const MAX_COMPLETENESS_NODES: usize = 200; fn validate_completeness(completeness: &CompletenessInput) -> Result<(), DevupError> { + if completeness.node_selectors.len() > MAX_COMPLETENESS_NODES * 2 { + return Err(invalid( + "completeness.nodeSelectors exceeds the node limit.", + )); + } + for (id, selector) in &completeness.node_selectors { + bounded_text("completeness.nodeSelectors node", id)?; + bounded_text("completeness.nodeSelectors selector", selector)?; + if !completeness.sections.contains(id) && !completeness.images.contains(id) { + return Err(invalid( + "completeness.nodeSelectors must name a listed section or image.", + )); + } + } if !completeness.frame_height.is_finite() || completeness.frame_height <= 0.0 || completeness.frame_height > 100_000.0 diff --git a/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs b/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs index a6437f28..e6039a7a 100644 --- a/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs +++ b/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs @@ -133,8 +133,9 @@ pub struct ScreenInput { #[serde(rename_all = "camelCase", deny_unknown_fields)] #[schemars(extend("additionalProperties" = json!({"not": {}})))] pub struct StepInput { - #[schemars(extend("enum" = ["click", "fill"]))] + #[schemars(extend("enum" = ["click", "fill", "press", "back", "forward", "reload"]))] pub action: String, + #[serde(default)] pub target: LocatorInput, /// The text for fill. #[serde(default)] @@ -173,6 +174,24 @@ pub struct ExpectInput { pub heading: Option, #[serde(default)] pub visible: Option, + /// Assert a single selected control AND its captured computed paint, not only navigation. + #[serde(default)] + pub selected: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct SelectionInput { + pub group: LocatorInput, + pub target: LocatorInput, + /// aria-current or aria-selected. + pub attribute: String, + pub value: String, + /// CSS property names and captured expected values; at least one paint value is required. + pub styles: BTreeMap, + #[serde(default)] + pub content: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -203,6 +222,8 @@ pub struct CheckInput { /// no-clipped-content: internal clipped rows require an explicit carousel with controls. /// styles-applied: every class name on the rendered page has a CSS rule; /// a page served before its own CSS arrived (cold dev server) fails. + /// markup: semantic landmarks, document language, headings, image alternatives + /// and target-size review evidence, independent of Figma node attributes. /// page-health: no error page text ("This page couldn't load", /// "Application error", ...), page error, console error or failed /// navigation while the screen was open. @@ -229,7 +250,7 @@ pub struct CheckInput { /// minHeightRatio of the frame's height, every listed section and image /// renders (images decoded), and no text is invisible against what is /// painted behind it. A drawn width without a passing one is not-run. - #[schemars(extend("enum" = ["links-resolve", "no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "bounds", "no-clipped-text", "interaction", "capture", "hidden", "rendered-completeness", "sequence-order", "section-layout", "text-align", "no-clipped-content", "styles-applied", "no-low-contrast-text", "image-loaded", "native-appearance", "stale-resources"]))] + #[schemars(extend("enum" = ["links-resolve", "no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "markup", "bounds", "no-clipped-text", "interaction", "capture", "hidden", "rendered-completeness", "sequence-order", "section-layout", "text-align", "no-clipped-content", "styles-applied", "no-low-contrast-text", "image-loaded", "native-appearance", "stale-resources"]))] pub kind: String, /// links-resolve: confirmed URL patterns and routes found on disk. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -555,14 +576,21 @@ pub(crate) fn validate_plan(plan: &PlanInput) -> Result<(), DevupError> { ))); } for step in &screen.steps { - validate_locator("steps[].target", &step.target, false)?; + if matches!(step.action.as_str(), "back" | "forward" | "reload") { + if [&step.target.role, &step.target.name, &step.target.test_id, &step.target.text, &step.target.css].iter().any(|field| field.is_some()) { + return Err(invalid("History/reload steps take no target.")); + } + } else { + validate_locator("steps[].target", &step.target, false)?; + } timeout("steps[].timeoutMs", step.timeout_ms)?; match (step.action.as_str(), &step.value) { - ("click", None) => {} + ("click" | "back" | "forward" | "reload", None) => {} ("fill", Some(value)) if value.chars().count() <= MAX_TEXT => {} + ("press", Some(value)) if !value.is_empty() && value.chars().count() <= MAX_TEXT => {} _ => { return Err(invalid( - "A step is click (no value) or fill (with a value of at most 300 characters).", + "A step is click, back, forward or reload (no value), or fill/press (nonempty value, at most 300 characters).", )); } } @@ -577,6 +605,23 @@ pub(crate) fn validate_plan(plan: &PlanInput) -> Result<(), DevupError> { if let Some(visible) = &expect.visible { validate_locator("expect.visible", visible, true)?; } + if let Some(selected) = &expect.selected { + validate_locator("expect.selected.group", &selected.group, true)?; + validate_locator("expect.selected.target", &selected.target, true)?; + if !matches!(selected.attribute.as_str(), "aria-current" | "aria-selected") + || !matches!(selected.value.as_str(), "true" | "page" | "step" | "location" | "date" | "time") + || (selected.attribute=="aria-selected" && selected.value!="true") + || selected.styles.is_empty() || selected.styles.len()>12 { + return Err(invalid("Selected expectations require a true ARIA selection and 1..12 captured CSS paint properties.")); + } + for (property, value) in &selected.styles { + if !matches!(property.as_str(), "color" | "background-color" | "backgroundColor" | "border-color" | "borderColor" | "opacity" | "outline-color" | "outlineColor" | "transform") { + return Err(invalid("Selected styles must name supported paint/transform properties.")); + } + bounded_text("expect.selected.styles", value)?; + } + if let Some(content) = &selected.content { validate_locator("expect.selected.content", content, true)?; } + } } for check in &screen.checks { validate_check(check)?; @@ -638,7 +683,7 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { needs(false, false)? } "visible" | "view-transition" | "hidden" => needs(true, false)?, - "stale-resources" | "styles-applied" => needs(false, false)?, + "stale-resources" | "styles-applied" | "markup" => needs(false, false)?, "no-clipped-text" | "no-low-contrast-text" | "image-loaded" | "native-appearance" => { if check.container.is_some() { return Err(invalid(format!( @@ -905,4 +950,3 @@ pub(crate) async fn create_plan( let references = dimension_references(&input, artifacts).await; write_plan(input, policy, output_path, template_path, references) } - diff --git a/crates/devup-mcp/src/server/runtime_audit/plan_columns.rs b/crates/devup-mcp/src/server/runtime_audit/plan_columns.rs index 8a0648fa..f80b02da 100644 --- a/crates/devup-mcp/src/server/runtime_audit/plan_columns.rs +++ b/crates/devup-mcp/src/server/runtime_audit/plan_columns.rs @@ -39,6 +39,7 @@ fn columns( fold(&[ "links-resolve", "page-health", + "markup", "no-horizontal-overflow", "no-clipped-content", "styles-applied", diff --git a/crates/devup-mcp/src/server/runtime_audit/screenshot_integrity.rs b/crates/devup-mcp/src/server/runtime_audit/screenshot_integrity.rs index 9488ddcb..54fb6756 100644 --- a/crates/devup-mcp/src/server/runtime_audit/screenshot_integrity.rs +++ b/crates/devup-mcp/src/server/runtime_audit/screenshot_integrity.rs @@ -116,6 +116,7 @@ fn array_text(value: &Value) -> String { pub(crate) fn failure_class(kind: &str, reason: &str) -> &'static str { match kind { "page-health" => "runtime-error", + "markup" => "accessibility", "image-loaded" | "stale-resources" => "asset-mismatch", "no-low-contrast-text" | "native-appearance" => "section-visual", "rendered-completeness" => "page-visual", @@ -133,6 +134,7 @@ pub(crate) fn failure_class(kind: &str, reason: &str) -> &'static str { /// element where the executor reported one. pub(crate) fn fix_hint(class: &str, kind: &str, measured: &Value) -> String { match class { + "accessibility" => "Inspect the measured landmark counts, headings and missingAlt entries. Use semantic HTML, one h1, ordered heading levels, document language and appropriate image alternatives. Review small target exceptions separately.".to_owned(), "runtime-error" => include_str!("../text/runtime_audit-fix_hint-3a12f919.md").to_owned(), "overflow" if kind == "no-clipped-text" => { include_str!("../text/runtime_audit-fix_hint-b3941912.md").to_owned() diff --git a/crates/devup-mcp/src/server/runtime_audit/selection_tests.rs b/crates/devup-mcp/src/server/runtime_audit/selection_tests.rs new file mode 100644 index 00000000..a7bd8505 --- /dev/null +++ b/crates/devup-mcp/src/server/runtime_audit/selection_tests.rs @@ -0,0 +1,25 @@ +#[cfg(test)] +mod selection_contract_tests { + use super::*; + + fn plan() -> Value { + json!({"baseUrl":"http://127.0.0.1:3000","allowedOrigins":["http://127.0.0.1:3000"],"viewports":[{"width":360,"height":800}],"screens":[{"id":"news","routePath":"/news","steps":[{"action":"back"},{"action":"forward"},{"action":"reload"},{"action":"press","target":{"role":"link","name":"List"},"value":"Enter"}],"expect":{"selected":{"group":{"role":"navigation","name":"View"},"target":{"role":"link","name":"List"},"attribute":"aria-current","value":"true","styles":{"background-color":"rgb(0, 87, 187)"}}},"checks":[{"kind":"page-health"}]}]}) + } + #[test] + fn accepts_history_keyboard_and_paint_selection_expectations() { + let plan: PlanInput = serde_json::from_value(plan()).unwrap(); + assert!(validate_plan(&plan).is_ok()); + } + #[test] + fn rejects_unverifiable_or_invalid_selection_expectations() { + for invalid_selection in [json!({"styles":{}}),json!({"value":"false"}),json!({"attribute":"onclick"}),json!({"styles":{"width":"10px"}})] { + let mut value=plan(); + for (key,value2) in invalid_selection.as_object().unwrap() {value["screens"][0]["expect"]["selected"][key]=value2.clone();} + let plan: PlanInput=serde_json::from_value(value).unwrap(); + assert!(validate_plan(&plan).is_err()); + } + let mut value=plan(); + value["screens"][0]["steps"][0]["target"]=json!({"role":"button","name":"Other"}); + assert!(validate_plan(&serde_json::from_value(value).unwrap()).is_err()); + } +} diff --git a/crates/devup-mcp/src/server/runtime_audit/tests.rs b/crates/devup-mcp/src/server/runtime_audit/tests.rs index ae371939..8e5642b9 100644 --- a/crates/devup-mcp/src/server/runtime_audit/tests.rs +++ b/crates/devup-mcp/src/server/runtime_audit/tests.rs @@ -61,7 +61,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 7); + assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 8); let imported = std::process::Command::new("node") .args(["--input-type=module", "-e", "await import('./run.mjs')"]) .current_dir(dir.join("runner")) @@ -700,8 +700,8 @@ mod tests { } #[test] - fn tall_audit_frame_compares_clipped_capture_and_short_page_fails() { - for height in [5953, 5900, 500] { + fn natural_capture_height_does_not_infer_missing_content() { + for height in [5953, 5900, 6174] { let (dir, policy) = root(); let input: PlanInput = serde_json::from_value(json!({ "baseUrl": "http://localhost:3000", "allowedOrigins": ["http://localhost:3000"], @@ -742,23 +742,27 @@ mod tests { .unwrap() .ends_with("frame-clipped.png") ); - if height == 5953 { - assert_eq!(comparison["blockers"], json!([]), "{result}"); - } else { - assert_eq!(result["verdict"], "fail", "{result}"); - let finding = result["findings"] + assert_eq!( + comparison["call"]["arguments"]["environment"]["viewport"]["height"], + 900 + ); + let blockers = comparison["blockers"].as_array().unwrap(); + assert_eq!(blockers.is_empty(), height == 5953, "{result}"); + assert!( + !result["findings"] .as_array() .unwrap() .iter() - .find(|row| row["kind"] == "page-height") - .unwrap(); + .any(|row| row["kind"] == "page-height"), + "raster dimensions do not establish missing content: {result}" + ); + if height != 5953 { assert!( - finding["reason"] + blockers.iter().any(|item| item .as_str() - .unwrap() - .contains(&height.to_string()) + .is_some_and(|text| text.contains("inconclusive"))), + "{result}" ); - assert!(finding["reason"].as_str().unwrap().contains("5953")); } drop(policy); std::fs::remove_dir_all(dir).unwrap(); diff --git a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs index e040b8d4..82504266 100644 --- a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs +++ b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs @@ -69,6 +69,14 @@ fn write_plan( let mut template_transaction = OutputTransaction::new(); template_transaction.refuse_existing(); for (name, bytes) in [ + ( + "selection-checks.mjs", + include_bytes!("../../../../../harness/runtime-audit/selection-checks.mjs").as_slice(), + ), + ( + "markup-checks.mjs", + include_bytes!("../../../../../harness/runtime-audit/markup-checks.mjs").as_slice(), + ), ( "dev-environment.mjs", include_bytes!("../../../../../harness/runtime-audit/dev-environment.mjs") @@ -87,11 +95,6 @@ fn write_plan( "checks.mjs", include_bytes!("../../../../../harness/runtime-audit/checks.mjs").as_slice(), ), - ( - "strip-figma-node-ids.mjs", - include_bytes!("../../../../../harness/runtime-audit/strip-figma-node-ids.mjs") - .as_slice(), - ), ( "package.json", include_bytes!("../../../../../harness/runtime-audit/package.json").as_slice(), @@ -234,7 +237,6 @@ fn ingest_references( let mut dimension_rows = Vec::new(); let mut screen_rows = Vec::new(); let mut findings = Vec::new(); - let mut page_height_failed = false; let mut missing_nodes = Vec::new(); let mut link_table = Vec::::new(); let mut links = BTreeMap::>::new(); @@ -509,17 +511,7 @@ fn ingest_references( continue; } screenshot_budget -= 1; - let mut capture_viewport = viewport.clone(); - if screen.figma.as_ref().is_some_and(|figma| { - figma.drawn_widths.contains(&viewport.width) - && figma - .frames - .iter() - .any(|frame| frame.width == viewport.width && frame.height.is_some()) - }) { - capture_viewport.height = 1; - } - match verify_screenshot(policy, &report_dir, shot, &capture_viewport) { + match verify_screenshot(policy, &report_dir, shot, viewport) { Ok(verified) => { screenshots_verified += 1; if let Some(figma) = screen @@ -527,41 +519,6 @@ fn ingest_references( .as_ref() .filter(|figma| figma.drawn_widths.contains(&viewport.width)) { - if let Some(height) = figma - .frames - .iter() - .find(|frame| frame.width == viewport.width) - .and_then(|frame| frame.height) - && (viewport.device_scale_factor - 1.0).abs() <= f64::EPSILON - && verified.height < height - { - page_height_failed = true; - let reason = format!( - include_str!("../text/runtime_audit-page-height.md"), - verified.width, verified.height, viewport.width, height - ); - let mut issue = finding( - screen, - viewport, - None, - "page-visual", - &reason, - &json!({"captureHeight": verified.height, "frameHeight": height}), - Some(&verified.path), - ); - issue["kind"] = json!("page-height"); - issue["id"] = - json!(comparison_id(screen, "page-height", "page", viewport.width)); - findings.push(issue); - if let Some(row) = screen_rows.last_mut() { - row["status"] = json!("fail"); - row["reasons"] - .as_array_mut() - .expect("screen reasons") - .push(json!(reason)); - row["columns"]["completeness"] = json!("fail"); - } - } drawn_width_shots.push((screen, figma.node_id.as_str(), verified)); } } @@ -604,7 +561,7 @@ fn ingest_references( } let planned = planned_items(&plan); let count = |status: &str| counts.get(status).copied().unwrap_or(0); - let verdict = if (count("fail") > 0 || page_height_failed) && !environment_invalid { + let verdict = if count("fail") > 0 && !environment_invalid { "fail" } else if count("pass") == planned && !environment_invalid && report["status"] == "completed" { "pass" diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index cc810813..a00f960d 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -41,4 +41,5 @@ include!("screen_plan/plan_route_group.rs"); include!("screen_plan/state_variants.rs"); include!("screen_plan/lookup.rs"); include!("screen_plan/component_width.rs"); +include!("screen_plan/responsive_groups.rs"); include!("screen_plan/responsive_contract.rs"); diff --git a/crates/devup-mcp/src/server/screen_plan/actions.rs b/crates/devup-mcp/src/server/screen_plan/actions.rs index 7f471f58..e55d3db5 100644 --- a/crates/devup-mcp/src/server/screen_plan/actions.rs +++ b/crates/devup-mcp/src/server/screen_plan/actions.rs @@ -405,7 +405,7 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< "file": route["file"], "nodeIds": route["nodeIds"], "drawnWidths": route["drawnWidths"], - "states": state_exports(route), + "states": state_exports(&plan, route), "nextExport": if multi_width(route) { responsive_export_action(&plan, route, root) } else { Value::Null }, }) }) @@ -450,9 +450,9 @@ pub(super) fn assume(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result Result Result>(), "assumptions": plan["assumptions"], "remapped": remapped, + "responsiveGroups": plan["routes"].as_array().into_iter().flatten().filter(|route| route["responsiveGroups"].is_array()).map(|route| json!({"url":route["url"],"groups":route["responsiveGroups"]})).collect::>(), "dependencies": plan["dependencies"], "writtenTo": written, "how": "Keep implementing. Every assumption appears in status and must be in your final report.", diff --git a/crates/devup-mcp/src/server/screen_plan/responsive_contract.rs b/crates/devup-mcp/src/server/screen_plan/responsive_contract.rs index 24f367c0..1f48c65c 100644 --- a/crates/devup-mcp/src/server/screen_plan/responsive_contract.rs +++ b/crates/devup-mcp/src/server/screen_plan/responsive_contract.rs @@ -1,58 +1,230 @@ -pub(super) fn responsive_export_action(plan: &Value, route: &Value, root: &str) -> Value { - let mut frames = array(&route["nodeIds"]).iter().enumerate().collect::>(); - frames.sort_by(|(left, _), (right, _)| { - array(&route["drawnWidths"]).get(*left).and_then(Value::as_f64).unwrap_or_default() - .total_cmp(&array(&route["drawnWidths"]).get(*right).and_then(Value::as_f64).unwrap_or_default()) +/// A PAGE source URL with several frameIds is not a Section selection. +/// Anchor on an already verified requested frame so collection can resolve +/// its containing Section. Keep the real file/branch identity and all other +/// query pairs; no file key or ancestor node ID is invented. +fn anchor_export_arguments(plan: &Value, arguments: &mut Value) { + let Some(source) = plan["source"]["url"].as_str() else { + return; + }; + arguments["url"] = json!(source); + let Some(node) = array(&arguments["frameIds"]) + .first() + .and_then(Value::as_str) + else { + return; + }; + let Ok(mut url) = url::Url::parse(source) else { + return; + }; + let pairs = url + .query_pairs() + .filter(|(key, _)| key != "node-id") + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + let node = node.replace(':', "-"); + url.set_query(None); + url.query_pairs_mut() + .extend_pairs(pairs) + .append_pair("node-id", &node); + arguments["url"] = json!(url.to_string()); +} + +fn group_export_action(plan: &Value, group: &Value, root: &str) -> Value { + let widths = array(&group["drawnWidths"]); + let mut frames = array(&group["frameIds"]) + .iter() + .enumerate() + .collect::>(); + frames.sort_by_key(|(index, _)| { + widths + .get(*index) + .and_then(Value::as_u64) + .unwrap_or_default() }); - let mut arguments = json!({"projectRoot": root, "frameIds": frames.iter().map(|(_, id)| *id).collect::>(), "outputs": ["responsiveTsx"]}); - if let Some(url) = plan["source"]["url"].as_str() { - arguments["url"] = json!(url); + let output = if frames.len() > 1 { + "responsiveTsx" + } else { + "tsx" + }; + let mut arguments = json!({"projectRoot":root,"frameIds":frames.iter().map(|(_, id)| *id).collect::>(),"outputs":[output]}); + anchor_export_arguments(plan, &mut arguments); + json!({"tool":"devup_figma_export","arguments":arguments,"groupId":group["id"],"groupKind":group["kind"]}) +} + +fn responsive_grouping_recovery(plan: &Value, route: &Value, root: &str, reason: &str) -> Value { + let reads = array(&route["nodeIds"]) + .chunks(3) + .map(|ids| { + let mut arguments = json!({"projectRoot":root,"frameIds":ids,"outputs":["outline"]}); + anchor_export_arguments(plan, &mut arguments); + json!({"tool":"devup_figma_export","arguments":arguments}) + }) + .collect::>(); + json!({"status":"responsive_grouping_required","reason":reason, + "tool":reads.first().map(|read| read["tool"].clone()),"arguments":reads.first().map(|read| read["arguments"].clone()), + "remainingReads":reads.iter().skip(1).collect::>(), + "candidates":array(&route["nodeIds"]).iter().enumerate().map(|(index,id)| { + let frame = array(&plan["screens"]).iter().flat_map(|screen| array(&screen["frames"])) + .find(|frame| frame["nodeId"] == *id); + json!({"nodeId":id,"width":array(&route["drawnWidths"]).get(index),"name":frame.map(|frame| &frame["name"])}) + }).collect::>(), + "resolution":{"tool":"devup_screen_plan","action":"assume","projectRoot":root,"planId":plan["planId"], + "field":"responsiveGroups", "shape":{"url":route["url"],"id":"source-evidenced-state-name","kind":"baseline or state","frameIds":"one actual frame per width","reason":"the source evidence proving this pairing"}, + "requirements":"Record every route frame exactly once, one baseline group and named state groups, at most six frames per group. Never choose a winner from duplicate names or frame order."}}) +} + +pub(super) fn responsive_export_action(plan: &Value, route: &Value, root: &str) -> Value { + match responsive_groups(route) { + Ok(groups) => { + let mut action = group_export_action(plan, &groups[0], root); + action["remainingActions"] = json!( + groups + .iter() + .skip(1) + .map(|group| group_export_action(plan, group, root)) + .collect::>() + ); + action + } + Err(reason) => responsive_grouping_recovery(plan, route, root, &reason), } - json!({"tool": "devup_figma_export", "arguments": arguments}) } fn multi_width(route: &Value) -> bool { - array(&route["drawnWidths"]).iter().map(Value::to_string).collect::>().len() > 1 - && array(&route["nodeIds"]).len() > 1 && route["implementation"] != "state" + array(&route["drawnWidths"]) + .iter() + .map(Value::to_string) + .collect::>() + .len() + > 1 + && array(&route["nodeIds"]).len() > 1 + && route["implementation"] != "state" } -pub(super) fn responsive_required(root: &str, frames: &[String], outputs: &[String]) -> Result, DevupError> { +pub(super) fn responsive_required( + root: &str, + frames: &[String], + outputs: &[String], +) -> Result, DevupError> { let responsive = outputs.iter().any(|output| output == "responsiveTsx"); - if !outputs.iter().any(|output| matches!(output.as_str(), "tsx" | "componentTsx" | "pageScaffold" | "responsiveTsx")) { + if !outputs.iter().any(|output| { + matches!( + output.as_str(), + "tsx" | "componentTsx" | "pageScaffold" | "responsiveTsx" + ) + }) { return Ok(None); } for ancestor in Path::new(root).ancestors() { - let Some(plan) = read_plan(&ancestor.to_string_lossy())? else { continue; }; - if plan["status"] != "confirmed" { continue; } - let route = array(&plan["routes"]).iter().find(|route| multi_width(route) && frames.iter().any(|id| { - plan["source"]["nodeId"] == id.as_str() || array(&route["nodeIds"]).contains(&json!(id)) || array(&plan["frameInventory"]["frames"]).iter().any(|frame| { - frame["nodeId"] == id.as_str() && array(&frame["ancestors"]).iter().any(|parent| array(&route["nodeIds"]).contains(parent)) - }) - })); + let Some(plan) = read_plan(&ancestor.to_string_lossy())? else { + continue; + }; + if plan["status"] != "confirmed" { + continue; + } + let route = array(&plan["routes"]).iter().find(|route| { + multi_width(route) + && frames.iter().any(|id| { + plan["source"]["nodeId"] == id.as_str() + || array(&route["nodeIds"]).contains(&json!(id)) + || array(&plan["frameInventory"]["frames"]) + .iter() + .any(|frame| { + frame["nodeId"] == id.as_str() + && array(&frame["ancestors"]) + .iter() + .any(|parent| array(&route["nodeIds"]).contains(parent)) + }) + }) + }); if let Some(route) = route { - if responsive && !outputs.iter().any(|output| matches!(output.as_str(), "tsx" | "componentTsx")) - && array(&route["nodeIds"]).iter().all(|id| frames.iter().any(|frame| id == frame)) { - return Ok(None); + let groups = match responsive_groups(route) { + Ok(groups) => groups, + Err(reason) => { + return Ok(Some( + json!({"status":"responsive_grouping_required","written":false, + "responsiveRequired":{"code":"DEVUP_RESPONSIVE_GROUPING_REQUIRED","url":route["url"],"reason":reason}, + "nextAction":responsive_grouping_recovery(&plan,route,root,&reason)}), + )); + } + }; + let selected = groups.iter().find(|group| { + frames + .iter() + .all(|frame| array(&group["frameIds"]).contains(&json!(frame))) + }); + if let Some(group) = selected { + let complete = array(&group["frameIds"]) + .iter() + .all(|id| frames.iter().any(|frame| id == frame)); + let valid_output = if array(&group["frameIds"]).len() == 1 { + !outputs.iter().any(|output| output == "responsiveTsx") + } else { + responsive + && !outputs + .iter() + .any(|output| output == "componentTsx") + }; + if complete && valid_output { + return Ok(None); + } } - return Ok(Some(json!({"status": "responsive_required", "written": false, - "responsiveRequired": {"code": "DEVUP_RESPONSIVE_REQUIRED", "url": route["url"], "frameIds": route["nodeIds"], - "reason": "This confirmed URL has multiple drawn widths. responsiveTsx is the deliverable; never start from single-width TSX or change generated layout values."}, - "nextAction": responsive_export_action(&plan, route, root)}))); + let group = selected.unwrap_or(&groups[0]); + return Ok(Some(json!({"status":"responsive_required","written":false, + "responsiveRequired":{"code":"DEVUP_RESPONSIVE_REQUIRED","url":route["url"],"frameIds":group["frameIds"], + "reason":"Export one recorded baseline/state group at a time. Multi-width groups require responsiveTsx; generated layout values must remain unchanged."}, + "nextAction":group_export_action(&plan,group,root), + "groupActions":groups.iter().map(|group| group_export_action(&plan,group,root)).collect::>()}))); } break; } Ok(None) } -fn responsive_status(plan: &Value, route: &Value, evidence: &ProjectEvidence, records: &[Value]) -> Value { +fn responsive_status( + plan: &Value, + route: &Value, + evidence: &ProjectEvidence, + records: &[Value], +) -> Value { let ids = array(&route["nodeIds"]); - let matching = records.iter().filter(|record| array(&record["frameIds"]).iter().any(|id| ids.contains(id)) - || ids.contains(&record["rootNodeId"])).collect::>(); - let responsive = matching.iter().find(|record| record["output"] == "responsiveTsx" - && ids.iter().all(|id| array(&record["frameIds"]).contains(id))); - let sources = evidence.route_sources.get(text(&route["url"])).cloned().unwrap_or_default(); - json!({"required": multi_width(route), "recorded": !matching.is_empty(), "responsive": responsive.is_some(), - "issue": if multi_width(route) && responsive.is_none() && !matching.is_empty() { Some("single-width export used for a multi-width URL") } else { None }, - "nextAction": if multi_width(route) { responsive_export_action(plan, route, &evidence.root.to_string_lossy()) } else { Value::Null }, - "generatedDrift": responsive.map(|record| super::export_record::generated_drift(record, &evidence.root, &sources))}) + let matching = records + .iter() + .filter(|record| { + array(&record["frameIds"]).iter().any(|id| ids.contains(id)) + || ids.contains(&record["rootNodeId"]) + }) + .collect::>(); + let groups = match responsive_groups(route) { + Ok(groups) => groups, + Err(reason) => { + return json!({"required":multi_width(route),"recorded":!matching.is_empty(),"responsive":false, + "issue":reason,"nextAction":responsive_grouping_recovery(plan,route,&evidence.root.to_string_lossy(),&reason),"groups":[],"generatedDrift":null}); + } + }; + let sources = evidence + .route_sources + .get(text(&route["url"])) + .cloned() + .unwrap_or_default(); + let rows = groups.iter().map(|group| { + let ids = array(&group["frameIds"]); + let record = matching.iter().find(|record| { + (if ids.len() > 1 { record["output"] == "responsiveTsx" } else { matches!(text(&record["output"]), "tsx" | "componentTsx") }) + && ids.iter().all(|id| array(&record["frameIds"]).contains(id) || record["rootNodeId"] == *id) + && array(&record["frameIds"]).iter().all(|id| ids.contains(id)) + }); + json!({"id":group["id"],"kind":group["kind"],"frameIds":ids,"responsive":record.is_some(),"reason":group["reason"], + "nextAction":group_export_action(plan,group,&evidence.root.to_string_lossy()), + "generatedDrift":record.map(|record| super::export_record::generated_drift(record,&evidence.root,&sources))}) + }).collect::>(); + let complete = rows.iter().all(|group| group["responsive"] == true); + let next = rows + .iter() + .find(|group| group["responsive"] != true) + .unwrap_or(&rows[0]); + json!({"required":multi_width(route),"recorded":!matching.is_empty(),"responsive":complete, + "issue":if multi_width(route) && !complete && !matching.is_empty() { Some("baseline/state responsive export groups are missing or use single-width code") } else { None }, + "nextAction":if multi_width(route) { next["nextAction"].clone() } else { Value::Null }, + "generatedDrift":rows[0]["generatedDrift"],"groups":rows}) } diff --git a/crates/devup-mcp/src/server/screen_plan/responsive_groups.rs b/crates/devup-mcp/src/server/screen_plan/responsive_groups.rs new file mode 100644 index 00000000..d50ed3b0 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/responsive_groups.rs @@ -0,0 +1,156 @@ +const MAX_RESPONSIVE_GROUP_FRAMES: usize = 6; + +fn responsive_route_widths(route: &Value) -> Result, String> { + let ids = array(&route["nodeIds"]); + let widths = array(&route["drawnWidths"]); + if ids.len() != widths.len() || ids.is_empty() { + return Err( + "The route has missing or unpaired frame IDs and widths; refresh its frame inventory." + .into(), + ); + } + let mut result = BTreeMap::new(); + for (id, width) in ids.iter().zip(widths) { + let Some(id) = id.as_str().filter(|id| !id.is_empty()) else { + return Err("A route frame has no node ID.".into()); + }; + let Some(width) = width + .as_f64() + .filter(|width| width.is_finite() && *width >= 1.0 && *width <= f64::from(u32::MAX)) + else { + return Err(format!("Frame {id} has no valid drawn width.")); + }; + if result.insert(id.to_owned(), width.round() as u64).is_some() { + return Err(format!("Frame {id} appears more than once on the route.")); + } + } + Ok(result) +} + +/// No inference from order: duplicate widths require explicit, evidenced groups. +pub(super) fn responsive_groups(route: &Value) -> Result, String> { + let widths = responsive_route_widths(route)?; + let explicit = array(&route["responsiveGroups"]); + if explicit.is_empty() { + if widths.values().copied().collect::>().len() != widths.len() { + return Err("Several frames occupy the same drawn width. Read their actual state/content evidence and record baseline/state responsiveGroups with action assume; names or frame order alone do not select a winner.".into()); + } + if widths.len() > MAX_RESPONSIVE_GROUP_FRAMES { + return Err("This route exceeds the six-frame export limit; record executable responsiveGroups before exporting.".into()); + } + if widths + .values() + .map(|width| devup_mcp_devup_ui::codegen::responsive::slot_of_width(*width as u32)) + .collect::>() + .len() + != widths.len() + { + return Err("Several drawn widths occupy the same responsive slot. Record separate evidenced baseline/state groups; one responsive export cannot represent two frames in one slot.".into()); + } + return Ok(vec![ + json!({"id":"baseline", "kind":"baseline", "frameIds":route["nodeIds"], "drawnWidths":route["drawnWidths"], "reason":"One recorded frame at each distinct width."}), + ]); + } + let mut seen = BTreeSet::new(); + let mut group_ids = BTreeSet::new(); + let mut groups = Vec::new(); + let mut baselines = 0; + for group in explicit { + let id = text(&group["id"]); + if id.trim().is_empty() || id.len() > 100 || !group_ids.insert(id.to_owned()) { + return Err( + "Every responsive group needs a distinct non-empty id (at most 100 bytes).".into(), + ); + } + match text(&group["kind"]) { + "baseline" => baselines += 1, + "state" => {} + _ => return Err(format!("Group {id} kind must be baseline or state.")), + } + if text(&group["reason"]).trim().is_empty() { + return Err(format!("Group {id} needs source evidence in reason.")); + } + let ids = array(&group["frameIds"]); + if ids.is_empty() || ids.len() > MAX_RESPONSIVE_GROUP_FRAMES { + return Err(format!("Group {id} must contain one to six frames.")); + } + let mut drawn = BTreeSet::new(); + let mut slots = BTreeSet::new(); + let mut ordered = Vec::new(); + for node in ids { + let node = text(node); + let Some(width) = widths.get(node) else { + return Err(format!( + "Group {id} contains frame {node} outside this route." + )); + }; + if !drawn.insert(*width) { + return Err(format!( + "Group {id} repeats drawn width {width}; states cannot share a responsive slot." + )); + } + let slot = devup_mcp_devup_ui::codegen::responsive::slot_of_width(*width as u32); + if !slots.insert(slot) { + return Err(format!( + "Group {id} repeats responsive slot {slot}; distinct widths in the same slot cannot be merged." + )); + } + if !seen.insert(node.to_owned()) { + return Err(format!( + "Frame {node} belongs to more than one responsive group." + )); + } + ordered.push((*width, node.to_owned())); + } + ordered.sort(); + let mut group = group.clone(); + group["frameIds"] = json!(ordered.iter().map(|(_, node)| node).collect::>()); + group["drawnWidths"] = json!(ordered.iter().map(|(width, _)| width).collect::>()); + groups.push(group); + } + if baselines != 1 { + return Err("A route needs exactly one baseline responsive group.".into()); + } + let omitted = widths + .keys() + .filter(|id| !seen.contains(*id)) + .collect::>(); + if !omitted.is_empty() { + return Err(format!( + "Responsive groups omit planned frames: {}.", + omitted.into_iter().cloned().collect::>().join(", ") + )); + } + groups.sort_by_key(|group| group["kind"] != "baseline"); + Ok(groups) +} + +fn apply_responsive_groups( + plan: &mut Value, + inputs: &[ResponsiveGroupInput], +) -> Result, DevupError> { + let mut by_url = BTreeMap::>::new(); + for group in inputs { + let url = normalize_route(&group.url) + .filter(|url| valid_route(url)) + .ok_or_else(|| invalid(format!("Invalid responsive group URL {:?}.", group.url)))?; + by_url.entry(url).or_default().push(json!(group)); + } + let mut recorded = Vec::new(); + for (url, groups) in by_url { + let route = plan["routes"] + .as_array_mut() + .and_then(|routes| { + routes + .iter_mut() + .find(|route| route["url"] == url && route["implementation"] == "page") + }) + .ok_or_else(|| invalid(format!("Responsive group URL {url} is not a planned page.")))?; + let mut candidate = route.clone(); + candidate["responsiveGroups"] = json!(groups); + let verified = responsive_groups(&candidate).map_err(invalid)?; + route["responsiveGroups"] = json!(verified); + recorded.push(json!({"about":url, "decision":"recorded explicit baseline/state responsive groups", "reason":"Pairing evidence is retained per group.","recordedAt":now(),"evidence":verified.iter().map(|group| json!({"id":group["id"],"frameIds":group["frameIds"],"evidence":group["reason"]})).collect::>()})); + } + Ok(recorded) +} diff --git a/crates/devup-mcp/src/server/screen_plan/state.rs b/crates/devup-mcp/src/server/screen_plan/state.rs index 616bc110..eaa12dd9 100644 --- a/crates/devup-mcp/src/server/screen_plan/state.rs +++ b/crates/devup-mcp/src/server/screen_plan/state.rs @@ -120,6 +120,11 @@ pub struct ScreenPlanInput { /// assume: what was decided during implementation without asking. #[serde(default)] pub assumptions: Vec, + /// assume: explicitly pair baseline and state frames at distinct widths. + /// Every frame of each affected URL must belong to exactly one group. + /// Supply evidence in reason; frame order or duplicate names do not prove a pairing. + #[serde(default)] + pub responsive_groups: Vec, /// assume: the outcome of the dependency step. Each pin is also recorded /// as an assumption. #[serde(default)] @@ -271,6 +276,18 @@ pub struct AssumptionInput { pub reason: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ResponsiveGroupInput { + pub url: String, + pub id: String, + #[schemars(extend("enum" = ["baseline", "state"]))] + pub kind: String, + pub frame_ids: Vec, + pub reason: String, +} + /// The outcome of the dependency step, recorded once it ran. #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/crates/devup-mcp/src/server/screen_plan/state_variants.rs b/crates/devup-mcp/src/server/screen_plan/state_variants.rs index 1b444dff..726519a5 100644 --- a/crates/devup-mcp/src/server/screen_plan/state_variants.rs +++ b/crates/devup-mcp/src/server/screen_plan/state_variants.rs @@ -69,20 +69,47 @@ fn record_states(routes: &mut [Value], folds: &[(String, Vec)]) { } } -pub(super) fn state_exports(route: &Value) -> Vec { - array(&route["states"]).iter().map(|state| { +pub(super) fn state_exports(plan: &Value, route: &Value) -> Vec { + let mut result = array(&route["states"]).iter().map(|state| { let mut state = state.clone(); state["hostUrl"] = route["url"].clone(); state["exportCall"] = json!({"tool": "devup_figma_export", "arguments": { "frameIds": [state["nodeId"]], "outputs": ["tsx", "referencePng"] }}); state - }).collect() + }).collect::>(); + if !array(&route["responsiveGroups"]).is_empty() + && let Ok(groups) = responsive_groups(route) + { + result.extend(groups.iter().filter(|group| group["kind"] == "state").map(|group| { + let ids = array(&group["frameIds"]); + json!({"nodeId":ids.first(),"name":group["id"],"groupId":group["id"], + "frameIds":ids,"drawnWidths":group["drawnWidths"],"width":array(&group["drawnWidths"]).first(), + "hostUrl":route["url"],"reason":group["reason"], + "exportCall":{"tool":"devup_figma_export","arguments":{"frameIds":ids,"outputs":[if ids.len()>1 { "responsiveTsx" } else { "tsx" }]}}}) + })); + } + for state in &mut result { + anchor_export_arguments(plan, &mut state["exportCall"]["arguments"]); + } + result } pub(super) fn audit_routes(plan: &Value) -> Vec { array(&plan["routes"]).iter().flat_map(|route| { - let mut targets = vec![route.clone()]; + let mut targets = if !array(&route["responsiveGroups"]).is_empty() { + responsive_groups(route).ok().map(|groups| groups.into_iter().map(|group| { + let mut target = route.clone(); + target["nodeIds"] = group["frameIds"].clone(); + target["drawnWidths"] = group["drawnWidths"].clone(); + target["responsiveGroups"] = json!([]); + target["states"] = json!([]); + if group["kind"] == "state" { + target["state"] = json!({"nodeId":array(&group["frameIds"]).first(),"name":group["id"],"groupId":group["id"],"frameIds":group["frameIds"],"reason":group["reason"]}); + } + target + }).collect::>()).unwrap_or_else(|| vec![route.clone()]) + } else { vec![route.clone()] }; for state in array(&route["states"]) { let mut target = route.clone(); target["nodeIds"] = json!([state["nodeId"]]); diff --git a/crates/devup-mcp/src/server/screen_plan/status.rs b/crates/devup-mcp/src/server/screen_plan/status.rs index ead312c3..a2b8b6fa 100644 --- a/crates/devup-mcp/src/server/screen_plan/status.rs +++ b/crates/devup-mcp/src/server/screen_plan/status.rs @@ -49,10 +49,11 @@ pub(super) fn status_report( let Some(url) = route["url"].as_str() else { continue; }; - let route_states = state_exports(route).into_iter().map(|mut state| { + let route_states = state_exports(plan, route).into_iter().map(|mut state| { let suffix = format!("-state-{}", super::audit_plan::slug(text(&state["nodeId"]))); let mut required_widths = DEFAULT_WIDTHS.to_vec(); if let Some(width) = state["width"].as_f64() { required_widths.push(width.round() as u32); } + required_widths.extend(array(&state["drawnWidths"]).iter().filter_map(Value::as_u64).filter_map(|width| u32::try_from(width).ok())); let results = required_widths.iter().map(|width| { audit_rows.iter().find(|row| row["routePath"] == url && row["width"] == *width && text(&row["screen"]).ends_with(&suffix)) .map_or("not-run", |row| text(&row["status"])) diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs index 56693d60..a73f1053 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -11,3 +11,4 @@ include!("screen_plan_tests/monorepo_root_plan.rs"); include!("screen_plan_tests/feedback_remaining.rs"); include!("screen_plan_tests/component_width.rs"); include!("screen_plan_tests/responsive_contract.rs"); +include!("screen_plan_tests/responsive_groups.rs"); diff --git a/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs b/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs index 7dc6cb0c..27d3c7ef 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs @@ -444,18 +444,17 @@ async fn audit_plan_writes_a_plan_per_url_and_reports_what_it_cannot_compare() { .exists() ); assert_eq!(result["runner"]["state"], "written"); - // The plugin that strips the audit ids from production builds sits next - // to the app's vite.config, and the config is not wired to it yet. + // Auditing must not create a strip plugin or alter consumer build config. let strip = &result["productionStrip"][0]; - assert!( + assert!(! fixture .0 .join("vite-plugin-strip-figma-node-ids.mjs") .exists() ); - assert_eq!(strip["written"], true); + assert_eq!(strip["written"], false); assert_eq!(strip["wired"], false); - assert!(text(&strip["verify"]).contains("grep -rl data-figma-node-id")); + assert!(text(&strip["verify"]).contains("no debug exception")); assert_eq!( result["expiredArtifacts"][0]["artifactId"], "expired-artifact" diff --git a/crates/devup-mcp/src/server/screen_plan_tests/responsive_contract.rs b/crates/devup-mcp/src/server/screen_plan_tests/responsive_contract.rs index e653ecb6..bdff96b1 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests/responsive_contract.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests/responsive_contract.rs @@ -13,7 +13,9 @@ fn multi_width_export_redirects_single_width_code_before_collection() { assert!(responsive_required(fixture.root(), &["10:3".into()], &["tsx".into()]).unwrap().is_none()); assert!(responsive_required(fixture.root(), &["10:1".into()], &["outline".into()]).unwrap().is_none()); assert!(responsive_required(fixture.root(), &["10:1".into()], &["responsiveTsx".into()]).unwrap().is_some()); - assert!(responsive_required(fixture.root(), &["10:1".into(), "10:2".into()], &["responsiveTsx".into(), "tsx".into()]).unwrap().is_some()); + assert!(responsive_required(fixture.root(), &["10:1".into(), "10:2".into()], &["responsiveTsx".into(), "tsx".into()]).unwrap().is_none()); + assert!(responsive_required(fixture.root(), &["10:1".into()], &["responsiveTsx".into(), "tsx".into()]).unwrap().is_some()); + assert!(responsive_required(fixture.root(), &["10:1".into(), "10:2".into()], &["tsx".into()]).unwrap().is_some()); } #[test] diff --git a/crates/devup-mcp/src/server/screen_plan_tests/responsive_groups.rs b/crates/devup-mcp/src/server/screen_plan_tests/responsive_groups.rs new file mode 100644 index 00000000..9f8800ba --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan_tests/responsive_groups.rs @@ -0,0 +1,244 @@ +fn ambiguous_contact_plan() -> Value { + let mut plan = confirmed_plan(); + plan["routes"][0]["nodeIds"] = json!(["d:1", "m:1", "d:2", "m:2", "d:3", "m:3", "d:4", "m:4"]); + plan["routes"][0]["drawnWidths"] = json!([1440, 360, 1440, 360, 1440, 360, 1440, 360]); + plan +} + +fn contact_groups() -> Vec { + ["normal", "dropdown", "error", "success"] + .iter() + .enumerate() + .map(|(index, name)| ResponsiveGroupInput { + url: "/".into(), + id: (*name).into(), + kind: if index == 0 { "baseline" } else { "state" }.into(), + frame_ids: vec![format!("d:{}", index + 1), format!("m:{}", index + 1)], + reason: format!("Captured labels identify {name} on both frames."), + }) + .collect() +} + +#[test] +fn duplicate_width_recovery_never_asks_for_an_impossible_responsive_export() { + let plan = ambiguous_contact_plan(); + let action = responsive_export_action(&plan, &plan["routes"][0], "root"); + assert_eq!(action["status"], "responsive_grouping_required"); + assert_eq!(action["arguments"]["outputs"], json!(["outline"])); + assert!(array(&action["arguments"]["frameIds"]).len() <= 3); + assert_eq!(array(&action["remainingReads"]).len(), 2); + assert_eq!(array(&action["candidates"]).len(), 8); + assert_eq!(action["resolution"]["field"], "responsiveGroups"); + let fixture = Fixture::new(); + write_plan_file(&fixture, &plan); + let response = responsive_required( + fixture.root(), + &["d:1".into(), "m:1".into()], + &["responsiveTsx".into()], + ) + .unwrap() + .unwrap(); + assert_eq!(response["status"], "responsive_grouping_required"); +} + +#[test] +fn distinct_widths_in_one_responsive_slot_are_not_an_executable_group() { + let mut route = json!({"url":"/","nodeIds":["mobile:1","mobile:2"],"drawnWidths":[360,390],"implementation":"page"}); + assert!( + responsive_groups(&route) + .unwrap_err() + .contains("same responsive slot") + ); + route["responsiveGroups"] = json!([{"id":"normal","kind":"baseline","frameIds":["mobile:1","mobile:2"],"reason":"Captured labels."}]); + assert!( + responsive_groups(&route) + .unwrap_err() + .contains("repeats responsive slot") + ); +} + +#[test] +fn assume_records_all_state_pairings_and_exports_each_group_independently() { + let fixture = Fixture::new(); + write_plan_file(&fixture, &ambiguous_contact_plan()); + let mut request = input(&fixture, "assume"); + request.responsive_groups = contact_groups(); + assume(&request, &fixture.policy()).unwrap(); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let groups = responsive_groups(&plan["routes"][0]).unwrap(); + assert_eq!(groups.len(), 4); + assert_eq!(groups[0]["frameIds"], json!(["m:1", "d:1"])); + assert_eq!( + array(&plan["assumptions"]).last().unwrap()["decision"], + "recorded explicit baseline/state responsive groups" + ); + for index in 1..=4 { + assert!( + responsive_required( + fixture.root(), + &[format!("d:{index}"), format!("m:{index}")], + &["responsiveTsx".into()] + ) + .unwrap() + .is_none() + ); + } + let mixed = responsive_required( + fixture.root(), + &["d:1".into(), "m:2".into()], + &["responsiveTsx".into()], + ) + .unwrap() + .unwrap(); + assert_eq!(mixed["status"], "responsive_required"); + assert_eq!(array(&mixed["groupActions"]).len(), 4); + let targets = audit_routes(&plan); + assert_eq!(array(&targets[0]["nodeIds"]), &[json!("m:1"), json!("d:1")]); + assert_eq!( + targets + .iter() + .filter(|target| target["state"].is_object()) + .count(), + 3 + ); + assert!( + targets + .iter() + .filter(|target| target["url"] == "/") + .all(|target| array(&target["nodeIds"]).len() == 2) + ); +} + +#[test] +fn responsive_group_validation_refuses_omissions_duplicate_widths_and_unsupported_frames() { + let baseline = ambiguous_contact_plan(); + let mut invalids = Vec::new(); + let mut omitted = contact_groups(); + omitted.pop(); + invalids.push(omitted); + let mut repeated = contact_groups(); + repeated[0].frame_ids = vec!["d:1".into(), "d:2".into()]; + invalids.push(repeated); + let mut unknown = contact_groups(); + unknown[0].frame_ids[0] = "outside".into(); + invalids.push(unknown); + let mut baselines = contact_groups(); + baselines[1].kind = "baseline".into(); + invalids.push(baselines); + for groups in invalids { + let mut plan = baseline.clone(); + assert!(apply_responsive_groups(&mut plan, &groups).is_err()); + } +} + +#[test] +fn group_status_requires_every_baseline_and_state_export_record() { + let fixture = Fixture::new(); + let mut plan = ambiguous_contact_plan(); + apply_responsive_groups(&mut plan, &contact_groups()).unwrap(); + let evidence = ProjectEvidence { + root: fixture.0.clone(), + ..evidence() + }; + for index in 1..=4 { + super::super::export_record::write(&fixture.policy(),fixture.root(),"file",&format!("group:{index}"),&json!({ + "responsiveTsx":"", "responsiveEvidence":{"frames":[{"nodeId":format!("d:{index}")},{"nodeId":format!("m:{index}")}]} + })).unwrap(); + let records = super::super::export_record::records(&fixture.0); + let status = responsive_status(&plan, &plan["routes"][0], &evidence, &records); + assert_eq!(status["responsive"], index == 4); + assert_eq!( + array(&status["groups"]) + .iter() + .filter(|group| group["responsive"] == true) + .count(), + index + ); + } +} + +#[test] +fn page_source_recovery_anchors_the_requested_frame_and_preserves_file_identity() { + for source in [ + "figma-bridge://current?node-id=0%3A1&mode=dev", + "https://www.figma.com/branch/ActualFile/ActualBranch/Design?node-id=0-1&mode=dev", + ] { + let mut plan = confirmed_plan(); + plan["source"]["url"] = json!(source); + let action = responsive_export_action(&plan, &plan["routes"][0], "root"); + let link = text(&action["arguments"]["url"]); + let parsed = FigmaTarget::parse(link).unwrap(); + assert_eq!(parsed.node_id.as_deref(), Some("10:2")); + let original = FigmaTarget::parse(source).unwrap(); + assert_eq!(parsed.file_key, original.file_key); + assert_eq!(parsed.branch_key, original.branch_key); + let url = url::Url::parse(link).unwrap(); + assert!( + url.query_pairs() + .any(|(key, value)| key == "mode" && value == "dev") + ); + assert_eq!( + url.query_pairs() + .filter(|(key, _)| key == "node-id") + .count(), + 1 + ); + plan["routes"][0]["states"] = json!([{"nodeId":"10:7","name":"known state","width":390}]); + let states = state_exports(&plan, &plan["routes"][0]); + assert_eq!( + FigmaTarget::parse(text(&states[0]["exportCall"]["arguments"]["url"])) + .unwrap() + .node_id + .as_deref(), + Some("10:7") + ); + plan["routes"][0]["nodeIds"] = json!(["10:1", "10:2", "10:5", "10:6"]); + plan["routes"][0]["drawnWidths"] = json!([1440, 390, 1440, 390]); + let recovery = responsive_export_action(&plan, &plan["routes"][0], "root"); + for call in std::iter::once(&recovery).chain(array(&recovery["remainingReads"])) { + let parsed = FigmaTarget::parse(text(&call["arguments"]["url"])).unwrap(); + assert_eq!( + parsed.node_id.as_deref(), + array(&call["arguments"]["frameIds"]) + .first() + .and_then(Value::as_str) + ); + } + } +} + +#[test] +fn projection_confirmation_finds_explicit_group_subsets_from_app_roots() { + let fixture = Fixture::new(); + fixture.write("apps/front/package.json", "{}"); + let mut plan = ambiguous_contact_plan(); + apply_responsive_groups(&mut plan, &contact_groups()).unwrap(); + write_plan_file(&fixture, &plan); + let app = fixture.0.join("apps/front"); + let app = app.to_str().unwrap(); + assert!( + super::super::projection::responsive_groups::confirmed( + Some(app), + &["d:1".into(), "m:1".into()] + ) + .unwrap() + ); + assert!( + super::super::projection::responsive_groups::confirmed( + Some(app), + &["m:2".into(), "d:2".into()] + ) + .unwrap() + ); + assert!( + !super::super::projection::responsive_groups::confirmed( + Some(app), + &["d:1".into(), "m:2".into()] + ) + .unwrap() + ); + assert!( + !super::super::projection::responsive_groups::confirmed(Some(app), &["d:1".into()]) + .unwrap() + ); +} diff --git a/crates/devup-mcp/src/server/skills/catalog.rs b/crates/devup-mcp/src/server/skills/catalog.rs index 5b5ad6ba..23279350 100644 --- a/crates/devup-mcp/src/server/skills/catalog.rs +++ b/crates/devup-mcp/src/server/skills/catalog.rs @@ -76,6 +76,7 @@ const EMBEDDED: &[(&str, Documents)] = &[ "devfive-frontend", &[ ("SKILL.md", include_str!("devfive-frontend/SKILL.md")), + ("references/delivery-quality.md", include_str!("devfive-frontend/references/delivery-quality.md")), ( "references/critical-rules.md", include_str!("devfive-frontend/references/critical-rules.md"), diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md b/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md index b34781ab..27baa43d 100644 --- a/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md @@ -16,6 +16,12 @@ description: | # DevFive Frontend Coding Conventions +Before wiring generated screens, read [delivery-quality](references/delivery-quality.md). +It covers clean TSX/sidecars, shared server layouts, responsive merging, Korean +word boundaries, complete controls and palette variants, motion, canonical assets +and DB-backed landing/detail content. Apply these rules even when the screenshot +looks correct; verify actual runtime state, asset URLs and changed data. + Common coding conventions for DevFive frontend projects (puzzle-fit, girok-space, etc.). ## Links, controls and completion evidence @@ -25,10 +31,18 @@ are a review list, and `deliverable.isFinal` never proves working navigation or forms. Resolve GNB, logo, footer, CTA, cards, “view all”, tabs and breadcrumbs before reporting functional completion. Preserve checkbox/radio semantics and selected/disabled/active variants; an icon or widget screenshot is not behavior. -`@devup-interaction` TSX comments preserve prototype/state hints; they do not wire -events or implement runtime state. Validation separates semantic controls, native +Structured interactionHints sidecars preserve prototype/state hints; never put +metadata comments in TSX. They do not implement events. Validation separates semantic controls, native checkbox/radio toggling and application wiring; report those counts by their scope. +A URL x width navigation pass is not interaction completion. Collect every used +interactive component's full COMPONENT_SET and carry its captured pseudo-state +styles onto the actual semantic focusable boundary, preserving instance overrides +and responsive layout. A page's default instance or screenshot is not full state +coverage. Test hover, pointer press, keyboard focus, disabled/pending, select open +and selected states, plus resize; leave undefined or uncaptured states explicitly +unverified. Use only source-backed state values, never invented token colors. + 1. Supply `projectRoot` with the export to review `projectContext.ui` reuse evidence, routes, `routeSuggestions`, and `envKeyNames` (names only, never request or print values). Alternatively read `devup_project_context` scope diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md b/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md new file mode 100644 index 00000000..6eb99b67 --- /dev/null +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md @@ -0,0 +1,130 @@ +# Behavior and data contracts + +Apply these rules with every export, including code assembled from multiple +frames. `developmentContract` and `behaviorPlan` are implementation obligations, +not proof that controls, API integration or animations already work. The running +binary ships this document in its usage resource and skill installation. + +## Preserve the design in production code + +- Use generated TSX as the layout source. Preserve captured instance overrides, + text and all widths, using responsive arrays and one shared tree whenever the + content is the same. A changed wrapper alone is not a different component. + Keep truly different mobile cards and desktop tables separate. +- Application TSX is complete code: no `@devup-*` metadata comments and no + `data-figma-node-id(s)` attributes, including debug builds. Node/element maps, + overrides and interaction evidence live in JSON sidecars. Resolve every + override through a prop/variant or record a reason; never discard its evidence. +- JSX uses actual `$token` props, including responsive lengths; no `var(--token)` + in JSX. External CSS objects use CSS variables where the library requires them. + Do not invent tokens from matching numbers. Collapse equal `pl/pr` to `px`, + `pt/pb` to `py`, and matching margin pairs, only when precedence is unchanged. + Verify computed styles at each breakpoint after changing token syntax. +- Korean sentences use `wordBreak="keep-all"` and normal wrapping. Do not fix + word splitting with `nowrap`; handle long URLs or single tokens explicitly. + +## Shared server shells and usable controls + +- Plan `layout.tsx` ownership before assembling pages. Persistent headers, + footers and route-tab bars belong in shared server layouts; data pages remain + Server Components. Put only pathname-dependent selection, focus handling, + animation or local state into a small Client Component. Do not add memoization + wrappers for work already handled by React Compiler. +- The page shell grows to at least the viewport (`minHeight: 100dvh`, column flex, + growing main content or auto top margin on footer). Preserve designed spacing; + do not pin/fix the footer over content. Check short, empty and long pages. +- Actual links/buttons/selects must own hover, focus-visible, active, disabled, + pending and captured state styles. Capture the whole COMPONENT_SET before + projecting states; a static icon or screenshot is not a working select. + Verify select open/choose/close, arrows, Enter/Escape, ARIA and focus restoration. +- A colour variant must use its own captured palette for background, text, + border, icon, hover, press, focus and disabled states. Do not inherit primary + blue into a secondary variant or infer absent tokens by suffix number. +- Derive content, selected background/text/icon and `aria-current` or + `aria-selected` from the same normalized URL/state. Never hardcode a frame's + selected appearance into a desktop/mobile copy. Keep filters/sort when changing + view, reset pagination deliberately, and test both directions, direct URLs, + reload, back/forward, keyboard activation and resize. + +## Motion inferred from visual meaning + +Inspect visual cues and sibling designs even when no prototype animation exists. +Treat inferred motion and timing as implementation choices, not captured facts. +Use captured variant text/images for each state. Respect explicit user playback +preferences; do not introduce unsolicited pause controls or hover suspension. + +- Overflowing repeated single-line display text is a marquee candidate: two + identical track copies, constant linear translation and a seamless loop. Hide + duplicate content from assistive technology; measure after font load and resize. +- Emphasized statistics are count-up candidates across all pages, not just the + landing. Support integers, decimals and dynamic API values. Exclude dates, + identifiers, patent numbers, labels, table row indexes and pagination. Preserve + units, precision, the final accessible value and reserved layout width. Start + once on viewport entry; verify final values after resizing and navigation. +- Sibling active-step designs plus progress lines suggest timed cycling: + complete progress, then highlight/description/image advance, wrap to the first + step, and reset deliberately on manual selection. Derive length from data, + handle empty/one-item arrays and clean timers/listeners on unmount. +- Rounded route tabs suggest a translating shared highlight. Keep the bar mounted + in a common layout; measure actual link widths/positions after route and size + changes. Test animation calls, intermediate position and persistent DOM, not + only the final selected colour. Retain native link/keyboard semantics. +- Use restrained below-to-above entrance motion for meaningful sections. One + shared observer/controller can enhance server markup, including sections added + after navigation. Do not hide SSR content before JavaScript runs. Support reduced + motion, no-JavaScript, focus, one-time playback and observer cleanup. +- Add transitions for captured colour, border and state changes without animating + every layout property. Reduced-motion behavior is the accessible default unless + an explicit requirement says otherwise; document intentional exceptions. + +## One source for business data + +Inventory every statistic, product summary and list/detail field before declaring +integration complete. A landing patent count uses the same API aggregate as the +patent page; pipeline totals and categories derive from actual records. Labels +such as total/registered are immutable text, not numeric replacement targets. +Preserve captured/API list ordering and pagination-aware ascending or descending +display ordinals separately from record IDs. Research years and editorial landing +copy need validated stored fields, not +duplicated TSX literals. Reuse product names, descriptions and stages on landing +and detail pages. Do not infer an API endpoint from a design label. + +Use parallel server reads and minimal client boundaries. Never present fixture +values or local success state after API failure. Test DB -> API -> UI using a copy +DB: change counts/categories/text, empty the records, and simulate server failure. +For forms verify actual stored fields and rejected submissions, preserving real +records. Distinguish persistence from email delivery or deployment. Migrations +must preserve edits, be idempotent and run when development seeds are disabled. + +## Every committed asset has a purpose + +Use one project registry (for example `docs/public-assets.json`) across SVG, +WebP/PNG/JPEG, PDFs, social previews and other public files. Public filenames +describe content and role, never content hashes, Figma IDs or `web-ready` dumps. +Keep source hashes/IDs in sidecars. Query the registry before acquiring/writing; +repeat exports must reuse paths and never overwrite different existing content. + +SVGs share one original across sizes; set responsive `boxSize/w/h` on one element. +Grayscale/muted logos share the colour original with `grayscale(1)` and opacity. +Flat glyph colours come from masks/tokens, not per-state SVG files. Normalize +internal IDs and scale for identity comparison without corrupting mask/filter +paint, alpha, viewBox padding or intentional optical differences. The same small +numeric difference is not sufficient proof of equality. + +For raster media compare actual content, crop and resolution. Reuse an appropriate +original without arbitrary re-encoding/upscaling; retain a distinct image only +when the crop/content requires it. Before removing/renaming, collect code, CSS, +API seed, persisted DB and dynamic references; ignored export records are not +production usage. Update references together and provide a safe URL migration. +Do not delete assets whose dynamic usage remains unresolved. + +## Verification is part of implementation + +Run semantic/token/source checks, the project build/type/lint/tests and browser +checks. Match viewport, device scale, zoom, fonts and scroll before visual +comparison; allow expected scale differences but investigate order-of-magnitude +errors. Test pseudo-state palettes, selected appearance, motion intermediate +states and data changes. Collect missing asset URLs, registry drift, opaque +filenames, exact duplicates and short-page footer gaps. Static validation, a +generated export, an API call in source or coincidentally matching initial data +does not establish runtime completion. Report verified and unverified scopes. diff --git a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md index f15f9426..7bc7d976 100644 --- a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md @@ -24,7 +24,7 @@ description: | ## Project intake comes first -When replacing an inlined instance with a shared component, carry over every `instanceOverrides` entry through a new variant or prop, or explicitly report it as unresolved. Never discard `// @devup-override` annotations or the required `DEVUP_CODEGEN_INSTANCE_OVERRIDE` review item silently. +When replacing an inlined instance with a shared component, carry over every `instanceOverrides` entry through a new variant or prop, or explicitly report it as unresolved. Keep override records in sidecar JSON and resolve the required `DEVUP_CODEGEN_INSTANCE_OVERRIDE` review item; never put metadata comments into application TSX. For a confirmed multi-width URL, `responsiveTsx` is the deliverable. Export all sibling frameIds in width order using screen-plan status's next export action. Never start from single-width `tsx`/`componentTsx` for its page or sections. Only wire links/events/data; never change generated layout values, collapse arrays, or add hard-coded sizes/layout wrappers. Review `generatedExport.generatedDrift` in status; its confidence-labelled findings are heuristic and missing mappings remain unverified. @@ -392,7 +392,7 @@ its computer-use setup yourself. Verify status and a small read before export. environment error. Between the drawn widths there is no design, so only health, overflow, containment, clipped text and the nav are checked. A page merged from one frame per width is found per width through - `data-figma-node-ids` (`"1440:12:34 390:56:78"`), and an element a width + sidecar element mappings and explicit semantic runtime targets, and an element a width does not draw is checked hidden there. A dynamic route (`[slug]`, `[id]`, catch-all) is audited at concrete URLs: its `generateStaticParams` (literal params, or the local data file it maps), else the links the @@ -569,11 +569,10 @@ evidence and replaces the question it would otherwise take: - `framework` (`vinext` | `next`) answers the migrate-to-vinext question, `updateDependencies: false` skips the dependency step for a project that forbids it, and `viewTransitions: false` opts out of view transitions. -- Figma node attributes are off by default. Where the exporter supports it, - `figmaNodeIds: true` explicitly opts in; do not send unsupported parameters - to an older server. DevupUI strips these attributes by default and - `DEVUP_AUDIT=1` keeps them. Do not change app build config for auditing. - Missing audit anchors are unverified, not a reason to claim checks passed. +- Figma identities stay in source-map/element-mapping JSON sidecars. No debug + option adds provenance attributes or metadata comments to TSX. Use explicit + semantic runtime targets; unavailable targets are unverified. Do not add a + strip plugin or change application build configuration for auditing. - `maxFixAttempts` (default 3) is how many fix-and-rerun rounds a finding gets before it is unresolved. - Geometry has no tolerance to configure; it is exact. @@ -595,3 +594,11 @@ These rules apply to every plan this workflow produces. ## Wrong-Width Component Masters Treat `DEVUP_DESIGN_COMPONENT_WIDTH_MISMATCH` as an automatically resolved design placement error. The screen plan records the strict same-width majority, the expected master and a correct sibling instance, plus native-node PNG comparisons when available (otherwise `render=unavailable`). Follow that assumption without another question. Header/Footer/Nav remains one responsive component across widths, not a second variant. Audit planning substitutes the correct sibling's height and children at screen width and the suspect's original y position. The original whole-page PNG still depicts the design error and must not be accepted as a corrected-page visual reference. + +## Delivery quality + +Apply `developmentContract` and `behaviorPlan` on every export. Read the bundled +`devfive-frontend/references/delivery-quality.md` for pure TSX, one responsive tree, +persistent server layouts, state/colour semantics, motion, all public assets and +DB-to-API-to-UI verification. Inferred motion is an implementation requirement, +not a claim that static generated markup is already interactive. diff --git a/crates/devup-mcp/src/server/skills/devup/SKILL.md b/crates/devup-mcp/src/server/skills/devup/SKILL.md index e2fbbd5c..0813548c 100644 --- a/crates/devup-mcp/src/server/skills/devup/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup/SKILL.md @@ -13,7 +13,7 @@ description: | ## Project intake comes first -When replacing an inlined instance with a shared component, carry over every `instanceOverrides` entry through a new variant or prop, or explicitly report it as unresolved. Never discard `// @devup-override` annotations or the required `DEVUP_CODEGEN_INSTANCE_OVERRIDE` review item silently. +When replacing an inlined instance with a shared component, carry over every `instanceOverrides` entry through a new variant or prop, or explicitly report it as unresolved. Keep override records in sidecar JSON and resolve the required `DEVUP_CODEGEN_INSTANCE_OVERRIDE` review item; never put metadata comments into application TSX. For a confirmed multi-width URL, `responsiveTsx` is the deliverable. Export all sibling frameIds in width order using screen-plan status's next export action. Never start from single-width `tsx`/`componentTsx` for its page or sections. Only wire links/events/data; never change generated layout values, collapse arrays, or add hard-coded sizes/layout wrappers. Review `generatedExport.generatedDrift` in status; its confidence-labelled findings are heuristic and missing mappings remain unverified. @@ -181,7 +181,7 @@ the full rules; these are the steps. page, each section and each asset against its Figma render and the size and position of each section, card and asset at the drawn widths - exact, half a pixel of rounding and no tolerance; merged pages per width - (`data-figma-node-ids`); dynamic routes at concrete URLs; hover + (sidecar element mappings and semantic targets); dynamic routes at concrete URLs; hover and focus; a view-transition check, last. Export whatever `missingReferences` names and run auditPlan again. 10. **Run, status, fix - URL by URL.** Run each plan with the runner it wrote, @@ -209,10 +209,7 @@ the full rules; these are the steps. widths and health and overflow pass at every required width. Report filed converter bugs and unresolved findings with evidence as incomplete. Also: every URL implemented, the production build free of `data-figma-node-id`. -DevupUI strips audit attributes by default; `DEVUP_AUDIT=1` keeps them. -Figma node attributes are off by default; opt in with `figmaNodeIds: true` -only where the export schema supports it. Do not change app build config for auditing -or register a strip plugin. Inspect the ordinary production output. +Generated TSX contains no provenance attributes, including debug builds. Use source-map sidecars and semantic runtime targets. Do not add strip plugins. Inspect the ordinary production output. Run `bun run lint:fix` first, then `bunx oxlint --deny-warnings` at the root. Never fix JSX prop order, import sorting or formatting errors by hand. Fix only non-fixable leftovers by hand (type errors, logic and decisions the fixer cannot make). @@ -267,3 +264,11 @@ user-owned decision unresolved; a prose question or silent assumption is not a substitute. Ordinary implementation choices still use defer/assume without asking. Any decision that needs the user, before or after confirm, is asked with the runtime question tool, never as numbered prose such as "tell me which numbers to change". Show the evidence table first, then ask. Put the recommended option first, labelled "(Recommended)", with two or three concrete alternatives (for example "category as a kind column / as an enum column / as a free text column"). A header is at most 30 characters and a question has two to four options. Keep the confirmation of a table separate from the decisions inside it: the table is one approve-or-change question, each real decision gets its own entry in the same question call. Record each answer with `devup_project_plan defer` or `assume` (id, reason, recommended) so it survives in the plan; a decision that changes a model is applied to `models/*.json` and the next action says so. + +## Delivery quality + +Apply `developmentContract` and `behaviorPlan` on every export. Read the bundled +`devfive-frontend/references/delivery-quality.md` for pure TSX, one responsive tree, +persistent server layouts, state/colour semantics, motion, all public assets and +DB-to-API-to-UI verification. Inferred motion is an implementation requirement, +not a claim that static generated markup is already interactive. diff --git a/crates/devup-mcp/src/server/skills/fetch_tests.rs b/crates/devup-mcp/src/server/skills/fetch_tests.rs index e5de5dad..647dc477 100644 --- a/crates/devup-mcp/src/server/skills/fetch_tests.rs +++ b/crates/devup-mcp/src/server/skills/fetch_tests.rs @@ -164,7 +164,13 @@ async fn own_external_and_unknown_skills_never_fetch() { .await .unwrap(); assert_eq!(result["installed"][0]["source"], "embedded"); - assert_eq!(result["installed"][0]["paths"].as_array().unwrap().len(), 5); + let installed_paths = result["installed"][0]["paths"].as_array().unwrap(); + assert_eq!(installed_paths.len(), 6); + let delivery_reference = installed_paths.iter().find_map(|path| { + let path = std::path::Path::new(path.as_str()?); + (path.file_name()?.to_str()? == "delivery-quality.md").then_some(path) + }).expect("delivery rules are installed with the frontend skill"); + assert!(std::fs::read_to_string(delivery_reference).unwrap().contains("Behavior and data contracts")); assert_eq!( result["notInstallable"][0]["name"], "vercel-react-best-practices" diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index 1845952e..8f50351d 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -51,8 +51,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 26167, - "sha256": "f6c9feebb9c250549bd442455102b35f699fdc9518946ecd23d00d85e3ff26d3" + "bytes": 27157, + "sha256": "9088379e6d51fedde1f6178f78e09cb6e7a36854b4ab98c6f1078db05d059445" }, { "path": "references/critical-rules.md", @@ -73,6 +73,11 @@ "path": "references/theme-colors.md", "bytes": 4610, "sha256": "1d9bb1d7bff8e3b1c0b01433fc9a1ca521d2513d2823048dde9a4fb241399cef" + }, + { + "path": "references/delivery-quality.md", + "bytes": 8490, + "sha256": "2780887b0e39e3db5b3880cb8757ca077d09ed1188da80e27a41de6c04c64365" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend", @@ -89,8 +94,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 42158, - "sha256": "6b206c3e1cd1b5d9a9da806b19f393caf71c61f55a5e3008ca0172bda44e85b3" + "bytes": 42556, + "sha256": "cb6ec7010a91502e0d432a359db3267e2e56d24ec60177d4765f64f99250c20d" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", @@ -107,8 +112,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 21433, - "sha256": "fc82f3767b2e09eee1dee8b422f87f62840c49c67441a172c4924e7101ce2892" + "bytes": 21804, + "sha256": "f4e8601dff53896f9cecbaea7c0a8d7391443984f18829639b8ada74053dc540" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup", diff --git a/crates/devup-mcp/src/server/stack_diff/openapi_client_layer.rs b/crates/devup-mcp/src/server/stack_diff/openapi_client_layer.rs index 81ebf76a..4879fdd3 100644 --- a/crates/devup-mcp/src/server/stack_diff/openapi_client_layer.rs +++ b/crates/devup-mcp/src/server/stack_diff/openapi_client_layer.rs @@ -1,5 +1,3 @@ - - // --------------------------------------------------------------------- // openapi-client: does the frontend call endpoints the spec has? // --------------------------------------------------------------------- @@ -39,6 +37,7 @@ fn openapi_client_layer(root: &Path) -> Value { let mut known_identifiers = BTreeSet::::new(); let mut known_paths = BTreeSet::::new(); + let mut generated_aliases = BTreeSet::::new(); let mut crud_operations = BTreeMap::>::new(); for file in &openapi_files { let Ok(source) = std::fs::read_to_string(file) else { @@ -47,6 +46,22 @@ fn openapi_client_layer(root: &Path) -> Value { let Ok(spec) = serde_json::from_str::(&source) else { continue; }; + // @devup-api defaults to camelCase operation IDs, but can also keep + // or convert their spelling. Require the generated accepted-key map + // plus a corresponding operation in the current spec; never accept a + // guessed alias or a stale key for a removed operation. + let generated_keys = file + .parent() + .and_then(|parent| std::fs::read_to_string(parent.join("df/api.d.ts")).ok()) + .map(|source| { + parse::generated_client_keys( + &source, + file.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("openapi.json"), + ) + }) + .unwrap_or_default(); if let Some(paths) = spec.get("paths").and_then(Value::as_object) { for (path, methods) in paths { known_identifiers.insert(path.clone()); @@ -57,6 +72,16 @@ fn openapi_client_layer(root: &Path) -> Value { operation.get("operationId").and_then(Value::as_str) { known_identifiers.insert(operation_id.to_owned()); + generated_aliases.extend( + generated_keys + .iter() + .filter(|key| { + !key.starts_with('/') + && operation_alias_key(key) + == operation_alias_key(operation_id) + }) + .cloned(), + ); } for tag in operation .get("tags") @@ -88,7 +113,11 @@ fn openapi_client_layer(root: &Path) -> Value { let sources = ts_files .iter() - .filter_map(|file| std::fs::read_to_string(file).ok().map(|source| (file, source))) + .filter_map(|file| { + std::fs::read_to_string(file) + .ok() + .map(|source| (file, source)) + }) .collect::>(); let known_clients = sources .iter() @@ -97,6 +126,7 @@ fn openapi_client_layer(root: &Path) -> Value { let mut drifts = Vec::new(); let mut calls_checked = 0usize; let mut spelling_only_matches = 0usize; + let mut generated_alias_matches = 0usize; for (file, source) in &sources { let (mut calls, configs) = parse::client_references_with(source, &known_clients); for (call_site, name) in configs { @@ -123,6 +153,10 @@ fn openapi_client_layer(root: &Path) -> Value { if known_identifiers.contains(&identifier) { continue; } + if generated_aliases.contains(&identifier) { + generated_alias_matches += 1; + continue; + } if identifier.starts_with('/') && known_paths.contains(&client_path_key(&identifier)) { spelling_only_matches += 1; continue; @@ -144,6 +178,7 @@ fn openapi_client_layer(root: &Path) -> Value { "callsChecked": calls_checked, "knownIdentifierCount": known_identifiers.len(), "spellingNormalizedMatches": spelling_only_matches, + "generatedOperationAliasMatches": generated_alias_matches, "drifts": drifts, }); if spelling_only_matches > 0 @@ -157,10 +192,30 @@ fn openapi_client_layer(root: &Path) -> Value { )]), ); } + if generated_alias_matches > 0 { + let note = json!( + "Operation aliases were verified against df/api.d.ts DevupPrecomputedMethodKeys and a corresponding operationId in the current OpenAPI spec. Custom generated directories and missing accepted-key maps remain unverified; naming similarity alone is never accepted." + ); + if let Some(notes) = layer.get_mut("notes").and_then(Value::as_array_mut) { + notes.push(note); + } else { + layer["notes"] = json!([note]); + } + } attach_excluded_paths(&mut layer, excluded); layer } +/// Naming only connects a generated key to a current operation. The key must +/// also be declared by the generator before this comparison is considered. +fn operation_alias_key(identifier: &str) -> String { + identifier + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|character| character.to_ascii_lowercase()) + .collect() +} + /// Folds the ways the same endpoint path is spelled on the two sides of /// the `openapi-client` comparison. /// @@ -212,6 +267,11 @@ fn find_frontend_sources(root: &Path, max_depth: usize) -> Vec { "target", // Another branch's copy of the same frontend (defect D7). ".worktrees", + // Saved exports, former page implementations and agent documents are + // evidence, not the current frontend. Auditing them reports stale calls. + ".devup", + ".agents", + ".opencode", ]; let mut found = Vec::new(); let mut queue = vec![(root.to_path_buf(), 0usize)]; @@ -250,6 +310,7 @@ fn extract_devup_api_calls(source: &str) -> Vec<(String, String)> { #[cfg(test)] mod tests { -include!("tests.rs"); -include!("tests_route_openapi_layer_flags_route_missing_from_spec.rs"); + include!("tests.rs"); + include!("tests_route_openapi_layer_flags_route_missing_from_spec.rs"); + include!("tests_generated_operation_aliases.rs"); } diff --git a/crates/devup-mcp/src/server/stack_diff/tests_generated_operation_aliases.rs b/crates/devup-mcp/src/server/stack_diff/tests_generated_operation_aliases.rs new file mode 100644 index 00000000..f1b7bdc3 --- /dev/null +++ b/crates/devup-mcp/src/server/stack_diff/tests_generated_operation_aliases.rs @@ -0,0 +1,128 @@ +fn generated_alias_fixture(label: &str, keys: &str) -> ScopedTempDir { + let temp = ScopedTempDir::new(label); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + std::fs::create_dir(temp.path().join("df")).unwrap(); + std::fs::write( + temp.path().join("openapi.json"), + json!({"paths": { + "/inquiries": {"post": {"operationId": "create_inquiry"}} + }}) + .to_string(), + ) + .unwrap(); + std::fs::write(temp.path().join("df/api.d.ts"), format!( + "declare module '@devup-api/fetch' {{ interface DevupPrecomputedMethodKeys {{ 'openapi.json': {{ post: {keys}; get: never; }} }} }}" + )).unwrap(); + temp +} + +#[tokio::test] +async fn generated_camel_operation_alias_is_not_client_drift() { + let temp = generated_alias_fixture("generated-alias", "'/inquiries' | 'createInquiry'"); + std::fs::write( + temp.path().join("page.tsx"), + r#" + import { createApi } from '@devup-api/fetch'; + const client = createApi({}); + client.post('createInquiry', { body: {} }); + import { createInquiry } from '@devup-api/fetch/server'; + pathSchemas.POST['createInquiry']; + api.post('missingInquiry'); + "#, + ) + .unwrap(); + let result = run( + Some(&temp.path().to_string_lossy()), + &["openapi-client".into()], + ) + .await + .unwrap(); + let layer = &result["layers"]["openapi-client"]; + assert_eq!(layer["callsChecked"], 4, "{layer}"); + assert_eq!(layer["generatedOperationAliasMatches"], 3, "{layer}"); + assert_eq!(layer["drifts"].as_array().unwrap().len(), 1, "{layer}"); + assert_eq!(layer["drifts"][0]["identifier"], "missingInquiry"); +} + +#[tokio::test] +async fn stale_generated_keys_cannot_hide_removed_operations() { + let temp = generated_alias_fixture("stale-alias", "'createInquiry' | 'deleteInquiry'"); + std::fs::write( + temp.path().join("page.ts"), + "api.post('createInquiry'); api.delete('deleteInquiry');", + ) + .unwrap(); + let result = run( + Some(&temp.path().to_string_lossy()), + &["openapi-client".into()], + ) + .await + .unwrap(); + let layer = &result["layers"]["openapi-client"]; + assert_eq!(layer["generatedOperationAliasMatches"], 1, "{layer}"); + assert_eq!(layer["drifts"].as_array().unwrap().len(), 1, "{layer}"); + assert_eq!(layer["drifts"][0]["identifier"], "deleteInquiry"); +} + +#[tokio::test] +async fn operation_alias_requires_generated_evidence_and_respects_maintain_case() { + let temp = generated_alias_fixture("maintain-alias", "'/inquiries' | 'create_inquiry'"); + std::fs::write( + temp.path().join("page.ts"), + "api.post('createInquiry'); api.post('create_inquiry');", + ) + .unwrap(); + for remove_generated in [false, true] { + if remove_generated { + std::fs::remove_file(temp.path().join("df/api.d.ts")).unwrap(); + } + let result = run( + Some(&temp.path().to_string_lossy()), + &["openapi-client".into()], + ) + .await + .unwrap(); + let layer = &result["layers"]["openapi-client"]; + assert_eq!(layer["generatedOperationAliasMatches"], 0, "{layer}"); + assert_eq!(layer["drifts"].as_array().unwrap().len(), 1, "{layer}"); + assert_eq!(layer["drifts"][0]["identifier"], "createInquiry"); + } +} + +#[test] +fn generated_keys_are_scoped_to_the_spec_and_ignore_comment_examples() { + let source = r#" + // interface DevupPrecomputedMethodKeys { 'openapi.json': { post: 'commentAlias'; } } + const example = "interface DevupPrecomputedMethodKeys { 'openapi.json': { post: 'stringAlias'; } }"; + interface Unrelated { 'openapi.json': { post: 'unrelatedAlias'; } } + interface DevupPrecomputedMethodKeys { + 'other.json': { post: 'otherAlias'; }; + 'openapi.json': { post: '/inquiries' | 'createInquiry'; get: never; }; + } + "#; + assert_eq!( + parse::generated_client_keys(source, "openapi.json"), + BTreeSet::from(["/inquiries".to_owned(), "createInquiry".to_owned(),]) + ); +} + +#[tokio::test] +async fn saved_exports_do_not_contribute_stale_client_calls() { + let temp = generated_alias_fixture("saved-alias", "'createInquiry'"); + std::fs::write(temp.path().join("page.ts"), "api.post('createInquiry');").unwrap(); + for folder in [".devup/backup", ".agents/skills", ".opencode/skill"] { + let dir = temp.path().join(folder); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("old-page.tsx"), "api.post('removedOperation');").unwrap(); + } + let result = run( + Some(&temp.path().to_string_lossy()), + &["openapi-client".into()], + ) + .await + .unwrap(); + let layer = &result["layers"]["openapi-client"]; + assert_eq!(layer["callsChecked"], 1, "{layer}"); + assert_eq!(layer["filesScanned"], 1, "{layer}"); + assert!(layer["drifts"].as_array().unwrap().is_empty(), "{layer}"); +} diff --git a/crates/devup-mcp/src/server/stack_diff_parse.rs b/crates/devup-mcp/src/server/stack_diff_parse.rs index c23aa478..c3fc9923 100644 --- a/crates/devup-mcp/src/server/stack_diff_parse.rs +++ b/crates/devup-mcp/src/server/stack_diff_parse.rs @@ -519,6 +519,57 @@ fn object_config(tokens: &[Token]) -> Option { /// references, which must first be expanded through OpenAPI's devup tags. type References = Vec<(String, String)>; +/// Read the generator's literal accepted method keys, not arbitrary strings +/// in declarations or comments. `convertCase` is configurable, so a naming +/// resemblance alone cannot prove that an operation alias exists. +pub(in crate::server) fn generated_client_keys(source: &str, spec_name: &str) -> BTreeSet { + let tokens = tokenize(source, false); + let mut keys = BTreeSet::new(); + for i in 0..tokens.len() { + if !word(&tokens, i, "interface") + || !word(&tokens, i + 1, "DevupPrecomputedMethodKeys") + || !symbol(&tokens, i + 2, '{') + { + continue; + } + let Some(end) = group_end(&tokens, i + 2) else { + continue; + }; + let mut at = i + 3; + while at < end { + if tokens.get(at).and_then(Token::literal) == Some(spec_name) + && symbol(&tokens, at + 1, ':') + && symbol(&tokens, at + 2, '{') + && let Some(server_end) = group_end(&tokens, at + 2) + { + let mut field = at + 3; + while field < server_end { + if tokens + .get(field) + .and_then(Token::word) + .is_some_and(http_method) + && symbol(&tokens, field + 1, ':') + { + field += 2; + while field < server_end && !symbol(&tokens, field, ';') { + if let Some(key) = tokens[field].literal() { + keys.insert(key.to_owned()); + } + field += 1; + } + } + field += 1; + } + at = server_end; + } else if let Some(group) = group_end(&tokens, at) { + at = group; + } + at += 1; + } + } + keys +} + /// Names bound directly to a `createApi(...)` result in this source: /// `export const client = createApi({ ... })`. pub(in crate::server) fn api_client_bindings(source: &str) -> Vec { @@ -549,12 +600,22 @@ fn client_receivers(tokens: &[Token], source: &str, known: &BTreeSet) -> && word(tokens, end + 1, "from") { for part in comma_parts(&tokens[i + 2..end]) { - let part = if word(part, 0, "type") { &part[1..] } else { part }; - let Some(imported) = part.first().and_then(Token::word) else { continue }; + let part = if word(part, 0, "type") { + &part[1..] + } else { + part + }; + let Some(imported) = part.first().and_then(Token::word) else { + continue; + }; if !known.contains(imported) { continue; } - let local = if word(part, 1, "as") { part.get(2).and_then(Token::word) } else { Some(imported) }; + let local = if word(part, 1, "as") { + part.get(2).and_then(Token::word) + } else { + Some(imported) + }; if let Some(local) = local { receivers.insert(local.to_owned()); } diff --git a/crates/devup-mcp/src/server/text/audit_plan-url_plan-a1031a26.md b/crates/devup-mcp/src/server/text/audit_plan-url_plan-a1031a26.md index bef05df7..2fd3e917 100644 --- a/crates/devup-mcp/src/server/text/audit_plan-url_plan-a1031a26.md +++ b/crates/devup-mcp/src/server/text/audit_plan-url_plan-a1031a26.md @@ -1 +1 @@ -The {width}px frame {node_id} is {height}px tall, outside the 1..={MAX_VIEWPORT_SIDE}px viewport range. Use the default viewport height and capture the page at its recorded drawn frame height for visual comparison. +The {width}px frame {node_id} is {height}px tall, outside the 1..={MAX_VIEWPORT_SIDE}px viewport range. Keep the default browser viewport height and capture the natural full page. Frame height is reference metadata, not a required browser window/capture height. diff --git a/crates/devup-mcp/src/server/text/audit_plan-write-7f9aea99.md b/crates/devup-mcp/src/server/text/audit_plan-write-7f9aea99.md index b2fbba8d..6e5f3231 100644 --- a/crates/devup-mcp/src/server/text/audit_plan-write-7f9aea99.md +++ b/crates/devup-mcp/src/server/text/audit_plan-write-7f9aea99.md @@ -1 +1 @@ -Sections, cards and assets are found by data-figma-node-id, which devup_figma_export writes into a project's TSX. A check whose element is missing reports missingNodes; re-export with projectRoot. \ No newline at end of file +Source node and responsive-width identity mappings remain in JSON sidecars. Generated TSX never carries Figma node attributes, including debug exports. Runtime audits use explicit semantic targets; unavailable mappings are unverified, not silently passed. diff --git a/crates/devup-mcp/src/server/text/devup-figma-export.md b/crates/devup-mcp/src/server/text/devup-figma-export.md index dfee10df..15dda345 100644 --- a/crates/devup-mcp/src/server/text/devup-figma-export.md +++ b/crates/devup-mcp/src/server/text/devup-figma-export.md @@ -1,4 +1,4 @@ -Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name="Cards"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). The `tsx` this returns is devup-ui code, not plain React: its components are compile-time placeholders, `$token` names an entry in the project's devup.json, and a style prop takes a responsive array. Call devup_skills before you write or edit it - it reports which of those conventions this workspace is missing and installs them where your runtime loads skills from. An agent that skips this does not know it is guessing, and this server cannot see the guesses; the gap is repeated as `skillGap` on the response that carries the code. This output is the design, already read. Do not rewrite it from a screenshot or from `get_design_context` - pictures and visual reasoning verify, they do not author. Do not read the node tree and write devup-ui by hand, and do not infer layout from coordinates. Never guess a colour, spacing, radius or typography value: if a value did not come back, say so and stop, because an invented one is indistinguishable from a real one in the code and is the single failure this server exists to prevent. A failed call is a fact to report, not something to route around. Ask only for what you will read: `tsx` is the deliverable, and the response always carries `status`, `quality`, `cache.artifactId`, `collection` and `source` beside it. `outputs` defaults to `["tsx"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the annotated output. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true} +Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name="Cards"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). The `tsx` this returns is devup-ui code, not plain React: its components are compile-time placeholders, `$token` names an entry in the project's devup.json, and a style prop takes a responsive array. Call devup_skills before you write or edit it - it reports which of those conventions this workspace is missing and installs them where your runtime loads skills from. An agent that skips this does not know it is guessing, and this server cannot see the guesses; the gap is repeated as `skillGap` on the response that carries the code. This output is the design, already read. Do not rewrite it from a screenshot or from `get_design_context` - pictures and visual reasoning verify, they do not author. Do not read the node tree and write devup-ui by hand, and do not infer layout from coordinates. Never guess a colour, spacing, radius or typography value: if a value did not come back, say so and stop, because an invented one is indistinguishable from a real one in the code and is the single failure this server exists to prevent. A failed call is a fact to report, not something to route around. Ask only for what you will read: `tsx` is the deliverable, and the response always carries `status`, `quality`, `cache.artifactId`, `collection` and `source` beside it. `outputs` defaults to `["tsx"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the clean output; node identities and instanceOverrides remain structured sidecars. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true} An asset request targeting a frame by nodeId defaults to the original IMAGE fill when one is available. This keeps hero headings and buttons live instead of baking them into the background image. Explicit renderMode node retains whole-node capture; its renderInfo warns when child text would be baked into the asset. interactiveCandidates may include role="marquee" with states.confidence="heuristic" for single-line text or repeated text rows exceeding a clipsContent ancestor, or long repeated-separator strips. Figma does not prove animation intent. Review the supplied recipe: duplicate content in a nowrap track, keyframes translateX(0) to translateX(-50%) with infinite linear animation, _motionReduce animation none, and aria-hidden on the duplicate. Export remains static until this behavior is implemented and tested. @@ -7,3 +7,5 @@ Exports report captured sections/assets with no generated element as DEVUP_CODEG responseLimit and responseOffset page the already-captured result (outline nodes, selected paths); they do not limit how much Figma is read. Collection cost follows the size of the selected node, so for a very large node pick a smaller inner frame (devup_figma_explore) or read only dimensions with devup_figma_measure instead of raising or lowering responseLimit. To poll a job cheaply pass responseFields such as ["exportJob.state","exportJob.elapsedMs","exportJob.completedCalls","exportJob.nextAction"]. With projectRoot, generated outputs default to guarded writes under .devup/exports/: tsx uses .tsx, componentTsx uses .component.tsx, responsiveTsx uses .responsive.tsx, and sourceMap uses .sourcemap.json, unless outputPaths already names that output. Section frames use frame:: keys. defaultOutputPaths lists applied defaults; disallowed defaults warn DEVUP_DEFAULT_OUTPUT_PATH_SKIPPED and retain inline delivery. Written code is omitted from the response body; outputResults reports bodyDelivery:"written-to-file" and path. Job polls retain the original destinations. Without projectRoot no default write is made. Export projectContext summarizes counts and relevant routes, components and referenced assets only; use its nextAction with devup_project_context scope ui/routes for the full inventory. responseFields keeps late project metadata in selected values only, alongside the bounded delivery/review envelope. Screen-plan component substitutions: when projectRoot has a plan, `componentWidthMismatches` and `componentWidthAssumptions` expose automatic `DEVUP_DESIGN_COMPONENT_WIDTH_MISMATCH` corrections. Use the expected master/correct sibling for the affected width in one responsive shared component, never a second variant. Native-node PNG evidence compares the suspect and correct sibling; unavailable renders are explicit and the strict width majority decides. Audit references use the substituted subtree and retain the suspect's y placement; the original whole-page render cannot verify the corrected page. + +Requesting outputs [tsx, responsiveTsx] expands instance subtrees in the responsive companion too, preserving their concrete captured text and layout. Requesting responsiveTsx alone retains shared component calls. Both modes retain override diagnostics; expansion does not prove that cross-width text or unmatched structure merged without loss. Read responsiveUnrepresented and the required review checklist. diff --git a/crates/devup-mcp/src/server/text/devup-runtime-audit.md b/crates/devup-mcp/src/server/text/devup-runtime-audit.md index 5e73ee63..6cd7e2d9 100644 --- a/crates/devup-mcp/src/server/text/devup-runtime-audit.md +++ b/crates/devup-mcp/src/server/text/devup-runtime-audit.md @@ -4,3 +4,5 @@ Before reporting done, run the runtime audit for every confirmed URL at every re Rendered completeness requires the frame height (default minimum 90%), listed sections and images, visible text contrast (default minimum 1.5:1), and decoded image resources. no-low-contrast-text checks computed text colours against composited ancestor backgrounds, including browser default control backgrounds. Image or unsupported colour backgrounds are inconclusive, never silently passed. image-loaded checks every img and CSS background URL (including pseudo-elements), requiring naturalWidth > 0; lazy images are loaded for the audit. Both checks accept an optional subject scope and run at every audit width in generated plans. native-appearance fails any visible button whose computed appearance is not none without an author-set background and border, or that paints the browser default grey. stale-resources fails any image, script, stylesheet, font or media response with an HTTP status of 400 or more, and any difference in stylesheet content, image URLs or first-section height from a fresh, cache-free load of the same URL; image-loaded also fails images with no painted box or a failed image request. `links-resolve` checks every same-origin anchor against confirmed URL patterns and existing routes, following concrete dynamic hrefs. The report and screen-plan finalReport include a linkTable and Markdown table; any failing link prevents done. + +Selection proof: expect.selected requires semantic group/target locators, attribute aria-current or aria-selected, a true selection value, and 1..12 captured CSS paint values in styles. It checks exactly one visible selected control, actual computed paint, containment and optional content visibility. Use press(target,value) and back/forward/reload(no target/value) steps to test keyboard and browser history. A URL change or stale ARIA alone never proves visual selection. Markup also detects short-page footer gaps. diff --git a/crates/devup-mcp/src/server/text/devup-screen-plan.md b/crates/devup-mcp/src/server/text/devup-screen-plan.md index c6b72fec..b53e83cc 100644 --- a/crates/devup-mcp/src/server/text/devup-screen-plan.md +++ b/crates/devup-mcp/src/server/text/devup-screen-plan.md @@ -1,4 +1,4 @@ -Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a "the Figma structure is wrong" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `proposal` (and `proposalMarkdown`): every open decision as one table row with the recommended answer and the alternatives. Do not interview the user one decision at a time: show the table and ask ONE question with your question tool - accept it, or name the rows to change - in the language the user first wrote in (translate for display only; answers stay keyed by question id with the original labels or custom text). confirm with `acceptRecommended: true` fills every unanswered applicable question with its recommended option, so send only the changed rows in `answers`; without it, confirm refuses and names the missing ids. `questions`/`questionRounds` remain for reviewing individual rows. A confirm that puts two screens on one URL writes nothing and returns `details.conflict` (both screens and the questions placing them) and `details.retryAnswers` with a distinct suggested URL to retry with. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it. +Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a "the Figma structure is wrong" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `proposal` (and `proposalMarkdown`): every open decision as one table row with the recommended answer and the alternatives. Do not interview the user one decision at a time: show the table and ask ONE question with your question tool - accept it, or name the rows to change - in the language the user first wrote in (translate for display only; answers stay keyed by question id with the original labels or custom text). confirm with `acceptRecommended: true` fills every unanswered applicable question with its recommended option, so send only the changed rows in `answers`; without it, confirm refuses and names the missing ids. `questions`/`questionRounds` remain for reviewing individual rows. A confirm that puts two screens on one URL writes nothing and returns `details.conflict` (both screens and the questions placing them) and `details.retryAnswers` with a distinct suggested URL to retry with. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, after recording CSS viewport, zoom, device scale, fonts and scroll; half a CSS pixel of rounding applies only to calibrated drawn-width geometry; merged pages mapped through structured export sidecars and explicit semantic runtime targets, with missing mappings left not-run; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; clean TSX in development and production without source-node attributes or strip plugins; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it. Audit build policy: do not change app build config for auditing or register a strip plugin, including any legacy auditPlan suggestion to do so. DevupUI strips audit attributes by default; DEVUP_AUDIT=1 keeps them. Figma node attributes are off by default; figmaNodeIds true is opt-in only where supported by the export schema. Missing anchors leave coverage unverified. Before reporting done, run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read the report, fix failures and rerun until every cell passes. Missing cells are not-run and block completion. At drawn widths require rendered completeness and page, section and asset visual comparisons. Check sequence-order and section-layout at every width. For every multi-width URL, status and its work queue provide the responsiveTsx export call with all sibling frameIds in width order. responsiveTsx is the deliverable: never start from single-width tsx, and never change generated layout values. Only wire links/events/data. generatedExport reports recorded responsive coverage, flags single-width exports used for multi-width URLs, and includes heuristic generatedDrift with confidence and source file paths; absent mappings remain unverified. Ask known site, structure and URL decisions up front in one runtime question call. Keep table approval separate from individual decisions. New user-owned decisions after confirm still use the question tool: headers up to 30 characters, two to four options, recommended first and labelled "(Recommended)". Never ask for numbers in chat. Record the chosen answer, decision id, affected model/endpoint/file and rationale through existing defer/assume actions; do not invent an ask action. Re-confirm source model changes before dependent work. Without a question tool, report the limitation and leave the decision unresolved. Workers escalate to the supervisor; continue independent work while answers are pending. diff --git a/crates/devup-mcp/src/server/text/devup-visual-compare.md b/crates/devup-mcp/src/server/text/devup-visual-compare.md index bf57e94a..b61945ce 100644 --- a/crates/devup-mcp/src/server/text/devup-visual-compare.md +++ b/crates/devup-mcp/src/server/text/devup-visual-compare.md @@ -1 +1,3 @@ -Compare actual PNG with one reference PNG path, cached artifactId referencePng, or {artifactId,assetId} captured native-node PNG asset. Asset comparisons validate retained reference bytes and report asset pixel scope without requiring a browser environment; raw-fill and unknown captures cannot be references. Paths must be allowlisted. Never renders or runs commands. Default threshold is 0.005. Page comparisons require a valid environment manifest for a conclusive verdict. Optional diff PNG uses auto|inline|resource delivery. \ No newline at end of file +Compare actual PNG with one reference PNG path, cached artifactId referencePng, or {artifactId,assetId} captured native-node PNG asset. Asset comparisons validate retained reference bytes and report asset pixel scope without requiring a browser environment; raw-fill and unknown captures cannot be references. Paths must be allowlisted. Never renders or runs commands. Default threshold is 0.005. Page comparisons require a valid environment manifest for a conclusive verdict. Optional diff PNG uses auto|inline|resource delivery. Page environment.viewport records the actual CSS browser window, never the Figma frame height. environment.captureContext must carry actual innerWidth/innerHeight, devicePixelRatio, visualViewportScale and rootCssZoom/bodyCssZoom measurements. Natural full-page PNG height may differ from viewport height. This bounded schema does not prove non-unit capture/reference calibration; missing/inconsistent zoom/DPR evidence stays inconclusive. Different PNG dimensions never establish a layout error or authorize resizing/padding/cropping. + +Strict pixels also depend on font rasterization and asset encoding. Record browser launch flags with consumer evidence: identical font bounds can still have different grayscale/subpixel RGB edges. The web-ready-images preset uses lossy WebP by default; a native Figma frame PNG is not that derivative. Diagnose a suspected encoding difference with an explicit lossless original-source asset request and the same TSX, geometry and browser. Pin source/output dimensions so resizing is not confused with compression. Confirm the baseline reproduces the original screenshot and that the replacement was actually loaded; a hot-reload race or an unintercepted image invalidates the experiment. Controlled improvements identify a cause, not pixel equivalence: keep strict results and unexplained differences visible, and never move generated layout values or relax the threshold to hide them. diff --git a/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md index b5df6425..2099b3c4 100644 --- a/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md +++ b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md @@ -1,8 +1,45 @@ # Using devup-mcp -When replacing an inlined instance with a shared component, carry over every `instanceOverrides` entry through a new variant or prop, or explicitly report it as unresolved. `DEVUP_CODEGEN_INSTANCE_OVERRIDE` is a required review item; `// @devup-override` annotations retain its evidence in generated TSX. A missing captured value or master is unresolved, never evidence that an override is safe to discard. - -For a confirmed URL with multiple drawn widths, `responsiveTsx` is the deliverable. Use all sibling frameIds in width order from screen-plan status or `responsiveRequired.nextAction`. Never start from single-width `tsx` or `componentTsx` for that page or its sections. Only wire links, events and data; never change layout values, collapse responsive arrays, add fixed heights or layout wrappers. `generatedExport.generatedDrift` reports heuristic layout changes against recorded generated styles with source paths and confidence; missing mappings are unverified, not a pass. +When replacing an inlined instance with a shared component, carry over every `instanceOverrides` entry through a new variant or prop, or explicitly report it as unresolved. `DEVUP_CODEGEN_INSTANCE_OVERRIDE` is a required review item; structured instanceOverrides sidecars retain its evidence without metadata comments in TSX. A missing captured value or master is unresolved, never evidence that an override is safe to discard. + +For a confirmed URL with multiple drawn widths, `responsiveTsx` is the deliverable. Use all sibling frameIds in width order from screen-plan status or `responsiveRequired.nextAction`. Never start from single-width `tsx` or `componentTsx` for that page or its sections. Only wire links, events and data; never change layout values, collapse responsive arrays, or add fixed heights or layout wrappers to approximate the design. `generatedExport.generatedDrift` reports heuristic layout changes against recorded generated styles with source paths and confidence; missing mappings are unverified, not a pass. + +Consume captured state exports as well as the baseline. A query/view/error/empty +state must use its captured control and media subtrees, not only different data +inside baseline styling. Preserve supplied empty-thumbnail branches when API image +values are empty. A source state that contradicts its route or another width is +an explicit design-review item; do not silently rewrite its labels or selection. + +A page's expanded default instances and a baseline screenshot do not establish +hover, pressed, focus or disabled styling. Export each used interactive +COMPONENT_SET with `componentTsx`, inspect every captured state, and carry its +source selectors onto the actual semantic link/button/input/select/combobox +boundary. Keep captured instance layout overrides and responsive values intact; +descendant state paint must follow that same interaction owner. Verify these +states in the browser rather than counting retained comments as implementation. +Autogenerated default/hover/active-only axes can become CSS states only when +captured descendants and topology agree. Mixed axes such as effect=mobile remain +explicit variants and need a source-backed viewport/base mapping. Business +selected/checked/open states must not be guessed as mouse-active states. Missing +full-state captures remain an explicit interaction gap; do not invent colors or +spacing from token names. + +When a generated Text or frame becomes a native control, inspect actual computed +border, padding, appearance and box size. Browser defaults can add geometry that +the source never supplied. Reset only those uncaptured native decorations; retain +captured outer-frame borders, padding, typography, background and keyboard focus. +Prefer native semantics on an existing captured node. If an image needs a semantic +link wrapper, preserve the image's captured dimensions and mirror its responsive +visibility and flex/grid participation on the wrapper. Hidden variants must not +leave empty layout items. Verify both geometry and a visible keyboard indicator. + +Asset acquisition can project a source fill into a nested Image/clip/gradient +paint tree. Compare the final delivered subtree, sourceMap and asset-css-map +before interpreting an earlier source-node generatedProps entry as final root +attributes. Do not copy an obsolete background shorthand over the delivered paint +tree. Every affected override still needs exact source-value/paint-order evidence +or an explicit scoped unresolved/not-applicable record; comments alone are not +reflection evidence. ## Project intake comes first @@ -270,6 +307,32 @@ plan and verifies the report, and never runs the browser itself. Its ingest returns a ready `devup_visual_compare` call for each screenshot taken at a drawn width. +Record the actual browser inner width/height, DPR, visual viewport scale and CSS +zoom, plus the reference image's logical frame size and export scale. Figma UI +zoom and the plugin iframe's DPR do not establish native export PNG scale. A long +Figma frame is not evidence of the browser window height. Compare logical geometry +only after calibration; unknown scale or incompatible image dimensions remains +inconclusive. Do not resize, pad or crop screenshots to manufacture a pass. Trace +unexplained large size multiples to CSS units, intrinsic dimensions and container +constraints. Pixel changes from encoding or font rendering are not a percentage +of layout errors; keep runtime, geometry and strict pixel verdicts separate. + +Pin browser launch flags as well: font rasterization can paint grayscale or +subpixel RGB edges at identical captured text bounds. Web-ready WebP q85 assets +also differ from a native frame PNG. Test a suspected encoding contribution with +an explicit lossless original-source request, unchanged TSX and layout, and pinned +source/output dimensions. The control must reproduce its original screenshot; +verify that a replacement image loaded and that hot reload finished before +capturing. Report residual differences without changing the strict threshold or +adjusting layout to compensate for rasterization. + +Auto-layout border calculations must use captured `strokesIncludedInLayout`, +not a blanket subtraction from padding. Figma documents true as border-box +layout: https://developers.figma.com/docs/plugins/api/properties/nodes-strokesincludedinlayout/. +An older snapshot without the flag is not evidence that the source used false. +Recapture the source before diagnosing a border/padding discrepancy; never force +a measured HUG height to hide it. + ## 14. Interactions by role, restrained Hover plus a transition is not a finished interaction. Each interactive @@ -315,12 +378,7 @@ converter bug: geometry allows only half a pixel of rounding, and a mismatch is `code-drift` when the code no longer writes what the export recorded in `.devup/exports/`, or `converter-bug` when it does - reported with a repro in `.devup/converter-bugs/`, never retried or tuned silently. -Merged pages are found per width through `data-figma-node-ids`, dynamic -routes at concrete URLs, and production builds strip the ids unless -`DEVUP_AUDIT=1`. DevupUI owns stripping; do not change app build config -for auditing or register a strip plugin. Node attributes are off by default; -`figmaNodeIds: true` is opt-in only where the export schema supports it. -Missing anchors leave coverage unverified. `status` classifies each failure (runtime-error, +Merged pages are mapped through sidecar element identities and explicit semantic runtime targets. Application TSX contains no Figma identity attributes or metadata comments, even in debug builds. Missing mappings leave coverage unverified; never add a strip plugin. `status` classifies each failure (runtime-error, overflow, layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its node, file and fix hint. Never stop to wait; reorder the work: a decision after confirm goes to diff --git a/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md b/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md index 575025e5..0f0e72d7 100644 --- a/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md +++ b/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md @@ -4,3 +4,4 @@ Build identity: use server.commit/buildId, not version; reconnect if stale. serv 3. For Figma implementation use devup_figma_export tsx, the deliverable. TSX links/events need wiring: resolve interactiveCandidates, devup_ui_validate, report wired/tested counts. deliverable.isFinal describes export readiness. Audit every width and exact layout. Backend completion needs devup_stack_diff zero drift. 4. Request only outputs you read. Before your second call read devup://guide/usage for sizing, verification, batching, assets and delivery. 5. After confirm, extract COMPONENT_SET/COMPONENT first with componentTsx into components/common|layout; record mainComponent id -> file in .devup/component-map.json. Assemble generated sections (frame-root children); audit via file mappings, not DOM Figma attributes. Never hand-write TSX from Figma nodes. Report unsupported exports. +6. Apply developmentContract and behaviorPlan before completion. diff --git a/crates/devup-mcp/src/server/text/projection-annotate_node_ids-b03e6d56.md b/crates/devup-mcp/src/server/text/projection-annotate_node_ids-b03e6d56.md index 63b2f912..6e5f3231 100644 --- a/crates/devup-mcp/src/server/text/projection-annotate_node_ids-b03e6d56.md +++ b/crates/devup-mcp/src/server/text/projection-annotate_node_ids-b03e6d56.md @@ -1 +1 @@ -Optional inert Figma node attributes, off by default in the opt-in export workflow. Use figmaNodeIds true only when the export schema supports it. DevupUI strips audit attributes by default; DEVUP_AUDIT=1 keeps them. Do not change app build config for auditing or register a strip plugin. Missing anchors leave coverage unverified. +Source node and responsive-width identity mappings remain in JSON sidecars. Generated TSX never carries Figma node attributes, including debug exports. Runtime audits use explicit semantic targets; unavailable mappings are unverified, not silently passed. diff --git a/crates/devup-mcp/src/server/text/projection-project_operation-8a156710.md b/crates/devup-mcp/src/server/text/projection-project_operation-8a156710.md index 98203f07..6e5f3231 100644 --- a/crates/devup-mcp/src/server/text/projection-project_operation-8a156710.md +++ b/crates/devup-mcp/src/server/text/projection-project_operation-8a156710.md @@ -1 +1 @@ -data-figma-node-id is the node at the widest width the element is shown at; data-figma-node-ids lists ":" for every drawn width it is shown at, widest first, space-separated, so [data-figma-node-ids~="390:56:78"] selects it at one width. hiddenAt lists the drawn widths it is hidden at. \ No newline at end of file +Source node and responsive-width identity mappings remain in JSON sidecars. Generated TSX never carries Figma node attributes, including debug exports. Runtime audits use explicit semantic targets; unavailable mappings are unverified, not silently passed. diff --git a/crates/devup-mcp/src/server/text/runtime_audit-page-height.md b/crates/devup-mcp/src/server/text/runtime_audit-page-height.md index bcbd5048..b0b6b18b 100644 --- a/crates/devup-mcp/src/server/text/runtime_audit-page-height.md +++ b/crates/devup-mcp/src/server/text/runtime_audit-page-height.md @@ -1 +1 @@ -The page capture is {}x{}px, shorter than the drawn Figma frame at {}x{}px. Render the missing page content before comparing pixels. +The capture is {}x{}px and the Figma frame is {}x{}px. Raster height alone does not establish missing page content; assess calibrated logical geometry and explicitly declared completeness checks. diff --git a/crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8010a061.md b/crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8010a061.md index f13d2fad..dceae638 100644 --- a/crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8010a061.md +++ b/crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8010a061.md @@ -1 +1 @@ -The page capture is {}x{}px but the drawn Figma frame is {}x{}px. Capture the frame-sized region when the page is tall enough; a shorter page is a page-height failure, not a viewport-height problem. +The natural page capture is {}x{}px and the drawn Figma reference is {}x{}px. Strict pixel comparison is inconclusive until capture scope and scale are compatible. Inspect logical geometry and explicit completeness evidence separately; do not force viewport/frame height or resize, pad or crop to manufacture matching dimensions. diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index 4be56a7c..caa2ac3b 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -96,7 +96,8 @@ pub struct FigmaExportInput { pub component_name: Option, #[serde(default)] pub include_diagnostics: bool, - /// Opt in to data-figma-node-id attributes for runtime design audits. + /// Legacy compatibility flag. Identity evidence stays in JSON sidecars; + /// generated application source never receives Figma node attributes. #[serde(default)] pub figma_node_ids: bool, #[serde(default = "default_scope")] @@ -439,6 +440,19 @@ pub struct SectionCoverageInput { /// Asset references in the original generated output, including masks and backgrounds. #[serde(default)] pub asset_urls: Vec, + /// Original export-side source mappings. They verify source structure, not DOM identity. + #[serde(default)] + pub element_mappings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SectionElementMapping { + pub node_id: String, + pub element_index: usize, + pub tag: String, + #[serde(default)] + pub source_file: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] diff --git a/crates/devup-mcp/src/server/visual_compare.rs b/crates/devup-mcp/src/server/visual_compare.rs index 8f9e3325..b83f968f 100644 --- a/crates/devup-mcp/src/server/visual_compare.rs +++ b/crates/devup-mcp/src/server/visual_compare.rs @@ -56,6 +56,7 @@ pub struct VisualEnvironment { pub tsx_resource: Option, pub reference_resource: Option, pub viewport: Option, + pub capture_context: Option, pub theme_path: Option, pub asset_directory: Option, pub font_manifest: Option>, @@ -84,6 +85,17 @@ pub struct Viewport { pub height: u32, pub device_scale_factor: f64, } +/// Actual browser measurements, consumer-attested rather than inferred from PNGs. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CaptureContext { + pub inner_width: u32, + pub inner_height: u32, + pub device_pixel_ratio: f64, + pub visual_viewport_scale: f64, + pub root_css_zoom: f64, + pub body_css_zoom: f64, +} #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct FontPin { pub family: String, @@ -139,12 +151,24 @@ fn assess_environment( if e.actual_png.as_deref() != Some(input.actual.as_str()) { issues.push("actualPng must identify the supplied actual path"); } - if !e - .viewport - .as_ref() - .is_some_and(|v| [v.width, v.height] == dimensions && v.device_scale_factor == 1.0) - { - issues.push("viewport or deviceScaleFactor missing or mismatched; capture reference dimensions at scale 1"); + match &e.viewport { + Some(v) if v.width > 0 && v.height > 0 + && v.device_scale_factor.is_finite() && v.device_scale_factor > 0.0 => { + if v.width != dimensions[0] { + issues.push("CSS viewport width differs from PNG width; capture scope and logical-to-pixel calibration are not established"); + } + if v.device_scale_factor != 1.0 { + issues.push("Non-unit DPR does not prove PNG capture scale. This manifest has no independent logical capture extent/export-scale calibration; strict pixels remain inconclusive without that evidence"); + } + } + _ => issues.push("positive CSS viewport width/height and finite positive deviceScaleFactor are required"), + } + match (&e.capture_context, &e.viewport) { + (Some(c), Some(v)) if c.inner_width == v.width && c.inner_height == v.height + && c.device_pixel_ratio.is_finite() && c.device_pixel_ratio > 0.0 + && c.device_pixel_ratio == v.device_scale_factor + && c.visual_viewport_scale == 1.0 && c.root_css_zoom == 1.0 && c.body_css_zoom == 1.0 => {} + _ => issues.push("Actual inner viewport, DPR, visualViewport.scale and root/body CSS zoom measurements are missing, inconsistent or uncalibrated; strict pixels remain inconclusive"), } if !e .renderer @@ -306,13 +330,23 @@ pub(super) async fn compare( .await.map_err(|_| invalid("PNG comparison could not complete."))? .map_err(invalid)?; if report.status == VisualStatus::InvalidDimensions { - return Err(DevupError::with_details( - ErrorCode::DevupCodegenFailed, - "PNG dimensions mismatch; render actual.png at reference dimensions without scaling.", - false, - json!({"verdict":"inconclusive","environment":{"status":"environment-invalid","issues":["image dimensions mismatch"]}, - "referenceDimensions":report.reference_dimensions,"actualDimensions":report.actual_dimensions,"reference":provenance}), - )); + if asset_comparison { + return Err(DevupError::with_details( + ErrorCode::DevupCodegenFailed, + "Native asset dimensions differ from the captured node export; compatible capture extent and scale are required for asset pixel comparison.", + false, + json!({"comparisonScope":"asset-pixels-against-native-node-export", + "referenceDimensions":report.reference_dimensions,"actualDimensions":report.actual_dimensions, + "reference":provenance}), + )); + } + return Ok(json!({ + "verdict":"inconclusive", "visual":report, + "environment":{"status":"environment-invalid","issues":["Incompatible PNG dimensions; capture scope and logical-to-pixel calibration are not established"],"declaration":input.environment}, + "reference":provenance, + "actual":{"path":actual_target.display_path(),"sha256":actual_hash}, + "note":"Compare logical geometry separately. Do not resize, pad or crop either image to force a match; raster dimensions alone do not establish a layout failure.", + })); } // A reference this server captured from Figma and still holds is pinned // by what the server read, so a consumer need not restate its hash. @@ -427,552 +461,5 @@ pub(super) async fn compare( } #[cfg(test)] -mod tests { - use super::*; - async fn run( - input: Value, - policy: &OutputPolicy, - store: &ArtifactStore, - ) -> Result { - let input = serde_json::from_value(input) - .map_err(|_| invalid("Invalid visual comparison input."))?; - compare(input, policy, store).await - } - use serde_json::json; - - struct Fixture(std::path::PathBuf); - impl Fixture { - fn new() -> Self { - let root = std::env::temp_dir().join(format!( - "visual-tool-{}-{}", - std::process::id(), - rand::random::() - )); - std::fs::create_dir_all(&root).unwrap(); - Self(root) - } - fn png(&self, name: &str, png: &str) { - std::fs::write(self.0.join(name), STANDARD.decode(png).unwrap()).unwrap(); - } - fn policy(&self) -> OutputPolicy { - OutputPolicy::from_roots(vec![self.0.clone()]).unwrap() - } - } - impl Drop for Fixture { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - fn reference_hash() -> String { - hash(&STANDARD.decode(WHITE).unwrap()) - } - fn environment() -> Value { - json!({"schemaVersion":1,"tsxResource":{"uri":"devup://artifact/test/manifest","sha256":"a".repeat(64)},"referenceResource":{"uri":"devup://artifact/test/manifest","sha256":reference_hash()},"viewport":{"width":1,"height":1,"deviceScaleFactor":1},"themePath":"devup.json","assetDirectory":"public/assets","fontManifest":[{"family":"Test","sha256":"b".repeat(64)}],"renderer":{"name":"playwright-chromium","version":"1"},"devupUiVersion":"1","actualPng":"actual.png","environmentStatus":"valid","os":"test","locale":"en-US","timezone":"UTC"}) - } - fn input() -> Value { - json!({"actual":"actual.png","reference":{"path":"reference.png"},"environment":environment()}) - } - const WHITE: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg=="; - const BLACK: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg=="; - const WIDE: &str = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAC0lEQVR4nGP4DwUAI+UH+Yo0eLMAAAAASUVORK5CYII="; - fn fixture() -> Fixture { - let f = Fixture::new(); - f.png("actual.png", WHITE); - f.png("reference.png", WHITE); - f - } - #[tokio::test] - async fn dimension_mismatch_refused() { - let f = fixture(); - f.png("actual.png", WIDE); - let e = run(input(), &f.policy(), &ArtifactStore::default()) - .await - .unwrap_err(); - assert!(e.message.contains("dimensions"), "{e}"); - } - #[tokio::test] - async fn environment_overrides_exact_pixels() { - let f = fixture(); - let mut i = input(); - i["environment"]["environmentStatus"] = json!("environment-invalid"); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["visual"]["passed"], true); - assert_eq!(r["environment"]["status"], "environment-invalid"); - assert_eq!(r["verdict"], "inconclusive"); - } - #[tokio::test] - async fn missing_environment_inconclusive() { - let f = fixture(); - let mut i = input(); - i.as_object_mut().unwrap().remove("environment"); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "inconclusive"); - } - - async fn asset_store(representation: &str, broken_hash: bool) -> (ArtifactStore, String) { - let store = ArtifactStore::default(); - let mut payload = super::super::artifacts::w3_tests::payload(); - let bytes = STANDARD.decode(WHITE).unwrap(); - payload.assets.push(serde_json::from_value(json!({"assetId":"picture:fills:0","nodeId":"picture","field":"fills/0","sourceKind":"image-fill", - "format":"png","scale":1,"status":"exported","mimeType":"image/png","byteLength":bytes.len(),"dataBase64":WHITE, - "sha256":if broken_hash {"0".repeat(64)}else{hash(&bytes)},"renderInfo":{"representation":representation}})).unwrap()); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), - payload, - ) - .await - .unwrap(); - (store, artifact.artifact_id.clone()) - } - - #[tokio::test] - async fn asset_comparison_has_validated_native_reference_and_pixel_scope() { - let f = fixture(); - let (store, id) = asset_store("native-node", false).await; - let i = json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}); - let matched = run(i.clone(), &f.policy(), &store).await.unwrap(); - assert_eq!(matched["verdict"], "pass"); - assert_eq!(matched["reference"]["assetId"], "picture:fills:0"); - assert_eq!(matched["reference"]["referenceSha256"], reference_hash()); - assert_eq!(matched["actual"]["sha256"], reference_hash()); - assert_eq!( - matched["comparisonScope"], - "asset-pixels-against-native-node-export" - ); - f.png("actual.png", BLACK); - let mismatch = run(i.clone(), &f.policy(), &store).await.unwrap(); - assert_eq!(mismatch["verdict"], "fail"); - assert_eq!(mismatch["visual"]["changedRatio"], 1.0); - assert_eq!(mismatch["visual"]["totalPixels"], 1); - f.png("actual.png", WIDE); - assert!( - run(i.clone(), &f.policy(), &store) - .await - .unwrap_err() - .message - .contains("dimensions") - ); - std::fs::write(f.0.join("actual.png"), b"invalid PNG").unwrap(); - assert!( - run(i, &f.policy(), &store) - .await - .unwrap_err() - .message - .contains("Invalid PNG") - ); - } - - #[tokio::test] - async fn webp_actual_uses_native_png_reference_and_keeps_original_byte_hash_and_scope() { - let f = fixture(); - let (store, id) = asset_store("native-node", false).await; - let mut renditions = Vec::new(); - for (png, verdict) in [(WHITE, "pass"), (BLACK, "fail")] { - let request = devup_mcp_figma::AssetRequest { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - asset_id: "picture:fills:0".into(), - node_id: "picture".into(), - field: "fills/0".into(), - image_hash: Some("source".into()), - format: devup_mcp_figma::AssetFormat::Webp, - scale: 1.0, - render_mode: devup_mcp_figma::AssetRenderMode::Node, - quality: Some(80), - lossless: Some(true), - }; - let asset = devup_mcp_figma::exported_asset_from_bytes( - &request, - &STANDARD.decode(png).unwrap(), - ) - .unwrap(); - let bytes = STANDARD.decode(asset.data_base64.unwrap()).unwrap(); - renditions.push(bytes.clone()); - std::fs::write(f.0.join("actual.webp"), &bytes).unwrap(); - let value=run(json!({"actual":"actual.webp","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}),&f.policy(),&store).await.unwrap(); - assert_eq!(value["verdict"], verdict); - assert_eq!(value["actual"]["mimeType"], "image/webp"); - assert_eq!(value["actual"]["sha256"], hash(&bytes)); - assert_eq!( - value["comparisonScope"], - "asset-pixels-against-native-node-export" - ); - assert_eq!( - value["reference"]["renderInfo"]["representation"], - "native-node" - ); - } - let error = run( - json!({"actual":"actual.webp","reference":{"path":"reference.png"}}), - &f.policy(), - &store, - ) - .await - .unwrap_err(); - assert!(error.message.contains("Page/card screenshots")); - fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec { - let mut out = kind.to_vec(); - out.extend_from_slice(&(data.len() as u32).to_le_bytes()); - out.extend_from_slice(data); - if !data.len().is_multiple_of(2) { - out.push(0); - } - out - } - let mut animation = b"WEBP".to_vec(); - animation.extend(chunk(b"VP8X", &[2, 0, 0, 0, 0, 0, 0, 0, 0, 0])); - animation.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); - for encoded in renditions { - let mut frame = vec![0; 16]; - frame[12] = 100; - frame.extend_from_slice(&encoded[12..]); - animation.extend(chunk(b"ANMF", &frame)); - } - let mut encoded = b"RIFF".to_vec(); - encoded.extend_from_slice(&(animation.len() as u32).to_le_bytes()); - encoded.extend(animation); - std::fs::write(f.0.join("animated.webp"), encoded).unwrap(); - let error=run(json!({"actual":"animated.webp","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}),&f.policy(),&store).await.unwrap_err(); - assert!(error.message.contains("Animated WebP"), "{}", error.message); - } - - #[tokio::test] - async fn asset_comparison_rejects_missing_raw_unknown_and_corrupt_reference() { - let f = fixture(); - for (representation, broken) in [ - ("original-fill", false), - ("unknown", false), - ("not-exported", false), - ("native-node", true), - ] { - let (store, id) = asset_store(representation, broken).await; - let i = json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"picture:fills:0"}}); - assert!(run(i, &f.policy(), &store).await.is_err()); - let missing = - json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"missing"}}); - assert!( - run(missing, &f.policy(), &store) - .await - .unwrap_err() - .message - .contains("not captured") - ); - } - } - #[tokio::test] - async fn threshold_pass_and_fail() { - let f = fixture(); - f.png("actual.png", BLACK); - let mut i = input(); - i["threshold"] = json!(1.0); - let r = run(i.clone(), &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "pass"); - i["threshold"] = json!(0.005); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "fail"); - assert_eq!(r["visual"]["changedRatio"], 1.0); - assert!(r.get("diffPath").is_none()); - } - #[tokio::test] - async fn a_diff_path_writes_the_diff_png_as_evidence() { - let f = fixture(); - f.png("actual.png", BLACK); - let mut i = input(); - i["threshold"] = json!(0.005); - i["diffPath"] = json!(f.0.join("evidence/diff.png")); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "fail"); - let written = r["diffPath"].as_str().unwrap(); - assert!(written.ends_with("diff.png"), "{r}"); - assert!(f.0.join("evidence/diff.png").exists()); - assert!(r.get("diffPng").is_none(), "includeDiff was not asked for"); - } - #[tokio::test] - async fn out_of_root_actual_rejected() { - let f = fixture(); - let mut i = input(); - i["actual"] = json!(std::env::temp_dir().join("outside.png")); - let e = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap_err(); - assert!(e.message.contains("allowed root"), "{e}"); - } - #[tokio::test] - async fn artifact_without_reference_has_recovery() { - let f = fixture(); - let store = ArtifactStore::default(); - let a = store - .insert( - super::super::artifacts::ArtifactRequestKey::from_collection( - &super::super::artifacts::w3_tests::request(), - ), - super::super::artifacts::w3_tests::payload(), - ) - .await - .unwrap(); - let mut i = input(); - i["reference"] = json!({"artifactId":a.artifact_id}); - let e = run(i, &f.policy(), &store).await.unwrap_err(); - assert!( - e.message.contains("referencePng") && e.message.contains("devup_figma_export"), - "{e}" - ); - } - #[tokio::test] - async fn diff_delivered_as_readable_resource() { - let f = fixture(); - let store = ArtifactStore::default(); - let mut i = input(); - i["delivery"] = json!("resource"); - i["includeDiff"] = json!(true); - let r = run(i, &f.policy(), &store).await.unwrap(); - assert!(r.get("diffPng").is_none()); - let m = &r["resources"][0]; - assert!(m["uri"].as_str().unwrap().starts_with("devup://artifact/")); - let manifests = store.output_manifests().await; - let bytes = store - .read_output_chunk(&manifests[0].artifact_id, &manifests[0].output_id, 0) - .await - .unwrap(); - assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n")); - } - - #[tokio::test] - async fn incomplete_manifest_inconclusive() { - let f = fixture(); - for field in [ - "renderer", - "fontManifest", - "viewport", - "tsxResource", - "referenceResource", - "themePath", - "assetDirectory", - "os", - "locale", - "timezone", - "devupUiVersion", - ] { - let mut i = input(); - i["environment"].as_object_mut().unwrap().remove(field); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "inconclusive", "{field}"); - } - } - #[tokio::test] - async fn declared_viewport_mismatch_inconclusive() { - let f = fixture(); - let mut i = input(); - i["environment"]["viewport"]["width"] = json!(2); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "inconclusive"); - assert_eq!(r["visual"]["passed"], true); - } - #[tokio::test] - async fn reference_requires_exactly_one_selector() { - for reference in [json!({}), json!({"path":"reference.png","artifactId":"id"})] { - let mut i = input(); - i["reference"] = reference; - assert!(serde_json::from_value::(i).is_err()); - } - } - #[tokio::test] - async fn traversal_and_ads_rejected() { - let f = fixture(); - for path in [ - "../actual.png", - "actual.png:stream", - "Z:/actual.png", - r"\\server\share\actual.png", - ] { - let mut i = input(); - i["actual"] = json!(path); - let e = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap_err(); - assert!(!e.message.contains("not implemented")); - } - } - - #[test] - fn bounded_read_preserves_png_bytes() { - let f = fixture(); - let bytes = f - .policy() - .resolve("actual.png") - .unwrap() - .read_bounded(1024) - .unwrap(); - assert_eq!(bytes, STANDARD.decode(WHITE).unwrap()); - } - #[test] - fn bounded_read_refuses_oversize() { - let f = fixture(); - let e = f - .policy() - .resolve("actual.png") - .unwrap() - .read_bounded(4) - .unwrap_err(); - assert_eq!(e.code, ErrorCode::DevupFigmaResponseTooLarge); - } - #[test] - fn symlink_escape_refused_before_read() { - let f = fixture(); - let outside = fixture(); - #[cfg(windows)] - // Like output_policy's directory-symlink test, this requires the host - // to allow symlink creation. Windows reports missing privilege as 1314 - // (ErrorKind::Uncategorized on some toolchains), not PermissionDenied. - if let Err(error) = std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")) { - if error.raw_os_error() == Some(1314) { - eprintln!("symlink escape test unavailable: Windows symlink privilege is missing"); - return; - } - panic!("could not create test symlink: {error}"); - } - #[cfg(unix)] - std::os::unix::fs::symlink(&outside.0, f.0.join("escape")).unwrap(); - assert!( - f.policy() - .resolve("escape/actual.png") - .and_then(|t| t.read_bounded(1024)) - .is_err() - ); - // A leaf link must also be rejected, including when swapped after resolve. - let target = f.policy().resolve("late.png").unwrap(); - #[cfg(windows)] - std::os::windows::fs::symlink_file(outside.0.join("actual.png"), f.0.join("late.png")) - .unwrap(); - #[cfg(unix)] - std::os::unix::fs::symlink(outside.0.join("actual.png"), f.0.join("late.png")).unwrap(); - assert!(target.read_bounded(1024).is_err()); - } - - #[tokio::test] - async fn artifact_reference_preserves_provenance() { - let f = fixture(); - let store = ArtifactStore::default(); - let mut payload = super::super::artifacts::w3_tests::payload(); - payload.reference_png = Some(devup_mcp_figma::ReferencePng { - mime_type: "image/png".into(), - data_base64: WHITE.into(), - byte_length: STANDARD.decode(WHITE).unwrap().len(), - sha256: reference_hash(), - note: None, - }); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), - payload, - ) - .await - .unwrap(); - let mut i = input(); - i["reference"] = json!({"artifactId":artifact.artifact_id}); - let result = run(i, &f.policy(), &store).await.unwrap(); - assert_eq!(result["verdict"], "pass"); - assert_eq!(result["reference"]["artifactId"], artifact.artifact_id); - assert_eq!(result["reference"]["referenceSha256"], reference_hash()); - assert_eq!(result["visual"]["maxChangedRatio"], 0.005); - } - /// The server holds the reference it captured, so it pins it itself; a - /// reference path is the consumer's file and still needs their pin. - #[tokio::test] - async fn a_held_reference_is_pinned_by_the_server() { - let f = fixture(); - let store = ArtifactStore::default(); - let mut payload = super::super::artifacts::w3_tests::payload(); - payload.reference_png = Some(devup_mcp_figma::ReferencePng { - mime_type: "image/png".into(), - data_base64: WHITE.into(), - byte_length: STANDARD.decode(WHITE).unwrap().len(), - sha256: reference_hash(), - note: None, - }); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), - payload, - ) - .await - .unwrap(); - let mut i = input(); - i["reference"] = json!({"artifactId":artifact.artifact_id}); - i["environment"] - .as_object_mut() - .unwrap() - .remove("referenceResource"); - let result = run(i, &f.policy(), &store).await.unwrap(); - assert_eq!(result["verdict"], "pass", "{result}"); - assert_eq!( - result["environment"]["filledByServer"], - json!(["referenceResource"]) - ); - - let mut by_path = input(); - by_path["environment"] - .as_object_mut() - .unwrap() - .remove("referenceResource"); - let result = run(by_path, &f.policy(), &store).await.unwrap(); - assert_eq!(result["verdict"], "inconclusive"); - assert!(result["environment"].get("filledByServer").is_none()); - } - - #[tokio::test] - async fn renderer_and_reference_hash_mismatch_override_pass() { - let f = fixture(); - let mut i = input(); - i["environment"]["expectedRenderer"] = - json!({"name":"playwright-chromium","version":"different"}); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "inconclusive"); - assert_eq!(r["visual"]["passed"], true); - let mut i = input(); - i["environment"]["referenceResource"]["sha256"] = json!("c".repeat(64)); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "inconclusive"); - } - #[tokio::test] - async fn inline_diff_and_omitted_diff_follow_request() { - let f = fixture(); - let r = run(input(), &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert!(r.get("diffPng").is_none()); - let mut i = input(); - i["includeDiff"] = json!(true); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert!( - STANDARD - .decode(r["diffPng"]["dataBase64"].as_str().unwrap()) - .unwrap() - .starts_with(b"\x89PNG") - ); - assert!(r.get("resources").is_none()); - } -} +#[path = "visual_compare_tests.rs"] +mod tests; diff --git a/crates/devup-mcp/src/server/visual_compare_calibration_tests.rs b/crates/devup-mcp/src/server/visual_compare_calibration_tests.rs new file mode 100644 index 00000000..0d850134 --- /dev/null +++ b/crates/devup-mcp/src/server/visual_compare_calibration_tests.rs @@ -0,0 +1,42 @@ +#[tokio::test] +async fn natural_full_page_height_is_independent_of_900px_viewport() { + let f = fixture(); + let image = image::RgbaImage::from_pixel(1440, 1200, image::Rgba([255, 255, 255, 255])); + image.save(f.0.join("reference.png")).unwrap(); + image.save(f.0.join("actual.png")).unwrap(); + let mut i = input(); + i["environment"]["referenceResource"]["sha256"] = + json!(hash(&std::fs::read(f.0.join("reference.png")).unwrap())); + i["environment"]["viewport"] = json!({"width":1440,"height":900,"deviceScaleFactor":1}); + i["environment"]["captureContext"] = json!({"innerWidth":1440,"innerHeight":900,"devicePixelRatio":1,"visualViewportScale":1,"rootCssZoom":1,"bodyCssZoom":1}); + let result = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(result["verdict"], "pass", "{result}"); + assert_eq!( + result["environment"]["declaration"]["viewport"]["height"], + 900 + ); +} + +#[tokio::test] +async fn dpr_and_zoom_require_calibration_even_when_pixels_match() { + let f = fixture(); + for (field, value) in [ + ("devicePixelRatio", 2), + ("visualViewportScale", 2), + ("rootCssZoom", 2), + ("bodyCssZoom", 2), + ] { + let mut i = input(); + i["environment"]["captureContext"][field] = json!(value); + if field == "devicePixelRatio" { + i["environment"]["viewport"]["deviceScaleFactor"] = json!(value); + } + let result = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(result["verdict"], "inconclusive", "{field}: {result}"); + assert_eq!(result["visual"]["passed"], true); + } +} diff --git a/crates/devup-mcp/src/server/visual_compare_tests.rs b/crates/devup-mcp/src/server/visual_compare_tests.rs new file mode 100644 index 00000000..9b08c316 --- /dev/null +++ b/crates/devup-mcp/src/server/visual_compare_tests.rs @@ -0,0 +1,553 @@ +use super::*; +async fn run( + input: Value, + policy: &OutputPolicy, + store: &ArtifactStore, +) -> Result { + let input = + serde_json::from_value(input).map_err(|_| invalid("Invalid visual comparison input."))?; + compare(input, policy, store).await +} +use serde_json::json; + +struct Fixture(std::path::PathBuf); +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "visual-tool-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(&root).unwrap(); + Self(root) + } + fn png(&self, name: &str, png: &str) { + std::fs::write(self.0.join(name), STANDARD.decode(png).unwrap()).unwrap(); + } + fn policy(&self) -> OutputPolicy { + OutputPolicy::from_roots(vec![self.0.clone()]).unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn reference_hash() -> String { + hash(&STANDARD.decode(WHITE).unwrap()) +} +fn environment() -> Value { + json!({"schemaVersion":1,"tsxResource":{"uri":"devup://artifact/test/manifest","sha256":"a".repeat(64)},"referenceResource":{"uri":"devup://artifact/test/manifest","sha256":reference_hash()},"viewport":{"width":1,"height":1,"deviceScaleFactor":1},"captureContext":{"innerWidth":1,"innerHeight":1,"devicePixelRatio":1,"visualViewportScale":1,"rootCssZoom":1,"bodyCssZoom":1},"themePath":"devup.json","assetDirectory":"public/assets","fontManifest":[{"family":"Test","sha256":"b".repeat(64)}],"renderer":{"name":"playwright-chromium","version":"1"},"devupUiVersion":"1","actualPng":"actual.png","environmentStatus":"valid","os":"test","locale":"en-US","timezone":"UTC"}) +} +fn input() -> Value { + json!({"actual":"actual.png","reference":{"path":"reference.png"},"environment":environment()}) +} +const WHITE: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg=="; +const BLACK: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg=="; +const WIDE: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAC0lEQVR4nGP4DwUAI+UH+Yo0eLMAAAAASUVORK5CYII="; +fn fixture() -> Fixture { + let f = Fixture::new(); + f.png("actual.png", WHITE); + f.png("reference.png", WHITE); + f +} +#[tokio::test] +async fn dimension_mismatch_is_inconclusive_not_a_codegen_failure() { + let f = fixture(); + f.png("actual.png", WIDE); + let result = run(input(), &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(result["verdict"], "inconclusive"); + assert_eq!(result["visual"]["status"], "invalid-dimensions"); + assert!( + result["visual"]["changedRatio"].is_null(), + "no pixel mismatch percentage was measured" + ); +} +#[tokio::test] +async fn environment_overrides_exact_pixels() { + let f = fixture(); + let mut i = input(); + i["environment"]["environmentStatus"] = json!("environment-invalid"); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["visual"]["passed"], true); + assert_eq!(r["environment"]["status"], "environment-invalid"); + assert_eq!(r["verdict"], "inconclusive"); +} +#[tokio::test] +async fn missing_environment_inconclusive() { + let f = fixture(); + let mut i = input(); + i.as_object_mut().unwrap().remove("environment"); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "inconclusive"); +} + +async fn asset_store(representation: &str, broken_hash: bool) -> (ArtifactStore, String) { + let store = ArtifactStore::default(); + let mut payload = super::super::artifacts::w3_tests::payload(); + let bytes = STANDARD.decode(WHITE).unwrap(); + payload.assets.push(serde_json::from_value(json!({"assetId":"picture:fills:0","nodeId":"picture","field":"fills/0","sourceKind":"image-fill", + "format":"png","scale":1,"status":"exported","mimeType":"image/png","byteLength":bytes.len(),"dataBase64":WHITE, + "sha256":if broken_hash {"0".repeat(64)}else{hash(&bytes)},"renderInfo":{"representation":representation}})).unwrap()); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), + payload, + ) + .await + .unwrap(); + (store, artifact.artifact_id.clone()) +} + +#[tokio::test] +async fn asset_comparison_has_validated_native_reference_and_pixel_scope() { + let f = fixture(); + let (store, id) = asset_store("native-node", false).await; + let i = json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}); + let matched = run(i.clone(), &f.policy(), &store).await.unwrap(); + assert_eq!(matched["verdict"], "pass"); + assert_eq!(matched["reference"]["assetId"], "picture:fills:0"); + assert_eq!(matched["reference"]["referenceSha256"], reference_hash()); + assert_eq!(matched["actual"]["sha256"], reference_hash()); + assert_eq!( + matched["comparisonScope"], + "asset-pixels-against-native-node-export" + ); + f.png("actual.png", BLACK); + let mismatch = run(i.clone(), &f.policy(), &store).await.unwrap(); + assert_eq!(mismatch["verdict"], "fail"); + assert_eq!(mismatch["visual"]["changedRatio"], 1.0); + assert_eq!(mismatch["visual"]["totalPixels"], 1); + f.png("actual.png", WIDE); + assert!( + run(i.clone(), &f.policy(), &store) + .await + .unwrap_err() + .message + .contains("dimensions") + ); + std::fs::write(f.0.join("actual.png"), b"invalid PNG").unwrap(); + assert!( + run(i, &f.policy(), &store) + .await + .unwrap_err() + .message + .contains("Invalid PNG") + ); +} + +#[tokio::test] +async fn webp_actual_uses_native_png_reference_and_keeps_original_byte_hash_and_scope() { + let f = fixture(); + let (store, id) = asset_store("native-node", false).await; + let mut renditions = Vec::new(); + for (png, verdict) in [(WHITE, "pass"), (BLACK, "fail")] { + let request = devup_mcp_figma::AssetRequest { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + asset_id: "picture:fills:0".into(), + node_id: "picture".into(), + field: "fills/0".into(), + image_hash: Some("source".into()), + format: devup_mcp_figma::AssetFormat::Webp, + scale: 1.0, + render_mode: devup_mcp_figma::AssetRenderMode::Node, + quality: Some(80), + lossless: Some(true), + }; + let asset = + devup_mcp_figma::exported_asset_from_bytes(&request, &STANDARD.decode(png).unwrap()) + .unwrap(); + let bytes = STANDARD.decode(asset.data_base64.unwrap()).unwrap(); + renditions.push(bytes.clone()); + std::fs::write(f.0.join("actual.webp"), &bytes).unwrap(); + let value=run(json!({"actual":"actual.webp","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}),&f.policy(),&store).await.unwrap(); + assert_eq!(value["verdict"], verdict); + assert_eq!(value["actual"]["mimeType"], "image/webp"); + assert_eq!(value["actual"]["sha256"], hash(&bytes)); + assert_eq!( + value["comparisonScope"], + "asset-pixels-against-native-node-export" + ); + assert_eq!( + value["reference"]["renderInfo"]["representation"], + "native-node" + ); + } + let error = run( + json!({"actual":"actual.webp","reference":{"path":"reference.png"}}), + &f.policy(), + &store, + ) + .await + .unwrap_err(); + assert!(error.message.contains("Page/card screenshots")); + fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut out = kind.to_vec(); + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(data); + if !data.len().is_multiple_of(2) { + out.push(0); + } + out + } + let mut animation = b"WEBP".to_vec(); + animation.extend(chunk(b"VP8X", &[2, 0, 0, 0, 0, 0, 0, 0, 0, 0])); + animation.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); + for encoded in renditions { + let mut frame = vec![0; 16]; + frame[12] = 100; + frame.extend_from_slice(&encoded[12..]); + animation.extend(chunk(b"ANMF", &frame)); + } + let mut encoded = b"RIFF".to_vec(); + encoded.extend_from_slice(&(animation.len() as u32).to_le_bytes()); + encoded.extend(animation); + std::fs::write(f.0.join("animated.webp"), encoded).unwrap(); + let error=run(json!({"actual":"animated.webp","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}),&f.policy(),&store).await.unwrap_err(); + assert!(error.message.contains("Animated WebP"), "{}", error.message); +} + +#[tokio::test] +async fn asset_comparison_rejects_missing_raw_unknown_and_corrupt_reference() { + let f = fixture(); + for (representation, broken) in [ + ("original-fill", false), + ("unknown", false), + ("not-exported", false), + ("native-node", true), + ] { + let (store, id) = asset_store(representation, broken).await; + let i = json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"picture:fills:0"}}); + assert!(run(i, &f.policy(), &store).await.is_err()); + let missing = + json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"missing"}}); + assert!( + run(missing, &f.policy(), &store) + .await + .unwrap_err() + .message + .contains("not captured") + ); + } +} +#[tokio::test] +async fn threshold_pass_and_fail() { + let f = fixture(); + f.png("actual.png", BLACK); + let mut i = input(); + i["threshold"] = json!(1.0); + let r = run(i.clone(), &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "pass"); + i["threshold"] = json!(0.005); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "fail"); + assert_eq!(r["visual"]["changedRatio"], 1.0); + assert!(r.get("diffPath").is_none()); +} +#[tokio::test] +async fn a_diff_path_writes_the_diff_png_as_evidence() { + let f = fixture(); + f.png("actual.png", BLACK); + let mut i = input(); + i["threshold"] = json!(0.005); + i["diffPath"] = json!(f.0.join("evidence/diff.png")); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "fail"); + let written = r["diffPath"].as_str().unwrap(); + assert!(written.ends_with("diff.png"), "{r}"); + assert!(f.0.join("evidence/diff.png").exists()); + assert!(r.get("diffPng").is_none(), "includeDiff was not asked for"); +} +#[tokio::test] +async fn out_of_root_actual_rejected() { + let f = fixture(); + let mut i = input(); + i["actual"] = json!(std::env::temp_dir().join("outside.png")); + let e = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap_err(); + assert!(e.message.contains("allowed root"), "{e}"); +} +#[tokio::test] +async fn artifact_without_reference_has_recovery() { + let f = fixture(); + let store = ArtifactStore::default(); + let a = store + .insert( + super::super::artifacts::ArtifactRequestKey::from_collection( + &super::super::artifacts::w3_tests::request(), + ), + super::super::artifacts::w3_tests::payload(), + ) + .await + .unwrap(); + let mut i = input(); + i["reference"] = json!({"artifactId":a.artifact_id}); + let e = run(i, &f.policy(), &store).await.unwrap_err(); + assert!( + e.message.contains("referencePng") && e.message.contains("devup_figma_export"), + "{e}" + ); +} +#[tokio::test] +async fn diff_delivered_as_readable_resource() { + let f = fixture(); + let store = ArtifactStore::default(); + let mut i = input(); + i["delivery"] = json!("resource"); + i["includeDiff"] = json!(true); + let r = run(i, &f.policy(), &store).await.unwrap(); + assert!(r.get("diffPng").is_none()); + let m = &r["resources"][0]; + assert!(m["uri"].as_str().unwrap().starts_with("devup://artifact/")); + let manifests = store.output_manifests().await; + let bytes = store + .read_output_chunk(&manifests[0].artifact_id, &manifests[0].output_id, 0) + .await + .unwrap(); + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n")); +} + +#[tokio::test] +async fn incomplete_manifest_inconclusive() { + let f = fixture(); + for field in [ + "renderer", + "fontManifest", + "viewport", + "captureContext", + "tsxResource", + "referenceResource", + "themePath", + "assetDirectory", + "os", + "locale", + "timezone", + "devupUiVersion", + ] { + let mut i = input(); + i["environment"].as_object_mut().unwrap().remove(field); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "inconclusive", "{field}"); + } +} +#[tokio::test] +async fn declared_viewport_mismatch_inconclusive() { + let f = fixture(); + let mut i = input(); + i["environment"]["viewport"]["width"] = json!(2); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "inconclusive"); + assert_eq!(r["visual"]["passed"], true); +} +include!("visual_compare_calibration_tests.rs"); + +#[tokio::test] +async fn reference_requires_exactly_one_selector() { + for reference in [json!({}), json!({"path":"reference.png","artifactId":"id"})] { + let mut i = input(); + i["reference"] = reference; + assert!(serde_json::from_value::(i).is_err()); + } +} +#[tokio::test] +async fn traversal_and_ads_rejected() { + let f = fixture(); + for path in [ + "../actual.png", + "actual.png:stream", + "Z:/actual.png", + r"\\server\share\actual.png", + ] { + let mut i = input(); + i["actual"] = json!(path); + let e = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap_err(); + assert!(!e.message.contains("not implemented")); + } +} + +#[test] +fn bounded_read_preserves_png_bytes() { + let f = fixture(); + let bytes = f + .policy() + .resolve("actual.png") + .unwrap() + .read_bounded(1024) + .unwrap(); + assert_eq!(bytes, STANDARD.decode(WHITE).unwrap()); +} +#[test] +fn bounded_read_refuses_oversize() { + let f = fixture(); + let e = f + .policy() + .resolve("actual.png") + .unwrap() + .read_bounded(4) + .unwrap_err(); + assert_eq!(e.code, ErrorCode::DevupFigmaResponseTooLarge); +} +#[test] +fn symlink_escape_refused_before_read() { + let f = fixture(); + let outside = fixture(); + #[cfg(windows)] + // Like output_policy's directory-symlink test, this requires the host + // to allow symlink creation. Windows reports missing privilege as 1314 + // (ErrorKind::Uncategorized on some toolchains), not PermissionDenied. + if let Err(error) = std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")) { + if error.raw_os_error() == Some(1314) { + eprintln!("symlink escape test unavailable: Windows symlink privilege is missing"); + return; + } + panic!("could not create test symlink: {error}"); + } + #[cfg(unix)] + std::os::unix::fs::symlink(&outside.0, f.0.join("escape")).unwrap(); + assert!( + f.policy() + .resolve("escape/actual.png") + .and_then(|t| t.read_bounded(1024)) + .is_err() + ); + // A leaf link must also be rejected, including when swapped after resolve. + let target = f.policy().resolve("late.png").unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_file(outside.0.join("actual.png"), f.0.join("late.png")).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(outside.0.join("actual.png"), f.0.join("late.png")).unwrap(); + assert!(target.read_bounded(1024).is_err()); +} + +#[tokio::test] +async fn artifact_reference_preserves_provenance() { + let f = fixture(); + let store = ArtifactStore::default(); + let mut payload = super::super::artifacts::w3_tests::payload(); + payload.reference_png = Some(devup_mcp_figma::ReferencePng { + mime_type: "image/png".into(), + data_base64: WHITE.into(), + byte_length: STANDARD.decode(WHITE).unwrap().len(), + sha256: reference_hash(), + note: None, + }); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), + payload, + ) + .await + .unwrap(); + let mut i = input(); + i["reference"] = json!({"artifactId":artifact.artifact_id}); + let result = run(i, &f.policy(), &store).await.unwrap(); + assert_eq!(result["verdict"], "pass"); + assert_eq!(result["reference"]["artifactId"], artifact.artifact_id); + assert_eq!(result["reference"]["referenceSha256"], reference_hash()); + assert_eq!(result["visual"]["maxChangedRatio"], 0.005); +} +/// The server holds the reference it captured, so it pins it itself; a +/// reference path is the consumer's file and still needs their pin. +#[tokio::test] +async fn a_held_reference_is_pinned_by_the_server() { + let f = fixture(); + let store = ArtifactStore::default(); + let mut payload = super::super::artifacts::w3_tests::payload(); + payload.reference_png = Some(devup_mcp_figma::ReferencePng { + mime_type: "image/png".into(), + data_base64: WHITE.into(), + byte_length: STANDARD.decode(WHITE).unwrap().len(), + sha256: reference_hash(), + note: None, + }); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), + payload, + ) + .await + .unwrap(); + let mut i = input(); + i["reference"] = json!({"artifactId":artifact.artifact_id}); + i["environment"] + .as_object_mut() + .unwrap() + .remove("referenceResource"); + let result = run(i, &f.policy(), &store).await.unwrap(); + assert_eq!(result["verdict"], "pass", "{result}"); + assert_eq!( + result["environment"]["filledByServer"], + json!(["referenceResource"]) + ); + + let mut by_path = input(); + by_path["environment"] + .as_object_mut() + .unwrap() + .remove("referenceResource"); + let result = run(by_path, &f.policy(), &store).await.unwrap(); + assert_eq!(result["verdict"], "inconclusive"); + assert!(result["environment"].get("filledByServer").is_none()); +} + +#[tokio::test] +async fn renderer_and_reference_hash_mismatch_override_pass() { + let f = fixture(); + let mut i = input(); + i["environment"]["expectedRenderer"] = + json!({"name":"playwright-chromium","version":"different"}); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "inconclusive"); + assert_eq!(r["visual"]["passed"], true); + let mut i = input(); + i["environment"]["referenceResource"]["sha256"] = json!("c".repeat(64)); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "inconclusive"); +} +#[tokio::test] +async fn inline_diff_and_omitted_diff_follow_request() { + let f = fixture(); + let r = run(input(), &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert!(r.get("diffPng").is_none()); + let mut i = input(); + i["includeDiff"] = json!(true); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert!( + STANDARD + .decode(r["diffPng"]["dataBase64"].as_str().unwrap()) + .unwrap() + .starts_with(b"\x89PNG") + ); + assert!(r.get("resources").is_none()); +} diff --git a/crates/devup-mcp/tests/composite_export/common.rs b/crates/devup-mcp/tests/composite_export/common.rs index 13eabdc3..e5f40590 100644 --- a/crates/devup-mcp/tests/composite_export/common.rs +++ b/crates/devup-mcp/tests/composite_export/common.rs @@ -664,9 +664,14 @@ async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> ); assert_eq!( summaries.len(), - 3, - "asset manifest, image-fill audit and one binary asset" + 5, + "asset manifest, image-fill audit, asset registry, development contract and one binary asset" ); + assert!(summaries.iter().any(|r| r["name"] == "asset-registry.json")); + let contract_resource = summaries.iter().find(|r| r["name"] == "development-contract.json") + .expect("the contract accompanies asset-only resource delivery"); + let contract: Value = serde_json::from_slice(&read_resource_bytes(&client, contract_resource["uri"].as_str().unwrap()).await?)?; + assert_eq!(contract["alwaysApplies"], true); assert!( summaries .iter() @@ -935,4 +940,4 @@ async fn strict_export_rejects_partial_payload_before_projection() -> anyhow::Re client.cancel().await?; task.await??; Ok(()) -} \ No newline at end of file +} diff --git a/crates/devup-mcp/tests/composite_export/strict_tsx_export_rejects_lossy_projection.rs b/crates/devup-mcp/tests/composite_export/strict_tsx_export_rejects_lossy_projection.rs index c5ed4067..dde1662c 100644 --- a/crates/devup-mcp/tests/composite_export/strict_tsx_export_rejects_lossy_projection.rs +++ b/crates/devup-mcp/tests/composite_export/strict_tsx_export_rejects_lossy_projection.rs @@ -329,6 +329,21 @@ async fn r3_assert_resume(timeout_first: bool) -> anyhow::Result<()> { let mut paused = call(&client, "devup_figma_export", args.clone()).await?; if timeout_first { assert_eq!(paused["assetJob"]["state"], "running"); + // The initial one-second reply can beat scheduling the second read. + // Start its deadline before advancing the paused clock; advancing + // first otherwise schedules the read after the entire timeout window. + for _ in 0..100 { + if upstream.second_asset.load(Ordering::SeqCst) > 0 { + break; + } + tokio::task::yield_now().await; + paused = call( + &client, + "devup_figma_export", + json!({"jobId":paused["assetJob"]["jobId"]}), + ).await?; + } + assert_eq!(upstream.second_asset.load(Ordering::SeqCst), 1); let timeout_ms = paused["exportJob"]["calls"] .as_array() .unwrap() diff --git a/crates/devup-mcp/tests/downstream_integration.rs b/crates/devup-mcp/tests/downstream_integration.rs index b1e9247d..f4d058ce 100644 --- a/crates/devup-mcp/tests/downstream_integration.rs +++ b/crates/devup-mcp/tests/downstream_integration.rs @@ -385,7 +385,10 @@ async fn jsx_css_whitespace_loss_is_exposed_without_optional_diagnostics() -> an assert_eq!(issue["property"], "characters"); assert_eq!(issue["fidelityImpact"], "lossy"); assert_eq!(result["quality"]["projection"], "lossy"); - assert!(result["tsx"].as_str().unwrap().contains("a b")); + assert_eq!(issue["details"]["originalValue"], "a b"); + // The JSX string expression preserves both source spaces; normal CSS + // still collapses them, which is the visual loss this diagnostic reports. + assert!(result["tsx"].as_str().unwrap().contains(r#"a{" "}b"#)); Ok(()) } diff --git a/crates/devup-mcp/tests/export_project_theme.rs b/crates/devup-mcp/tests/export_project_theme.rs index c04f2b05..fd09381d 100644 --- a/crates/devup-mcp/tests/export_project_theme.rs +++ b/crates/devup-mcp/tests/export_project_theme.rs @@ -294,7 +294,8 @@ async fn an_export_confirms_a_project_that_already_has_the_token() -> anyhow::Re /// Node identities are explicitly opted into, independently of project config. #[tokio::test] -async fn an_export_into_a_project_marks_its_elements_with_the_figma_node() -> anyhow::Result<()> { +async fn an_export_into_a_project_keeps_node_evidence_outside_application_source() +-> anyhow::Result<()> { let root = std::env::temp_dir().join(format!("devup-node-ids-{}", std::process::id())); fs::create_dir_all(root.join(".devup"))?; fs::write(root.join("package.json"), r#"{"name":"fixture"}"#)?; @@ -303,7 +304,7 @@ async fn an_export_into_a_project_marks_its_elements_with_the_figma_node() -> an export(json!({"url": URL, "outputs": ["tsx"], "projectRoot": root.to_string_lossy(), "figmaNodeIds": true})) .await?; assert!( - fs::read_to_string(result["outputPaths"]["tsx"].as_str().unwrap())?.contains(marked), + !fs::read_to_string(result["outputPaths"]["tsx"].as_str().unwrap())?.contains(marked), "{}", result["tsx"] ); @@ -348,7 +349,10 @@ async fn an_export_into_a_project_marks_its_elements_with_the_figma_node() -> an .contains("data-figma-node-id") ); assert!(result.get("figmaNodeIds").is_none()); - assert!(result["outputResults"]["tsx"]["sectionCoverage"].is_null()); + assert_eq!( + result["outputResults"]["tsx"]["sectionCoverage"]["identityStrategy"], + "source-map-sidecar" + ); let result = export(json!({"url": URL, "outputs": ["tsx"]})).await?; assert!( diff --git a/crates/devup-mcp/tests/responsive_export.rs b/crates/devup-mcp/tests/responsive_export.rs index 3169a33c..9f233a62 100644 --- a/crates/devup-mcp/tests/responsive_export.rs +++ b/crates/devup-mcp/tests/responsive_export.rs @@ -145,18 +145,31 @@ impl FigmaUpstream for TwoWidths { async fn call_read_tool(&self, call: ReadToolCall) -> Result { match call { - ReadToolCall::Snapshot { .. } => { + ReadToolCall::Snapshot { + script: devup_mcp_figma::BuiltinScript::SectionIndex, + node_id, + .. + } => Ok(UpstreamResult { + raw: json!({"fileKey":"FileKey123","version":"v1","rootIds":[node_id],"nodes":[ + {"id":node_id,"type":"SECTION","fields":{"name":"notice board","childrenIds":["1:10","1:20"],"visible":true,"projectionTruncated":false,"absoluteBoundingBox":{"x":0,"y":0,"width":2200,"height":1000}}}, + {"id":"1:10","type":"FRAME","fields":{"name":"mobile","parentId":node_id,"childrenIds":[],"visible":true,"directChildCount":1,"subtreeNodeCount":4,"estimatedSerializedBytes":3000,"absoluteBoundingBox":{"x":0,"y":0,"width":360,"height":800}}}, + {"id":"1:20","type":"FRAME","fields":{"name":"desktop","parentId":node_id,"childrenIds":[],"visible":true,"directChildCount":3,"subtreeNodeCount":6,"estimatedSerializedBytes":5000,"absoluteBoundingBox":{"x":400,"y":0,"width":1920,"height":800}}} + ],"diagnostics":[]}), + }), + ReadToolCall::Snapshot { + node_id, root_ids, .. + } => { let mut nodes = width("1:10", "mobile", 360, 16, 20, false); nodes.extend(width("1:20", "desktop", 1920, 40, 30, true)); let node_count = nodes.len(); let mut envelope = json!({ "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, - "source": {"fileKey": "FileKey123", "rootId": "1:20"}, + "source": {"fileKey": "FileKey123", "rootId": node_id}, "snapshot": { "fileKey": "FileKey123", "version": "v1", - "rootIds": ["1:10", "1:20"], + "rootIds": root_ids.unwrap_or_else(||vec!["1:10".into(),"1:20".into()]), "nodes": nodes, "diagnostics": [] }, @@ -216,7 +229,12 @@ impl FigmaUpstream for TwoWidths { } async fn export(arguments: Value) -> anyhow::Result { - let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), Arc::new(TwoWidths))); + let services = Services::new(Arc::new(ConnectedAuth), Arc::new(TwoWidths)); + let server = if let Some(root) = arguments["projectRoot"].as_str() { + DevupServer::with_output_roots(services, vec![std::path::PathBuf::from(root)])? + } else { + DevupServer::new(services) + }; let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); let task = tokio::spawn(async move { server.serve(server_transport).await?.waiting().await?; @@ -349,6 +367,47 @@ async fn the_page_is_named_after_its_section_when_the_caller_gives_no_name() -> Ok(()) } +#[tokio::test] +async fn confirmed_monorepo_allows_tsx_companion_with_the_complete_responsive_group() +-> anyhow::Result<()> { + struct Root(std::path::PathBuf); + impl Drop for Root { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let root = Root(std::env::temp_dir().join(format!( + "devup-responsive-monorepo-{:016x}", + rand::random::() + ))); + let app = root.0.join("apps/front"); + std::fs::create_dir_all(&app)?; + std::fs::create_dir_all(root.0.join(".devup"))?; + std::fs::write( + root.0.join(".devup/screen-plan.json"), + serde_json::to_vec(&json!({ + "kind":"devup-screen-plan","schemaVersion":1,"planId":"sp-responsive-monorepo","status":"confirmed", + "source":{"nodeId":"1:20","url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-20"}, + "routes":[{"url":"/","screen":"1:20","nodeIds":["1:10","1:20"],"drawnWidths":[360,1920], + "implementation":"page","app":"apps/front","file":"apps/front/src/app/page.tsx"}] + }))?, + )?; + let result=export(json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=0-1", + "projectRoot":app,"frameIds":["1:10","1:20"],"outputs":["tsx","responsiveTsx","assetManifest"],"delivery":"inline"})).await?; + assert_ne!(result["status"], "responsive_required", "{result:#?}"); + assert_ne!( + result["status"], "responsive_grouping_required", + "{result:#?}" + ); + assert!( + result["responsiveTsx"].is_string(), + "{}", + json!({"keys":result.as_object().map(|result| result.keys().collect::>()),"responsiveTsx":result["responsiveTsx"],"outputResults":result["outputResults"],"failures":result["failures"]}) + ); + assert_eq!(result["responsiveSlots"].as_array().map(Vec::len), Some(2)); + Ok(()) +} + /// A responsive array written one slot to a line, folded back onto one. fn folded(tsx: &str) -> String { let mut out = String::with_capacity(tsx.len()); diff --git a/crates/devup-mcp/tests/runtime_audit_tool.rs b/crates/devup-mcp/tests/runtime_audit_tool.rs index b95f8c5e..5951881d 100644 --- a/crates/devup-mcp/tests/runtime_audit_tool.rs +++ b/crates/devup-mcp/tests/runtime_audit_tool.rs @@ -176,7 +176,9 @@ async fn consumer_template_is_allowlisted_and_missing_acquisitions_never_pass() assert!(!failed, "{planned}"); assert_eq!(planned["template"]["executed"], false); assert_eq!(planned["dimensionReferences"][0]["status"], "unverified"); - assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 7); + assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 8); + let markup = std::fs::read_to_string(root.join("consumer-audit/markup-checks.mjs"))?; + assert!(markup.contains("collectMarkup")); let runner = std::fs::read_to_string(root.join("consumer-audit/run.mjs"))?; assert!(runner.contains("runPlan")); let manifest: Value = diff --git a/crates/devup-mcp/tests/ui_validate_response/section_coverage.rs b/crates/devup-mcp/tests/ui_validate_response/section_coverage.rs index f2646b24..8e185a2a 100644 --- a/crates/devup-mcp/tests/ui_validate_response/section_coverage.rs +++ b/crates/devup-mcp/tests/ui_validate_response/section_coverage.rs @@ -43,3 +43,16 @@ async fn missing_sections_and_asset_files_fail_even_without_strict() -> anyhow:: ); Ok(()) } + +#[tokio::test] +async fn pure_source_coverage_uses_sidecar_mappings_without_requiring_dom_attributes() -> anyhow::Result<()> { + let result = validate(json!({ + "tsx":"export function Screen(){return Content}", + "sectionCoverage":{"nodeIds":["frame","copy"],"elementMappings":[{"nodeId":"frame","elementIndex":0,"tag":"Box"},{"nodeId":"copy","elementIndex":1,"tag":"Text"}]} + })).await?; + assert!(!result["violations"].as_array().unwrap().iter().any(|finding| finding["rule"]=="section-coverage"),"{result}"); + assert_eq!(result["sectionCoverageVerification"]["runtimeVerified"], false); + let unavailable = validate(json!({"tsx":"","sectionCoverage":{"nodeIds":["missing"]}})).await?; + assert!(unavailable["violations"].as_array().unwrap().iter().any(|finding| finding["rule"]=="section-coverage"&&finding["severity"]=="warning"),"{unavailable}"); + Ok(()) +} diff --git a/fixtures/devup-figma-plugin/README.md b/fixtures/devup-figma-plugin/README.md index bb9d805f..18f32d71 100644 --- a/fixtures/devup-figma-plugin/README.md +++ b/fixtures/devup-figma-plugin/README.md @@ -32,6 +32,70 @@ The 50 affected snapshot checksums are synchronized in `manifest.json`; original case inputs and upstream inventory remain unchanged. These are static codegen regressions, not new browser pixel measurements. +Intrinsic-width text (`textAutoResize=WIDTH_AND_HEIGHT`) now emits +`whiteSpace="pre"` so a narrow flex parent cannot soft-wrap a captured marquee. +The reviewed corpus changes add only that property and formatter whitespace; +source-map snapshots also record the explicit textAutoResize derivation. +Clamped text and list text retain their separate policies. Original inputs and +upstream inventory are unchanged; checksums follow the reviewed snapshots. + +The accessibility correction adds image `alt` attributes derived from captured +layer names to 34 reviewed codegen snapshots. Explicitly named semantic regions +and heading layers also emit their HTML tags. The review checks that all other +attribute values and text remain unchanged, allowing formatter line wrapping. +Only the affected snapshot checksums are updated; case inputs, upstream source +identity and test inventory are preserved. These are non-visual HTML corrections, +not additional browser pixel measurements. + ## R4 배치 계약 변경 `upstream-codegen-191`, `251`–`254`의 골든은 absolute 자식의 기준 요소를 생성 코드가 소유하도록 갱신했습니다. PAGE/SECTION 루트에 relative와 FIXED 축의 크기를 보존하고, 앞에 그린 absolute 배경에는 루트 stacking context 안의 z-index를 적용합니다. 원본 case JSON은 유지했고 변경한 5개 snapshot의 LF-normalized checksum만 동기화했습니다. 이는 생성 코드 배치 계약의 의도적인 변경이며 신규 브라우저 픽셀 측정 결과를 뜻하지 않습니다. + +The carriage-return correction updates only codegen snapshot 253: lone CR +controls render as inline spaces, while CRLF, LF and Unicode line separators +retain their hard breaks. Its two changed JSX lines follow three lone CRs in +unchanged captured strings (including trailing spaces). The rule was checked +against the native Figma location transit PNGs and four single-line source +boxes; this is not a new pixel measurement of the Greetings corpus screen. +Raw case inputs, style properties and the upstream inventory remain unchanged. + + +### Captured Korean HEIGHT wrapping correction + +Six reviewed codegen goldens intentionally replace exactly fourteen +`wordBreak="keep-all"` lines with `wordBreak="normal"`. Every changed source +Text has captured Korean styled segments, `textAutoResize=HEIGHT`, a positive +measured width on a FILL axis, and `textTruncation=DISABLED` without a line cap. +The old keep-all default adds word-boundary restrictions absent from Figma's +captured wrapping. The source-backed Vision correction is protected by +`captured_korean_wrapping.rs`. Separate browser verification of those Vision +cases matched source heights 46/52px instead of the old 69/78px; these older +corpus screens have not acquired new PNG measurements. + +| Golden case | Source Text IDs | Changed lines | +| --- | --- | --- | +| 165 | 1:17, 1:18 | 2 | +| 191 | 107:32 | 1 | +| 213 | 109:64 | 1 | +| 223 | 113:10, 113:13, 113:18, 113:25, 113:29, 113:33 | 6 | +| 252 | 284:18751, 284:18753 | 2 | +| 253 | 213:7494, 213:7495 | 2 | + +The review verifies that all other output bytes are unchanged after LF +normalization. Original case JSON, upstream source identity and the test +inventory remain untouched; only these six snapshot checksums change. +Intrinsic HUG/no-wrap text, unknown fields, truncation/clamping, explicit +existing word-break settings and theme policies retain their separate guards. +`compat_fixtures` continues to compare all 268 complete outputs byte for byte; +there is no comparator exception or broad text normalization. + +### ChondroGen delivery feedback (2026-11-03) + +The later explicit Korean-word-boundary requirement supersedes the HEIGHT +wrapping default above: Korean text uses keep-all plus emergency long-token +wrapping unless an explicit user/theme policy says otherwise. Seventeen reviewed +codegen goldens change only this wrapping policy, emergency wrapping, or removal +of one component-usage inspection comment. Source case JSON, upstream identity +and coverage inventory are unchanged. Only the affected snapshot checksums are +updated; complete outputs still compare byte for byte. These changes do not +assert new pixel measurements of every pinned upstream screen. diff --git a/fixtures/devup-figma-plugin/manifest.json b/fixtures/devup-figma-plugin/manifest.json index 43995856..fd48fcfc 100644 --- a/fixtures/devup-figma-plugin/manifest.json +++ b/fixtures/devup-figma-plugin/manifest.json @@ -1155,39 +1155,39 @@ }, { "path": "snapshots/codegen/upstream-codegen-003-c855738695.snap", - "sha256": "107a4fc6a1b284870aafa9c796460cd324913c851929dc2c1bd134626d807c8d" + "sha256": "e21714906b1ad9a22fdfaa822247dae5439d0aff8941bae6d41a05f1d37b9600" }, { "path": "snapshots/codegen/upstream-codegen-004-8ec61c5bb5.snap", - "sha256": "0d73ed65e694b49b9210a486ac7319bcce42cebce52d6240e4f61c7f875c6d41" + "sha256": "af151dbaf4b7a68c720590ec7da853efba57e53bd45b3e271354cb6f055d5f47" }, { "path": "snapshots/codegen/upstream-codegen-005-bcc6ca7dbf.snap", - "sha256": "ecc94354d07867bc6371171d6c06ed469277bee57c3e56782c289ca0bf590853" + "sha256": "38ac5720ab4b9a66a3ffc126100f65fdb5a16a4d6100004c369f36e3b65871e5" }, { "path": "snapshots/codegen/upstream-codegen-006-86c21dc8ce.snap", - "sha256": "21b2dd6a1752fee5498db1b57e36cb55fb1610f1b7447e087400c27939c87ac8" + "sha256": "77d56f77f0d3c1ad18f2aa931196c4c37c5a5c7db8a2ab1c1b8be514feb12623" }, { "path": "snapshots/codegen/upstream-codegen-007-c1c1afa5d7.snap", - "sha256": "ec12b4610d766fcba77e461b3e2ec8b5f38adfb7ed44efe763d887521be9280b" + "sha256": "a1bcb80d9f0dbcb3c898416efed5c4b8c2a120b2e2b8a3e3c03e0cd0de81544a" }, { "path": "snapshots/codegen/upstream-codegen-008-8ea06a6c88.snap", - "sha256": "89609fae76c848720a6cfea26851576f3026d9a336e7f9cc578f1c0d955dce4b" + "sha256": "55b2a732f97caba2370a9dbbc6e66a0a8f62b9e442c88d12f2162c66b2750966" }, { "path": "snapshots/codegen/upstream-codegen-009-008aac1a2c.snap", - "sha256": "c063e2b0cc76a83849f174392fd91809fbb21fb17e9f9e241c28291c0d04dedc" + "sha256": "044e6e559c559f544ee07d8707f6b3309abe818f570186e07fd0917167ba1f1e" }, { "path": "snapshots/codegen/upstream-codegen-010-8454d22acd.snap", - "sha256": "552b628a81fa60e52412c75b1a941b992262414de6f155ba48bdbcda6d5b4297" + "sha256": "07c2ede7fce2b7cc7bff436be7f9abd4e20d1e2d7b9e46b75fc9255815022d5d" }, { "path": "snapshots/codegen/upstream-codegen-011-72709aab82.snap", - "sha256": "8ec53168a0cccd781bf1ac67036411a7c07e29a8c63d640fed23c96981e0a1ae" + "sha256": "21d98e580250f16800b4d55cd7fb9dafb124c62ca66a7c5793d19c295a7f4ca9" }, { "path": "snapshots/codegen/upstream-codegen-012-92c671c0e8.snap", @@ -1207,15 +1207,15 @@ }, { "path": "snapshots/codegen/upstream-codegen-016-3f1fb1569b.snap", - "sha256": "bc5741f520fe3197c9cd6a5391d918c099e7e6f8c624402aa3c1ebeafd3bdd04" + "sha256": "f04be69cd6e23f04ade7d48f4d29e34194ea84e405727e2a376d3b4a673c20f6" }, { "path": "snapshots/codegen/upstream-codegen-017-44cff97cec.snap", - "sha256": "fcaf1f4e3df1b2d0dca35d19c683a8c22fba0fc4d027b923fa9af35ca9e1005a" + "sha256": "342739780fe64a42bfb1da8fb4fc65b70d255073df33fd99d9bce69cb9058666" }, { "path": "snapshots/codegen/upstream-codegen-018-bc5eb21a9e.snap", - "sha256": "fe08bdb7d8073a9bee0f3323c020196d3a4bfed9e413fdc9fa651ba71ecf81e2" + "sha256": "d020ecc42ea44d07f0f47bbb66a68739aaadb2a9b6ad3e38c56941389eb01e85" }, { "path": "snapshots/codegen/upstream-codegen-019-4988f86e5e.snap", @@ -1223,7 +1223,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-020-a9d569be7a.snap", - "sha256": "41323d03429d53d97d8bd555a167508bbf115ec41ecd7a41ae3193dcf25cbbdb" + "sha256": "b75455c32d03a73e39ef4bb3b76db699755999b0ac173bbb0525f70a5b87a780" }, { "path": "snapshots/codegen/upstream-codegen-021-6cae93b843.snap", @@ -1255,7 +1255,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-028-0dcf04d5ca.snap", - "sha256": "a87a5659c53749e73f245853f672b6d15a3a67f12798fa8a2dbd4a0aca07d6d9" + "sha256": "66d5f951c054286f157f61a4b2a63a106817fec4cc8996f0a663646b8694ad46" }, { "path": "snapshots/codegen/upstream-codegen-029-dd01228f04.snap", @@ -1695,7 +1695,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-138-9cccfcb552.snap", - "sha256": "06a76b64be48d981b5e25497af7526164a1cb8b5c607fc2805709ee69dc25cf0" + "sha256": "e56ac5fb6b99ad8e2f4e5526e2683c8c14ec38bc22c545ae0e0893b014830083" }, { "path": "snapshots/codegen/upstream-codegen-139-63f7b1f175.snap", @@ -1703,7 +1703,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-140-e577df9ad5.snap", - "sha256": "980e22dd4042cc685b49c690a23688c4eeffd11bbfc9f6894ac869882332df3f" + "sha256": "e2fdefde20599ede8e2db4166dc012a83f904b87e45de5e52a81c71c9cc9562f" }, { "path": "snapshots/codegen/upstream-codegen-141-48307df49b.snap", @@ -1715,7 +1715,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-143-52b441f9f4.snap", - "sha256": "8d1079d0572575cf6144f86be4442d2c503fc30221853125e552400a7388870d" + "sha256": "1bdb0ed24879c2bc58667faba74c6456faeb8998b8df955dca06e7c6e5c2f464" }, { "path": "snapshots/codegen/upstream-codegen-144-042a6de403.snap", @@ -1767,7 +1767,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-156-bec8be9b99.snap", - "sha256": "7fdd5dc5696593d77e050be4e8ebfe3986ebd56c0bd7df6ce969a5ced21c9c39" + "sha256": "42d70d08d576e405a609e58906a26e23a10c286cb792854f591d518472dae08a" }, { "path": "snapshots/codegen/upstream-codegen-157-4cb71f2863.snap", @@ -1787,31 +1787,31 @@ }, { "path": "snapshots/codegen/upstream-codegen-161-5b230821f2.snap", - "sha256": "9d7c6ca792642d02ad7d4fdbdb2f2c48886234af5ef68dc661ce214f4e782555" + "sha256": "70abbf6a85e28d6ba21987d9fe4f77d99bcf65f4b47ca61e9653fff3e0614b4f" }, { "path": "snapshots/codegen/upstream-codegen-162-51e1254b4c.snap", - "sha256": "2a2f3d03bdcca8b2017274486bd2a41bc965f6fb03137efb36e31503e7140293" + "sha256": "9b488130463ef70a4e2e12e97d7fbbe721d15436400c71ad6f2358c62a240b26" }, { "path": "snapshots/codegen/upstream-codegen-163-2a9f9a056a.snap", - "sha256": "1d7e81bfada5252d932f77c46f398f54ad63caf2d3c343083c4ed8c98641953b" + "sha256": "68a5169c271bff2d79578efa8aa8cec2f31ef21ada62783a44593aeb9257e849" }, { "path": "snapshots/codegen/upstream-codegen-164-33e4c6591c.snap", - "sha256": "194f06f9eb7ed13b8f1393737b3a1a91805422d0b2658cf23b8d756134ec0e22" + "sha256": "b9b66439d24391371700a2ab4280e9edaff4657639607b5475d68ecd245f1edd" }, { "path": "snapshots/codegen/upstream-codegen-165-501ee1df12.snap", - "sha256": "c13404dd0d0961fe7760c1cf767ba6a7ba33116ca3dcbac8bc481e23e23d4c63" + "sha256": "6ff36023e7a4b5f4bd7b94e32dd860858790306ec388c3e18f9329f47bbb1c67" }, { "path": "snapshots/codegen/upstream-codegen-166-cda14f0b08.snap", - "sha256": "b9d7c0159106e942c20b8b1163f10176efe9588eccc21e1f5dc008a5167743d2" + "sha256": "2514ae0021bc1457a5b4b9918509f1c0f60129ec27c8a229b48b90432b6dbef5" }, { "path": "snapshots/codegen/upstream-codegen-167-114a88771f.snap", - "sha256": "9d164445800460cdc18a60985363fc92dff602d61380111a38d92dec99fdc910" + "sha256": "b73546674448ff60163d61cf465cbabea513f1577628f91668bc0c4a196f79db" }, { "path": "snapshots/codegen/upstream-codegen-168-4211bd8279.snap", @@ -1907,7 +1907,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-191-2021d398f8.snap", - "sha256": "d35d9d2650e83a508c96e56aba5c56c92f3369eb0afbcb8f2ec337e4c53cdbce" + "sha256": "a0cfd38459810097773e40db834410d21737fbc00c35e4896c5ddccada3f47b4" }, { "path": "snapshots/codegen/upstream-codegen-192-dd2c3f0c1c.snap", @@ -1931,47 +1931,47 @@ }, { "path": "snapshots/codegen/upstream-codegen-197-026402bf2e.snap", - "sha256": "7c8f2764bff6a4ed31e33fab0c26ce0066a0efe3e3cd75db049d4f21be65b2fd" + "sha256": "359d83f6170ba3427403c44b536d2ab4906ef3ae5dbd23b5b7a363dbd039e212" }, { "path": "snapshots/codegen/upstream-codegen-198-a9ea7ba9c3.snap", - "sha256": "bc3d85601a1e694f99ed182735024d9969cc52b16f8226f50340e7ae75e4f7d5" + "sha256": "6c564c6e0cd515a27ef4db4307757956feb30c6bf060cf38ec53c999e8a95512" }, { "path": "snapshots/codegen/upstream-codegen-199-829a5c949d.snap", - "sha256": "4b28a55ece68dd455db01b4b93701786b758077242ffb180b0e1014a0f0eefbb" + "sha256": "f6f4e63da0772a7377cab32c17b6b159b3a644349465b82300f9fbf5c7fbbf26" }, { "path": "snapshots/codegen/upstream-codegen-200-3313d92719.snap", - "sha256": "0eb6f43dffe7a9365611a48ac1627d8a65dca4e0e1f1d08cf6ced74a4f85eb1a" + "sha256": "9966e2f1cbf0146e1ad8f8945baa74d509ab72d7e5c14714c9d40e72efeb4bf4" }, { "path": "snapshots/codegen/upstream-codegen-201-8207a4008e.snap", - "sha256": "9bcae01c5c470c188a4f735de6f60cdf4958f7949b82bcc84052b61782146e2d" + "sha256": "13e8db57a31d94f0f00390845203493c6c182a1163e531dd09fe09d89d9af80b" }, { "path": "snapshots/codegen/upstream-codegen-202-2786eda075.snap", - "sha256": "98d09516cc7a9fc48369cf17a24f64a88c4153c3c0d5e1055ec8a20475a29702" + "sha256": "a0026c68012d8e362ff96c19346ff5e9925ab2e3d03b17ea884cc7412e9e7a85" }, { "path": "snapshots/codegen/upstream-codegen-203-8af03217a3.snap", - "sha256": "bbb4efc27b6547cbfed03a96a878c489e8a0611dcd9ae9e0928faf610c82d6ec" + "sha256": "fc99293b169ada52c98e52621c3de17ef99b78f3ac2966da4052671d06a55d9f" }, { "path": "snapshots/codegen/upstream-codegen-204-2b2967c8c4.snap", - "sha256": "98d09516cc7a9fc48369cf17a24f64a88c4153c3c0d5e1055ec8a20475a29702" + "sha256": "a0026c68012d8e362ff96c19346ff5e9925ab2e3d03b17ea884cc7412e9e7a85" }, { "path": "snapshots/codegen/upstream-codegen-205-cc3ea5202a.snap", - "sha256": "9b380696023db9a84b7063c9ca82d81bdda607bd9e4853770a519b9fc8bee055" + "sha256": "a5ccdf11a3bd2bfd96d991709c1bcffb365eddca4ccb6919304dff5f296c71c8" }, { "path": "snapshots/codegen/upstream-codegen-206-b5c5dc97eb.snap", - "sha256": "f12fb5cc1d5b43b74433ea9447fda3a8e4744a57f71aa7ea1a970a73e0ff8613" + "sha256": "4c1dbbe29b2506d55bbb7d618804c8e9f3634be42af1dcafa69178f3fdbfad10" }, { "path": "snapshots/codegen/upstream-codegen-207-b0e134acf4.snap", - "sha256": "fc7939b4c9603211a68ee0c0beb0ca67b5d9105c1e4c5c8139b5bf227f46c1a5" + "sha256": "5e5b948939454a0b08935c6ec434adb178eee5629d3924f82f024742d7ab120c" }, { "path": "snapshots/codegen/upstream-codegen-208-1a343e45db.snap", @@ -1979,23 +1979,23 @@ }, { "path": "snapshots/codegen/upstream-codegen-209-0514b0990f.snap", - "sha256": "b117ee109393f355b2170a643289875aae10ede094bd17590e5d48f7cd146969" + "sha256": "06952c69a309f6c4f1e03c50ebb71c66db59de13b93ea7ad3ca3ec0acba16bbf" }, { "path": "snapshots/codegen/upstream-codegen-210-1b24060767.snap", - "sha256": "e6f7cdbd19b4fcb1af2b089339b1348a82bc992a919f44c75951d2d9305cf62c" + "sha256": "87742dbff59dfb2e44d38a537b67b6df354347872c5c0356a3545df21c948830" }, { "path": "snapshots/codegen/upstream-codegen-211-2e15c55701.snap", - "sha256": "462e2966e427fb10648658105eb0a85b8604d01e4c24cb799a385b6aa789b5a1" + "sha256": "0ce5ab49c39576c61cc60242ccce7811a96928d920465d56a4dd7bbd5db5e1f0" }, { "path": "snapshots/codegen/upstream-codegen-212-2a8f7b4a99.snap", - "sha256": "5fec63dd4ce0f59ebfb068894cdc687735d1ae065beae40a0f93841d0b816861" + "sha256": "0bc18390879276f924e7a3836111e65f4835bc5eaa31c8430f5b5260dda9e443" }, { "path": "snapshots/codegen/upstream-codegen-213-aa53408d2f.snap", - "sha256": "a9d4208072b46676cd2ad4d5320f526fe7b4ae2bb7b9360eed36c13179614070" + "sha256": "7482a0fb474a4a3ab5344ffaa7270ad9b63077a51064796f6275c8cf61fe66b5" }, { "path": "snapshots/codegen/upstream-codegen-214-6dc49544c0.snap", @@ -2015,15 +2015,15 @@ }, { "path": "snapshots/codegen/upstream-codegen-218-587666c709.snap", - "sha256": "c8e5a180a98cb5235867c2dbc48461bc87c2f6458b788cdf588d1556daeec89d" + "sha256": "de25c8b42e5250c7a0c1288357a56afc3f044f0f06f2d83db1d820b0e7223d7a" }, { "path": "snapshots/codegen/upstream-codegen-219-c6446b7b2e.snap", - "sha256": "41201d7901fc28c3671bf916c7524586833b26bae44fc88685a95896a421f8b7" + "sha256": "04552347a9ed873caf5333402d96e062ba8622c44d8ddcb21e5d7e0636b58813" }, { "path": "snapshots/codegen/upstream-codegen-220-5980153f26.snap", - "sha256": "ba96caf7143dadc73fb3f9367d9c89f04e344550cdc3b5c1803787a05f0aedbe" + "sha256": "8c1435c93a9b3de9bdfc299a042e3548c54d83d184876f2b71fe30b4aa4532b7" }, { "path": "snapshots/codegen/upstream-codegen-221-d003bdb336.snap", @@ -2031,11 +2031,11 @@ }, { "path": "snapshots/codegen/upstream-codegen-222-f2d780c847.snap", - "sha256": "aceebdd85489cf7d781d3919d77342b655377971de700d2a3cbbb08f6cb4eb8a" + "sha256": "53c076d93a2baeff3abd11cf347029977ad4724a516a1f63106e98e47f094bff" }, { "path": "snapshots/codegen/upstream-codegen-223-4dc8092992.snap", - "sha256": "3ae6fed5cb6f059aa6656579ba90c3e349096960985f32bef47ca79ca710b1c1" + "sha256": "d7a250d817e57a89a679b55f2a912401e829d65720a0be1126222a3e2d551c4f" }, { "path": "snapshots/codegen/upstream-codegen-224-075a41e65c.snap", @@ -2079,11 +2079,11 @@ }, { "path": "snapshots/codegen/upstream-codegen-234-01ad9a0db0.snap", - "sha256": "85f92c032e9fe4c9420fd8873b1650226b18b88d5a2606d637076144fdb71b9e" + "sha256": "7fe33a9197529ab41de52af82d8515826511367aa87a9e5a1254eda719f41097" }, { "path": "snapshots/codegen/upstream-codegen-235-8a3992468b.snap", - "sha256": "ed3506bc5e94b81f18766512d749279f10e45c1cce2db2d687f2d21ab3a47d93" + "sha256": "11174b57bee12c67805eb3012860fa77ac794265f584995382d73f4507e2102c" }, { "path": "snapshots/codegen/upstream-codegen-236-621c6d6a9e.snap", @@ -2095,7 +2095,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-238-1a8cf8c870.snap", - "sha256": "21ac84461a0cbacd84248dc4fc8c83633ce53f050f3d76b2557331a1c4d61c70" + "sha256": "bcbf286a3d27852647054e1ff161aae2eaa73e681e011df86e90dd095d47e648" }, { "path": "snapshots/codegen/upstream-codegen-239-a6dada3682.snap", @@ -2103,7 +2103,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-240-77d56a1585.snap", - "sha256": "21ac84461a0cbacd84248dc4fc8c83633ce53f050f3d76b2557331a1c4d61c70" + "sha256": "bcbf286a3d27852647054e1ff161aae2eaa73e681e011df86e90dd095d47e648" }, { "path": "snapshots/codegen/upstream-codegen-241-e171ef72ce.snap", @@ -2111,59 +2111,59 @@ }, { "path": "snapshots/codegen/upstream-codegen-242-5c198a8490.snap", - "sha256": "01f026b07182e959a23c9d2cf1fec0fa90ee2b342e47274a34a9aaad099a1c28" + "sha256": "b30a5a535c44e00856ebc964ad2912300ceebb114e646067253d04558acb2920" }, { "path": "snapshots/codegen/upstream-codegen-243-14366a47ff.snap", - "sha256": "d9f3b59a8585884d957b690651be81ca7e5bf03ce3d553ac6c95465f9ebe8370" + "sha256": "430cc8b904e517db007c71d8a84d31cc69245751c632f3ba0658880b66656273" }, { "path": "snapshots/codegen/upstream-codegen-244-4ccea91f13.snap", - "sha256": "6a5bec0dda5697c78dbf553f55cb3c8ff25c1ddbaf4981282d23353011526c8c" + "sha256": "627bd533c5de69fe535d5d012228395372eeff7171436754b6dcb4cd70347435" }, { "path": "snapshots/codegen/upstream-codegen-245-537356bcf4.snap", - "sha256": "29e92ac662b46d796a0c86f8d8b69dbcf0b0eb5e8835a4d05ea57a359cae47dc" + "sha256": "1a7ccf651a251ed7347eb7eab085a5777207fc8a8c21ce483de976c4bd1a08ac" }, { "path": "snapshots/codegen/upstream-codegen-246-295f39e09b.snap", - "sha256": "b6f9e888c38ec2bc6fdf0768bb180c78d25a8be2425f024779f7bc2ab612c58c" + "sha256": "259d72bdfe7ad275dde327c957727956819884dd91de0ed60ad8fb006cb6cacb" }, { "path": "snapshots/codegen/upstream-codegen-247-39d6959685.snap", - "sha256": "3ff541729986f11bdaa79caa77c109b64844fb3e3c075fa2cfd87136afa54fa4" + "sha256": "0049476b7720597da585324641a458883761520dc281628c454444ff7015bcf6" }, { "path": "snapshots/codegen/upstream-codegen-248-d1bc18e068.snap", - "sha256": "8ff54872fbb3d924fc8ac618bd891e25b9d4a576d38bca9165db186500e66c3b" + "sha256": "ddfb5a069948c994c79ee685695670f2d518c08ac43386616e21fad852d031a0" }, { "path": "snapshots/codegen/upstream-codegen-249-6b8a42981d.snap", - "sha256": "d48d40fb258f79060e18cef2a7b010a4cb7a30d41f278fb46e7e74f9924e6ab2" + "sha256": "a644d34c90c35388d9a8069e29ed03143fb224b475763d0072eae46cdc6c1f6b" }, { "path": "snapshots/codegen/upstream-codegen-250-935ccdb80f.snap", - "sha256": "44b2e73fb75229032c8899210fef949b5335eda3971aeda672e4958228e7602f" + "sha256": "26f83533fe09d7bdb29c792ccc48fe77a66987d388371664ec67bec0709c54ae" }, { "path": "snapshots/codegen/upstream-codegen-251-50acddfb96.snap", - "sha256": "547561fd248270cbccd3311714de36a24de9aed0449e854354c64140108abb2d" + "sha256": "d39f49e2a8ba47a507ae6ef24c45587fb11a00ce72992c59eb4c1d5ebb875573" }, { "path": "snapshots/codegen/upstream-codegen-252-49e979239e.snap", - "sha256": "1ca180d276bdf67a040bc23b7dd699144726821980c0bd48233471983f5b8b17" + "sha256": "52ab20ca9f59b307a6e04e143fa30191cb37914ea3baffc91d5d44d482d5e29f" }, { "path": "snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap", - "sha256": "82942a09319540cf7c3cd24d3fc54d97c41933e259df11996c4a9878eee891e5" + "sha256": "50b99d951b8c831b892a0c56fa0d13d929da72bb328f4abd9b70939f915aab36" }, { "path": "snapshots/codegen/upstream-codegen-254-9f340b6a10.snap", - "sha256": "aa2709664a7c01f96246afa0cd0f90fd554cffcd2a33e7da5889818504327508" + "sha256": "1b483a5ea68f4ea3be9cbea1e0b9c4971796ebb6b51efb4295fa119d835bb58e" }, { "path": "snapshots/codegen/upstream-codegen-255-d01d8c7de6.snap", - "sha256": "9026ef8c9d744cbb366022395594a921ad0d18532f8d5ddf71349114f2ea1145" + "sha256": "8660c3924dd274c75c201ba77d9985108f56923773a0c47179c1a7d3c5104b61" }, { "path": "snapshots/codegen/upstream-codegen-256-baf32cc08e.snap", diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-003-c855738695.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-003-c855738695.snap index c891c44a..a715a72b 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-003-c855738695.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-003-c855738695.snap @@ -4,6 +4,7 @@ expression: actual --- "ObjectFitContain" +"VectorIcon" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-007-c1c1afa5d7.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-007-c1c1afa5d7.snap index ef342d41..e881b050 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-007-c1c1afa5d7.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-007-c1c1afa5d7.snap @@ -3,4 +3,4 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -"" +"StarIcon" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-008-8ea06a6c88.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-008-8ea06a6c88.snap index 3bebd24d..385eda9c 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-008-8ea06a6c88.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-008-8ea06a6c88.snap @@ -3,4 +3,4 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -"" +"PolygonIcon" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-009-008aac1a2c.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-009-008aac1a2c.snap index 63ca01e8..b3161720 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-009-008aac1a2c.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-009-008aac1a2c.snap @@ -3,4 +3,4 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -"" +"EllipseIcon" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-010-8454d22acd.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-010-8454d22acd.snap index e3acc022..515f9a4c 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-010-8454d22acd.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-010-8454d22acd.snap @@ -3,4 +3,4 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -"" +"GradientAsset" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-011-72709aab82.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-011-72709aab82.snap index 6ba44594..ccb7e3cf 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-011-72709aab82.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-011-72709aab82.snap @@ -4,5 +4,5 @@ expression: actual --- " - + ChildVector " diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-016-3f1fb1569b.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-016-3f1fb1569b.snap index 6a941f65..4c5032d2 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-016-3f1fb1569b.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-016-3f1fb1569b.snap @@ -3,4 +3,4 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -"" +"MixedIcon" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-017-44cff97cec.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-017-44cff97cec.snap index 239fb7fe..800d3913 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-017-44cff97cec.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-017-44cff97cec.snap @@ -3,4 +3,4 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -"" +"GradientIcon" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-018-bc5eb21a9e.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-018-bc5eb21a9e.snap index 2399b25b..a3fdfe66 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-018-bc5eb21a9e.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-018-bc5eb21a9e.snap @@ -3,4 +3,4 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -"" +"InvisibleIcon" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-020-a9d569be7a.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-020-a9d569be7a.snap index b689f758..fe58d319 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-020-a9d569be7a.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-020-a9d569be7a.snap @@ -3,4 +3,4 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -"" +"NestedMultiColorIcon" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-028-0dcf04d5ca.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-028-0dcf04d5ca.snap index 4167f7cf..1ba97af9 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-028-0dcf04d5ca.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-028-0dcf04d5ca.snap @@ -10,6 +10,7 @@ expression: actual fontWeight="400" letterSpacing="0px" lineHeight="1.5px" + whiteSpace="pre" > AutoSize " diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-138-9cccfcb552.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-138-9cccfcb552.snap index c3ca16ad..4aa56dad 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-138-9cccfcb552.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-138-9cccfcb552.snap @@ -22,6 +22,7 @@ export function IconButton({ size, leftIcon }: IconButtonProps) { lineHeight="1.5px" overflow="hidden" textOverflow="ellipsis" + whiteSpace="pre" > Click diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-140-e577df9ad5.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-140-e577df9ad5.snap index d5ae84c4..23701b1d 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-140-e577df9ad5.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-140-e577df9ad5.snap @@ -22,6 +22,7 @@ export function AlertCard({ State, showBadge }: AlertCardProps) { lineHeight="1.5px" overflow="hidden" textOverflow="ellipsis" + whiteSpace="pre" > Hello diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap index 44ae1781..a0946398 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap @@ -25,6 +25,7 @@ export function Button({ size, leftIcon, showLeftIcon, rightIcon, showRightIcon lineHeight="1.5px" overflow="hidden" textOverflow="ellipsis" + whiteSpace="pre" > Submit diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-156-bec8be9b99.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-156-bec8be9b99.snap index f65372e6..8e6cf0e1 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-156-bec8be9b99.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-156-bec8be9b99.snap @@ -12,6 +12,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Hello World diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-161-5b230821f2.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-161-5b230821f2.snap index d6f06903..a4de18ce 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-161-5b230821f2.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-161-5b230821f2.snap @@ -13,6 +13,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="26px" + whiteSpace="pre" > Hello World diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-162-51e1254b4c.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-162-51e1254b4c.snap index aedb9f45..d9dea209 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-162-51e1254b4c.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-162-51e1254b4c.snap @@ -13,6 +13,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="26px" + whiteSpace="pre" > Hello World diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-163-2a9f9a056a.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-163-2a9f9a056a.snap index c0859309..3a0a5fef 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-163-2a9f9a056a.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-163-2a9f9a056a.snap @@ -13,6 +13,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="26px" + whiteSpace="pre" > Hello World diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-164-33e4c6591c.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-164-33e4c6591c.snap index 31046553..f99f9a57 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-164-33e4c6591c.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-164-33e4c6591c.snap @@ -21,6 +21,8 @@ expression: actual fontWeight="700" letterSpacing="-0.04em" lineHeight="29px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > 더 자세히 알아보기 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-165-501ee1df12.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-165-501ee1df12.snap index 784aadd1..5940f23a 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-165-501ee1df12.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-165-501ee1df12.snap @@ -6,7 +6,7 @@ expression: actual "export function Component1() { return ( - + puzzle-piece @@ -28,6 +29,7 @@ expression: actual fontWeight="500" letterSpacing="-0.06em" lineHeight="26px" + overflowWrap="break-word" whiteSpace="pre-wrap" wordBreak="keep-all" > diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-166-cda14f0b08.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-166-cda14f0b08.snap index c3304bac..c1f5caaa 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-166-cda14f0b08.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-166-cda14f0b08.snap @@ -21,6 +21,7 @@ expression: actual fontWeight="500" letterSpacing="-0.06em" lineHeight="58px" + whiteSpace="pre" > CARD 1 @@ -40,6 +41,7 @@ expression: actual fontWeight="500" letterSpacing="-0.06em" lineHeight="58px" + whiteSpace="pre" > CARD 2 @@ -59,6 +61,7 @@ expression: actual fontWeight="500" letterSpacing="-0.06em" lineHeight="58px" + whiteSpace="pre" > CARD 3 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-167-114a88771f.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-167-114a88771f.snap index 4e65ff1c..60862818 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-167-114a88771f.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-167-114a88771f.snap @@ -22,6 +22,7 @@ expression: actual fontWeight="600" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Hello World @@ -32,6 +33,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Lorem ipsum dolor sit amet,{" "} diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-191-2021d398f8.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-191-2021d398f8.snap index b8493191..b8d5f247 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-191-2021d398f8.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-191-2021d398f8.snap @@ -17,6 +17,7 @@ expression: actual zIndex="0" > puzzle 지금 시작할 수 있어요 @@ -60,6 +63,8 @@ expression: actual fontWeight="700" letterSpacing="-0.04em" lineHeight="24px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > 내 삶을 더 잘 이해하는 첫 걸음 @@ -72,6 +77,7 @@ expression: actual fontWeight="400" letterSpacing="-0.04em" lineHeight="22px" + overflowWrap="break-word" whiteSpace="pre-wrap" wordBreak="keep-all" > @@ -87,6 +93,8 @@ expression: actual letterSpacing="-0.04em" lineHeight="19px" opacity="0.5" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > 회원가입 후, 유료로 진행됩니다 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-197-026402bf2e.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-197-026402bf2e.snap index 4c892ca0..357f6fe8 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-197-026402bf2e.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-197-026402bf2e.snap @@ -20,10 +20,12 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Hello World! Hello World! Hello World! Hello World! Hello World! Hello World @@ -23,6 +24,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Lorem ipsum dolor sit amet, consectetur adipiscing elit. diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-203-8af03217a3.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-203-8af03217a3.snap index edacbafa..2f9a56eb 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-203-8af03217a3.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-203-8af03217a3.snap @@ -13,6 +13,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Hello World @@ -23,6 +24,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Lorem ipsum dolor sit amet, consectetur adipiscing elit. diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-204-2b2967c8c4.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-204-2b2967c8c4.snap index f4c7b0e7..a0ce3046 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-204-2b2967c8c4.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-204-2b2967c8c4.snap @@ -13,6 +13,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Hello World @@ -23,6 +24,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Lorem ipsum dolor sit amet, consectetur adipiscing elit. diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-205-cc3ea5202a.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-205-cc3ea5202a.snap index 9827ccad..63408feb 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-205-cc3ea5202a.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-205-cc3ea5202a.snap @@ -13,6 +13,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Hello World diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-206-b5c5dc97eb.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-206-b5c5dc97eb.snap index 14c1e3c2..053bddb7 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-206-b5c5dc97eb.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-206-b5c5dc97eb.snap @@ -13,6 +13,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Hello World @@ -23,6 +24,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Lorem ipsum dolor sit amet, consectetur adipiscing elit. diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-207-b0e134acf4.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-207-b0e134acf4.snap index 91d2d5a3..f03f9b6f 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-207-b0e134acf4.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-207-b0e134acf4.snap @@ -19,6 +19,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Hello World @@ -29,6 +30,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Lorem ipsum dolor sit amet, consectetur adipiscing elit. diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-209-0514b0990f.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-209-0514b0990f.snap index 995bdd5f..c5ed991a 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-209-0514b0990f.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-209-0514b0990f.snap @@ -19,6 +19,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Hello World @@ -29,6 +30,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Lorem ipsum dolor sit amet, consectetur adipiscing elit. diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-210-1b24060767.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-210-1b24060767.snap index 8d0cb3fe..130739cb 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-210-1b24060767.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-210-1b24060767.snap @@ -19,6 +19,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Hello World @@ -29,6 +30,7 @@ expression: actual fontWeight="400" letterSpacing="0em" lineHeight="normal" + whiteSpace="pre" > Lorem ipsum dolor sit amet, consectetur adipiscing elit. diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-211-2e15c55701.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-211-2e15c55701.snap index b43d3848..928708b5 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-211-2e15c55701.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-211-2e15c55701.snap @@ -7,6 +7,7 @@ expression: actual return ( 플레이 제목 @@ -38,6 +40,7 @@ expression: actual fontWeight="400" letterSpacing="-0.5px" lineHeight="23px" + whiteSpace="pre" > 2025.03.03 18:00 @@ -65,6 +68,7 @@ expression: actual fontWeight="600" letterSpacing="-0.5px" lineHeight="23px" + whiteSpace="pre" > 2 @@ -77,6 +81,7 @@ expression: actual fontWeight="400" letterSpacing="-1px" lineHeight="24px" + overflowWrap="break-word" wordBreak="keep-all" > 댓글 내용 출력 댓글 내용 출력 댓글 내용 출력 댓글 내용 출력 댓글 내용 출력 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-218-587666c709.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-218-587666c709.snap index 85d2a571..95eec4b0 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-218-587666c709.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-218-587666c709.snap @@ -6,6 +6,7 @@ expression: actual "export function MixBlendMode() { return ( 데브파이브 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap index bf6b85f3..1ea2b44c 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap @@ -20,6 +20,7 @@ expression: actual fontWeight="700" letterSpacing="-1px" lineHeight="30px" + overflowWrap="break-word" w="100%" wordBreak="keep-all" > @@ -41,6 +42,7 @@ expression: actual fontWeight="600" letterSpacing="-1px" lineHeight="24px" + overflowWrap="break-word" wordBreak="keep-all" > 카카오 @@ -52,6 +54,8 @@ expression: actual fontWeight="400" letterSpacing="-0.5px" lineHeight="23px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > 연결 전 @@ -70,6 +74,8 @@ expression: actual fontWeight="500" letterSpacing="0px" lineHeight="16px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > 인증하기 @@ -91,6 +97,7 @@ expression: actual fontWeight="600" letterSpacing="-1px" lineHeight="24px" + overflowWrap="break-word" wordBreak="keep-all" > 구글 @@ -102,6 +109,7 @@ expression: actual fontWeight="400" letterSpacing="-0.5px" lineHeight="23px" + whiteSpace="pre" > cooolvita@gmail.com @@ -119,6 +127,8 @@ expression: actual fontWeight="500" letterSpacing="0px" lineHeight="16px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > 인증하기 @@ -143,6 +153,7 @@ expression: actual fontWeight="600" letterSpacing="-1px" lineHeight="24px" + overflowWrap="break-word" wordBreak="keep-all" > 서비스 이용약관 @@ -172,6 +183,7 @@ expression: actual fontWeight="600" letterSpacing="-1px" lineHeight="24px" + overflowWrap="break-word" wordBreak="keep-all" > 개인정보 처리방침 @@ -201,6 +213,7 @@ expression: actual fontWeight="600" letterSpacing="-1px" lineHeight="24px" + overflowWrap="break-word" wordBreak="keep-all" > 회원 탈퇴 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-234-01ad9a0db0.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-234-01ad9a0db0.snap index 785f3ae6..d7e68cec 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-234-01ad9a0db0.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-234-01ad9a0db0.snap @@ -4,5 +4,5 @@ expression: actual --- "export function SvgDetail() { - return + return DevupUI }" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-235-8a3992468b.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-235-8a3992468b.snap index 4bd381de..fdccfc59 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-235-8a3992468b.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-235-8a3992468b.snap @@ -6,7 +6,7 @@ expression: actual "export function SvgDetail() { return ( - + - + plus ) }" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-240-77d56a1585.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-240-77d56a1585.snap index bf22caa9..5c6aca34 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-240-77d56a1585.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-240-77d56a1585.snap @@ -6,7 +6,7 @@ expression: actual "export function SvgDetail() { return (
    - + plus
    ) }" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-242-5c198a8490.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-242-5c198a8490.snap index 42c06717..5f852cbb 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-242-5c198a8490.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-242-5c198a8490.snap @@ -12,7 +12,7 @@ expression: actual flexDir="column" p="10px" > - + cog ) }" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-243-14366a47ff.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-243-14366a47ff.snap index ed13c7b3..9ef54c0d 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-243-14366a47ff.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-243-14366a47ff.snap @@ -14,6 +14,7 @@ expression: actual fontWeight="700" letterSpacing="-0.03em" lineHeight="47px" + whiteSpace="pre" > 16.8s diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-244-4ccea91f13.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-244-4ccea91f13.snap index ece36c3e..158e9422 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-244-4ccea91f13.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-244-4ccea91f13.snap @@ -13,8 +13,10 @@ expression: actual fontWeight="400" letterSpacing="-0.02em" lineHeight="normal" + overflowWrap="break-word" paintOrder="stroke fill" textShadow="0 0 12px #7C70FF" + whiteSpace="pre" wordBreak="keep-all" > 홈페이지와 앱 제작 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-245-537356bcf4.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-245-537356bcf4.snap index 635cb6ef..803cc7f0 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-245-537356bcf4.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-245-537356bcf4.snap @@ -12,6 +12,8 @@ expression: actual fontWeight="500" letterSpacing="-0.05em" lineHeight="56px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-246-295f39e09b.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-246-295f39e09b.snap index 48661702..ecad7259 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-246-295f39e09b.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-246-295f39e09b.snap @@ -14,6 +14,8 @@ expression: actual fontWeight="400" letterSpacing="-0.05em" lineHeight="90px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > 데브파이브 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-247-39d6959685.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-247-39d6959685.snap index b4a61b73..e9a54e91 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-247-39d6959685.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-247-39d6959685.snap @@ -13,8 +13,10 @@ expression: actual fontWeight="400" letterSpacing="-0.05em" lineHeight="90px" + overflowWrap="break-word" paintOrder="stroke fill" textShadow="0 0 12px #7C70FF, 0 10px 24px #FFC9FE66" + whiteSpace="pre" wordBreak="keep-all" > 데브파이브 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-248-d1bc18e068.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-248-d1bc18e068.snap index 16781bf2..8fdac1ce 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-248-d1bc18e068.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-248-d1bc18e068.snap @@ -14,6 +14,7 @@ expression: actual letterSpacing="-0.04em" lineHeight="30px" my="0px" + overflowWrap="break-word" pl="1.5em" wordBreak="keep-all" > diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-249-6b8a42981d.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-249-6b8a42981d.snap index 3389b05a..ed663152 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-249-6b8a42981d.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-249-6b8a42981d.snap @@ -37,6 +37,8 @@ export function GradientButton({ text = "자세히 알아보기 →" }: Gradient fontWeight="700" letterSpacing="-0.04em" lineHeight="22px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > {text} diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap index 619278a2..bf3a3012 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap @@ -70,6 +70,8 @@ export function Button({ size, variant, text1 = "참가 신청하기" }: ButtonP fontWeight="500" letterSpacing="-0.04em" lineHeight="normal" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > {text1} diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-251-50acddfb96.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-251-50acddfb96.snap index bab2a20a..92884edf 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-251-50acddfb96.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-251-50acddfb96.snap @@ -49,6 +49,7 @@ expression: actual fontWeight="700" letterSpacing="0.04em" lineHeight="normal" + whiteSpace="pre" > Under Construction @@ -59,6 +60,8 @@ expression: actual fontWeight="700" letterSpacing="-0.02em" lineHeight="normal" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > 제주국제관악제 리뉴얼 알림 @@ -72,7 +75,9 @@ expression: actual fontWeight="400" letterSpacing="-0.03em" lineHeight="40px" + overflowWrap="break-word" textAlign="center" + whiteSpace="pre" wordBreak="keep-all" > 더 나은 서비스 제공을 위해 시스템 점검 및 리뉴얼을 진행 중입니다.
    이용에 불편을 드려 죄송하며, 빠른 시일 내에 다시 찾아뵙겠습니다. @@ -85,6 +90,7 @@ expression: actual letterSpacing="-0.02em" lineHeight="29px" textAlign="center" + whiteSpace="pre" > We are currently updating our website to serve you better.
    We apologize for the inconvenience. @@ -98,13 +104,28 @@ expression: actual fontWeight="700" letterSpacing="-0.02em" lineHeight="29px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > 제주국제관악제 최신 소식 확인하기 - - + + instagram_15713420 1 + facebook_1384053 1 - + Layer 2 + 문의 : 064-722-8704 / bandfestival@hanmail.net diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-252-49e979239e.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-252-49e979239e.snap index 89e76d35..74cba665 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-252-49e979239e.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-252-49e979239e.snap @@ -51,6 +51,7 @@ expression: actual fontWeight="700" letterSpacing="0.04em" lineHeight="30px" + whiteSpace="pre" > Under Construction @@ -61,6 +62,7 @@ expression: actual fontWeight="700" letterSpacing="-0.02em" lineHeight="53px" + overflowWrap="break-word" textAlign="center" w="100%" whiteSpace="pre-wrap" @@ -77,6 +79,7 @@ expression: actual fontWeight="400" letterSpacing="-0.03em" lineHeight="34px" + overflowWrap="break-word" textAlign="center" w="100%" whiteSpace="pre-wrap" @@ -107,13 +110,28 @@ expression: actual fontWeight="700" letterSpacing="-0.02em" lineHeight="27px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > 제주국제관악제 최신 소식 확인하기 - - + + instagram_15713420 1 + facebook_1384053 1 - + Layer 2 + 문의 : 064-722-8704 /
    bandfestival@hanmail.net diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap index 68e1af6e..0f7a42dd 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap @@ -42,6 +42,7 @@ expression: actual Frame 568 - “ 섬, 그 바람의 울림! ”
    희망찬 봄을 여는 관악의 울림이 더
    널리,
    더 멀리 퍼져나가기를 기원합니다.
    + “ 섬, 그 바람의 울림! ”
    희망찬 봄을 여는 관악의 울림이 더
    널리, 더 멀리 퍼져나가기를 기원합니다.{" "}
    음악과 자연이 함께 어우러지는 제주국제관악제가 30주년을 맞게 되었습니다.

    제주국제관악제를 아껴주시는 모든 분들과 함께 세계 속의 제주관악의 역사를 써 왔음에 깊이 감사드립니다.
    그동안 힘들고 어려운 순간들 속에서도 관악의 힘찬 울림은 우리에게 큰 위로와 보람을 안겨주었습니다.
    새로운 세대를 여는 앞으로의 시간과 함께 켜켜이 쌓여가는 역사 속에서, 세계를 향한 우리의 각오와 노력이 더해질 것입니다.

    희망찬 봄을 여는 관악의 울림이 더 널리, 더 멀리 퍼져나가기를 기원합니다.
    감사합니다. @@ -93,9 +96,11 @@ expression: actual fontWeight="400" letterSpacing="-0.02em" lineHeight="29px" + overflowWrap="break-word" + whiteSpace="pre" wordBreak="keep-all" > - 제주국제관악제조직위원장
    + 제주국제관악제조직위원장{" "}
    양승보 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-254-9f340b6a10.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-254-9f340b6a10.snap index ac526e32..ebd94d2e 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-254-9f340b6a10.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-254-9f340b6a10.snap @@ -40,6 +40,7 @@ expression: actual w="100%" > */} -